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 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 3775@bnt-lap001 Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3
This commit is contained in:
parent
790aab025f
commit
f15ff61362
3 changed files with 55 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue