diff --git a/credential-change-requests/CCR-2026-0026-informed-decision-sitting-requester-verifier.yaml b/credential-change-requests/CCR-2026-0026-informed-decision-sitting-requester-verifier.yaml index 17d4a3d..57dffa3 100644 --- a/credential-change-requests/CCR-2026-0026-informed-decision-sitting-requester-verifier.yaml +++ b/credential-change-requests/CCR-2026-0026-informed-decision-sitting-requester-verifier.yaml @@ -28,6 +28,15 @@ review: decision: approved comment: User instructed register sitting-requester. Apply remains the attended helper with CAS=0 custody, exact create-only client, and no sitting POST. + - at: '2026-11-01' + reviewer: railiance-platform + decision: amendment_proposed + comment: 'key-cape 82b471f (founder decision 2026-11-01) adds approval:read to client + informed-decision-sitting-requester for informed-decision present_existing intake. + Text-only amendment, see amendments: custody path, field, policy, role, bound identity, + ESO delivery and env binding are unchanged, so no new CCR and no OpenBao change. + Needs platform-owner and key-cape-owner approval, recorded by the founder''s scope + decision, before the live keycape-client.py remove-then-add.' target: domain: financials tenant: platform @@ -108,3 +117,17 @@ state_hub: task_id: RPF-WP-0042-T01 related_request: CCR-2026-0027 related_workplan: INFD-WP-0002 +amendments: +- at: '2026-11-01' + by: railiance-platform + status: proposed + change: 'Scope of the client this credential authenticates widens from approval:create + to approval:create + approval:read (key-cape 82b471f, docs/sitting-requester-read-scope.md; + founder decision 2026-11-01). Still never approval:consume, approval:approve or + openid. Where this record says create-only, approval:create only or no read scope, + read: create and read, never consume, approve or openid. Residual: an exchange without + a scope parameter yields a create+read token (key-cape token.go). The 2026-09-15 + exchange proof (scripts/prove-sitting-requester-exchange.py) stays historical + evidence of the create-only shape; after the live apply the new shape is verified + as in the key-cape doc (read 200 with scope exactly approval:read; consume and approve + 400). No OpenBao object, custody value or delivery changes; status stays applied.' diff --git a/credential-change-requests/CCR-2026-0027-informed-decision-sitting-requester-reader.yaml b/credential-change-requests/CCR-2026-0027-informed-decision-sitting-requester-reader.yaml index 7473252..1e22d18 100644 --- a/credential-change-requests/CCR-2026-0027-informed-decision-sitting-requester-reader.yaml +++ b/credential-change-requests/CCR-2026-0027-informed-decision-sitting-requester-reader.yaml @@ -27,6 +27,15 @@ review: decision: approved comment: User instructed register sitting-requester. Apply remains the attended helper with CAS=0 custody, exact create-only client, and no sitting POST. + - at: '2026-11-01' + reviewer: railiance-platform + decision: amendment_proposed + comment: 'key-cape 82b471f (founder decision 2026-11-01) adds approval:read to client + informed-decision-sitting-requester for informed-decision present_existing intake. + Text-only amendment, see amendments: custody path, field, policy, role, bound identity, + ESO delivery and env binding are unchanged, so no new CCR and no OpenBao change. + Needs platform-owner and key-cape-owner approval, recorded by the founder''s scope + decision, before the live keycape-client.py remove-then-add.' target: domain: financials tenant: platform @@ -117,3 +126,17 @@ state_hub: task_id: RPF-WP-0042-T01 related_request: CCR-2026-0026 related_workplan: INFD-WP-0002 +amendments: +- at: '2026-11-01' + by: railiance-platform + status: proposed + change: 'Scope of the client this credential authenticates widens from approval:create + to approval:create + approval:read (key-cape 82b471f, docs/sitting-requester-read-scope.md; + founder decision 2026-11-01). Still never approval:consume, approval:approve or + openid. Where this record says create-only, approval:create only or no read scope, + read: create and read, never consume, approve or openid. Residual: an exchange without + a scope parameter yields a create+read token (key-cape token.go). The 2026-09-15 + exchange proof (scripts/prove-sitting-requester-exchange.py) stays historical + evidence of the create-only shape; after the live apply the new shape is verified + as in the key-cape doc (read 200 with scope exactly approval:read; consume and approve + 400). No OpenBao object, custody value or delivery changes; status stays applied.' diff --git a/scripts/provision-sitting-requester.py b/scripts/provision-sitting-requester.py index 079d89d..da464d2 100644 --- a/scripts/provision-sitting-requester.py +++ b/scripts/provision-sitting-requester.py @@ -28,14 +28,31 @@ rollout = load('requester_rollout', '/home/worsch/net-kingdom/sso-mfa/k8s/keycap rollout.IDS = ('informed-decision-sitting-requester',) +# key-cape 82b471f (founder decision 2026-11-01): create + read for present_existing intake. +# Still never consume, approve or openid. Custody is unchanged (CCR-2026-0026/0027 amendment). +SCOPES = ['approval:create', 'approval:read'] +RERUN_REFUSAL = ('sitting requester already provisioned (receipt status applied, 2026-09-15); this ' + 'one-shot helper does not re-run. A registration change, such as the 2026-11-01 ' + 'approval:read scope, is a founder-run remove-then-add through net-kingdom ' + 'sso-mfa/k8s/keycape/keycape-client.py: see key-cape docs/sitting-requester-read-scope.md.') + + +def already_provisioned(): + try: + return json.loads(RECEIPT.read_text()).get('status') == 'applied' + except (OSError, ValueError): + return False + + def contract(): require(CLIENT['clientId'] == 'informed-decision-sitting-requester', 'requester_contract_drift') - require(CLIENT['allowedScopes'] == ['approval:create'], 'requester_contract_drift') + require(CLIENT['allowedScopes'] == SCOPES, 'requester_contract_drift') require(CLIENT['serviceSubject'] == 'informed-decision', 'requester_contract_drift') require(CLIENT['audience'] == 'approval-engine', 'requester_contract_drift') require(CLIENT['tenant'] == 'tenant:platform', 'requester_contract_drift') require('approval:consume' not in CLIENT['allowedScopes'], 'consume_scope_forbidden') require('approval:approve' not in CLIENT['allowedScopes'], 'approve_scope_forbidden') + require('openid' not in CLIENT['allowedScopes'], 'openid_scope_forbidden') def run(receipt): @@ -92,6 +109,10 @@ def run(receipt): if __name__ == '__main__': + # Refuse before the receipt handler below, which would overwrite the 2026-09-15 evidence. + if already_provisioned(): + sys.stderr.write('refused: ' + RERUN_REFUSAL + '\n') + raise SystemExit(2) receipt = {'observed_at': datetime.now(timezone.utc).isoformat(), 'status': 'failed', 'phase': 'preflight', 'ccrs': list(CCRS), 'credential_values_emitted': False, 'approval_mutations': False, 'sitting_post': False} try: run(receipt) diff --git a/tests/test_sitting_requester_provisioning.py b/tests/test_sitting_requester_provisioning.py index bf88d64..0723fbb 100644 --- a/tests/test_sitting_requester_provisioning.py +++ b/tests/test_sitting_requester_provisioning.py @@ -9,28 +9,47 @@ spec.loader.exec_module(m) class SittingRequesterProvisioningTests(unittest.TestCase): - def test_contract_is_create_only_informed_decision(self): + def test_contract_is_create_and_read_informed_decision(self): + # key-cape 82b471f, founder decision 2026-11-01: approval:read for present_existing. m.contract() self.assertEqual(m.CLIENT['clientId'], 'informed-decision-sitting-requester') - self.assertEqual(m.CLIENT['allowedScopes'], ['approval:create']) + self.assertEqual(m.CLIENT['allowedScopes'], ['approval:create', 'approval:read']) self.assertEqual(m.CLIENT['serviceSubject'], 'informed-decision') self.assertEqual(m.CCRS, ('CCR-2026-0026', 'CCR-2026-0027')) self.assertNotIn('CCR-2026-0024', m.CCRS) self.assertNotIn('CCR-2026-0025', m.CCRS) - def test_contract_refuses_widened_scopes(self): + def test_contract_refuses_widened_or_narrowed_scopes(self): original = list(m.CLIENT['allowedScopes']) - m.CLIENT['allowedScopes'] = ['approval:create', 'approval:consume'] - with self.assertRaises(Exception): - m.contract() - m.CLIENT['allowedScopes'] = original + try: + for scopes in (['approval:create', 'approval:read', 'approval:consume'], + ['approval:create', 'approval:read', 'approval:approve'], + ['approval:create', 'approval:read', 'openid'], + ['approval:create', 'approval:consume'], + ['approval:create'], + ['approval:read', 'approval:create']): + m.CLIENT['allowedScopes'] = scopes + with self.assertRaises(Exception, msg=scopes): + m.contract() + finally: + m.CLIENT['allowedScopes'] = original + + def test_one_shot_refuses_rerun_and_names_remove_then_add(self): + self.assertTrue(m.already_provisioned()) + self.assertIn('keycape-client.py', m.RERUN_REFUSAL) + self.assertIn('remove-then-add', m.RERUN_REFUSAL) + source = Path(m.__file__).read_text() + main = source.split("if __name__ == '__main__':", 1)[1] + self.assertLess(main.index('already_provisioned()'), main.index('RECEIPT.write_text')) def test_source_keycape_registration_matches_helper(self): import yaml clients = yaml.safe_load(Path('/home/worsch/key-cape/config/service-clients.example.yaml').read_text())['clients'] named = next(c for c in clients if c['clientId'] == 'informed-decision-sitting-requester') self.assertEqual(named['secretRef'], 'env:' + m.ENV_NAME) - self.assertEqual(named['allowedScopes'], ['approval:create']) + self.assertEqual(named['allowedScopes'], ['approval:create', 'approval:read']) + for forbidden in ('approval:consume', 'approval:approve', 'openid'): + self.assertNotIn(forbidden, named['allowedScopes']) self.assertEqual(named['serviceSubject'], 'informed-decision')