From f15ff613627301214160e910374beac4b6710fd7 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 3 Oct 2026 10:07:54 +0200 Subject: [PATCH] CCR-2026-0031: choose the private probe repo with the admin token A read:repository-only PAT gets HTTP 403 on GET /orgs/coulomb/repos (needs read:organization). The 2026-10-03 attended run failed there and rolled back (PAT deleted, nothing written to OpenBao). The source token is still verified against the probe exactly as before: branch, archive, /admin/users and Actions secrets denials. Also records the delegated metadata apply evidence. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 3775@bnt-lap001 Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3 --- ...026-0031-core-hub-forgejo-source-read.yaml | 34 +++++++++++++++++-- scripts/provision-core-hub-forgejo-source.py | 17 ++++++---- .../test_core_hub_forgejo_source_provision.py | 13 ++++++- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/credential-change-requests/CCR-2026-0031-core-hub-forgejo-source-read.yaml b/credential-change-requests/CCR-2026-0031-core-hub-forgejo-source-read.yaml index c794465..23d96bf 100644 --- a/credential-change-requests/CCR-2026-0031-core-hub-forgejo-source-read.yaml +++ b/credential-change-requests/CCR-2026-0031-core-hub-forgejo-source-read.yaml @@ -3,7 +3,7 @@ kind: credential-change-request schema_version: 1 request_type: workload-kv-read title: Core Hub repo-manager publisher Forgejo private-source read token lane -status: approved +status: applied created: '2026-10-03' updated: '2026-10-03' requester: @@ -142,7 +142,37 @@ verification: - The ClusterSecretStore and ExternalSecret are committed and synced; rapp-core-hub mounts the Secret and passes the positive and negative checks above. - ops-warden catalog entry core-hub-forgejo-source-read is added and reviewed. - evidence: [] + evidence: + - at: '2026-10-03T06:40:16+00:00' + actor: attended operator via governed platform-admin lane + kind: delegated_metadata_apply + result: passed + details: + - Delegated metadata applier ran as attended operator via governed platform-admin + lane using local bao CLI ambient authority. + - 'Policy metadata write: sys/policies/acl/workload-kv-read-core-hub-forgejo-source' + - 'Auth role metadata write: auth/kubernetes/role/external-secrets-core-hub-forgejo-source' + - No secret values were read, written, printed, or accepted in argv. + - at: '2026-10-03T08:06:12+00:00' + actor: attended operator via governed platform-admin lane + kind: delegated_metadata_apply + result: passed + details: + - Delegated metadata applier ran as attended operator via governed platform-admin + lane using local bao CLI ambient authority. + - 'Policy metadata write: sys/policies/acl/workload-kv-read-core-hub-forgejo-source' + - 'Auth role metadata write: auth/kubernetes/role/external-secrets-core-hub-forgejo-source' + - No secret values were read, written, printed, or accepted in argv. + - at: '2026-10-03T08:06:37+00:00' + actor: attended operator via governed platform-admin lane + kind: delegated_metadata_apply + result: passed + details: + - Delegated metadata applier ran as attended operator via governed platform-admin + lane using local bao CLI ambient authority. + - 'Policy metadata write: sys/policies/acl/workload-kv-read-core-hub-forgejo-source' + - 'Auth role metadata write: auth/kubernetes/role/external-secrets-core-hub-forgejo-source' + - No secret values were read, written, printed, or accepted in argv. lifecycle: deactivate: Delete ExternalSecret and Secret in core-hub, unset publisher.existingSecret, revoke the PAT, disable the auth role, and leave private sources failing closed. diff --git a/scripts/provision-core-hub-forgejo-source.py b/scripts/provision-core-hub-forgejo-source.py index a23422e..f8dd34c 100755 --- a/scripts/provision-core-hub-forgejo-source.py +++ b/scripts/provision-core-hub-forgejo-source.py @@ -193,9 +193,15 @@ def ensure_team(admin_token: str) -> int: return team_id -def find_private_probe(source_token: str) -> str: +def find_private_probe(admin_token: str) -> str: + """Pick a private org repository to probe. + + Listing org repositories needs read:organization, which the least-privilege + source token deliberately lacks (Forgejo returns 403), so the admin token + chooses the probe and the source token is then tested against it. + """ for page in range(1, 6): - _, repos = api_request(source_token, "GET", f"/orgs/{ORG}/repos?limit=50&page={page}") + _, repos = api_request(admin_token, "GET", f"/orgs/{ORG}/repos?limit=50&page={page}") if not isinstance(repos, list): raise ProvisionError("Forgejo returned an invalid repository list") for repo in repos: @@ -203,13 +209,12 @@ def find_private_probe(source_token: str) -> str: return str(repo["name"]) if len(repos) < 50: break - raise ProvisionError("source token sees no private repository in the org") + raise ProvisionError("no private repository found in the org to probe") -def verify_source_token(source_token: str, scopes: object) -> str: +def verify_source_token(source_token: str, scopes: object, probe: str) -> str: if scopes != SCOPES: raise ProvisionError("Forgejo returned scopes other than exactly read:repository") - probe = find_private_probe(source_token) _, repo = api_request(source_token, "GET", f"/repos/{ORG}/{probe}") branch_name = repo.get("default_branch") if isinstance(repo, dict) else None if not isinstance(branch_name, str) or not branch_name: @@ -304,7 +309,7 @@ def provision(admin_token: str) -> str: source_token = str(created.get("sha1") or "") if not source_token: raise ProvisionError("Forgejo did not return the new access token") - probe = verify_source_token(source_token, created.get("scopes")) + probe = verify_source_token(source_token, created.get("scopes"), find_private_probe(admin_token)) verify_effective_permission(admin_token, probe) stored = bao( diff --git a/tests/test_core_hub_forgejo_source_provision.py b/tests/test_core_hub_forgejo_source_provision.py index 849e069..f17d6f6 100644 --- a/tests/test_core_hub_forgejo_source_provision.py +++ b/tests/test_core_hub_forgejo_source_provision.py @@ -60,6 +60,7 @@ class Harness: with ( mock.patch.object(MODULE, "ensure_user"), mock.patch.object(MODULE, "ensure_team", return_value=7), + mock.patch.object(MODULE, "find_private_probe", return_value="private-probe") as probe_finder, mock.patch.object(MODULE, "verify_source_token", return_value="private-probe"), mock.patch.object(MODULE, "verify_effective_permission"), mock.patch.object(MODULE, "record_evidence") as evidence, @@ -67,6 +68,7 @@ class Harness: mock.patch.object(MODULE, "bao", side_effect=self.bao), ): self.evidence = evidence + self.probe_finder = probe_finder return MODULE.provision("admin-token") def revoked(self): @@ -101,7 +103,7 @@ class ContractTests(unittest.TestCase): def test_broader_scope_rejected_before_network(self) -> None: with mock.patch.object(MODULE, "api_request") as request: with self.assertRaises(MODULE.ProvisionError): - MODULE.verify_source_token(SENTINEL, ["read:repository", "write:repository"]) + MODULE.verify_source_token(SENTINEL, ["read:repository", "write:repository"], "priv") request.assert_not_called() def test_errors_never_carry_query_strings_or_unexpected_detail(self) -> None: @@ -123,6 +125,15 @@ class ContractTests(unittest.TestCase): self.assertEqual(MODULE.find_private_probe(SENTINEL), "priv") + def test_probe_is_listed_with_the_admin_token_not_the_source_token(self) -> None: + """2026-10-03: a read:repository-only PAT gets 403 on /orgs/{org}/repos.""" + repos = [{"name": "priv", "private": True}] + with mock.patch.object(MODULE, "api_request", return_value=(200, repos)) as request: + self.assertEqual(MODULE.find_private_probe("admin-token"), "priv") + self.assertEqual(request.call_args.args[0], "admin-token") + self.assertTrue(request.call_args.args[2].startswith("/orgs/coulomb/repos")) + + class ProvisionFlowTests(unittest.TestCase): def test_fresh_mint_writes_value_only_via_stdin(self) -> None: h = Harness()