diff --git a/docs/evidence/g01-broker.md b/docs/evidence/g01-broker.md index a176be48..800fb7c4 100644 --- a/docs/evidence/g01-broker.md +++ b/docs/evidence/g01-broker.md @@ -25,7 +25,7 @@ The historical unversioned broker approval schema included `discover-actions-hos Runner-group and private repository inspection require an installation/user authority; App JWT alone cannot perform those checks. The agreed executable sequence is therefore: -0. Validate private inputs and the supported native controller build; reserve, write and sync the exact non-secret controller snapshot when applicable. Acquire the fixed account ledger, pin resources/controller state, consume this phase slot durably, and check compatible existing controller inventory. For controller mode, run the credential-free canonical local-state preparation and validate its exact receipt before any authenticated request. These local checks precede every API call. +0. Validate private inputs and the supported native controller build; open and durably prepare the exact per-attempt journal. For controller/paired modes, before claiming a phase slot, hold the fixed account-ledger lock, append the non-secret workflow-provenance GET intent to the attempt journal, and sync `workflow_provenance_get_started` to the account ledger. Perform the authenticated workflow-run GET under that lock; append the validated observation to the attempt journal and sync `workflow_provenance_get_confirmed` before releasing the lock. Only after that confirmation does the broker write/sync the controller snapshot, consume the phase slot durably and run credential-free local preparation. These local checks precede every later API call. On ledger replay, each historical reusable-callee claim is also checked against its confirmed GET event: slot, attempt and journal identities, controller-approval digest, provenance source, workflow run ID, ref, SHA and path must all match before recovery can reuse the claim. 1. Verify App, owner and organization installation with the App JWT. 2. Flush a non-secret token issuance intent. 3. Flush the non-secret read-token intent, then immediately before dispatching the installation-token POST, sync `read_token_lifecycle_started` to the fixed account admission ledger. If that sync cannot be established, do not send the request. When a v3 attempt exits before this boundary, sync `read_token_request_not_dispatched`; this preserves later recovery without claiming a request happened. Request a token restricted to `repository_ids: [exact_canary_id]` and exactly `organization_self_hosted_runners: read` plus `metadata: read`. The organization-only scope requests only `organization_self_hosted_runners: read` and no repository IDs. If the response exposes a syntactically valid token but fails typed decoding, expiry or exact-scope validation, do not use it for provider reads; make one self-revocation attempt under the already-durable lifecycle intent. If the returned read token equals the caller workflow-verification token, record the collision and stop before provider-state preflight or DELETE, leaving the lifecycle unresolved. @@ -35,7 +35,7 @@ Runner-group and private repository inspection require an installation/user auth Repository restriction does **not** cryptographically restrict the organization-level self-hosted-runners permission to one runner group. The broker and reviewed controller enforce the selected canary policy, under the trusted-controller assumption. This boundary assumes a trusted controller UID and host administrators; file modes and a stdin pipe do not isolate either. The read-only preflight token is issued by the existing App; no separate user or organization credential is requested. The broker never adds Actions permission to the G02 App or mints the separate workflow authority. -A failed/ambiguous token request, failed policy check, expired credential, uncertain handoff or error leaves the intent and stops. This account-wide start is written before the POST can be dispatched, so a lost success response remains an unresolved lifecycle even when no token candidate is available to revoke. The v3 reusable-callee route makes one self-revocation attempt for each usable read-token candidate, including a token rejected for expiry or scope, and does not run provider preflight for rejected issuance. The per-attempt journal records `read_only_token_revocation_started` before DELETE. A caller-token collision records `read_only_token_revocation_skipped` and stops without a provider request, leaving the account intent pending. After a successful DELETE and a durable per-attempt `read_only_token_revoked` record, the broker syncs `read_token_lifecycle_confirmed` to the account ledger. If the per-attempt success record or the account confirmation cannot be synced, a best-effort `read_only_token_revocation_uncertain` record is attempted and the unmatched lifecycle intent blocks every later phase, including `inspect` and `cleanup`, before another authenticated request, management-token issuance or child handoff. An incomplete legacy reusable-callee claim without lifecycle or no-dispatch evidence also fails closed because its earlier POST outcome cannot be reconstructed. Correctly paired historical `read_token_revocation_started` / `read_token_revocation_confirmed` records remain accepted as completed history. The next open re-syncs the ledger before interpreting a visible confirmation. There is no automatic retry or token refresh/replacement. An uncertain revocation may leave the read token valid until expiry. A management token issued before a later failure may also remain valid until expiry; it is not automatically revoked. A future operator approval must include both potential issuances and the read-token revocation effect, not just the eventual read/phase. The maximum remaining operation lifetime is ten minutes, approval expiry, or one minute before the management installation-token expiry, whichever occurs first. +A failed/ambiguous token request, failed policy check, expired credential, uncertain handoff or error leaves the intent and stops. The initial authenticated workflow-run GET has the same account-wide uncertainty rule: its per-attempt journal intent and account-ledger start precede dispatch, and the fixed account lock remains held through observation and confirmation. A timeout, transport failure, lost response, crash-visible start or failed confirmation is ambiguous; it leaves `workflow_provenance_get_started` pending and blocks every later phase, including `inspect` and `cleanup`, before another workflow GET, installation-token issuance or child handoff. Only a failure proven before HTTP dispatch may append matching per-attempt and account `workflow_provenance_get_not_dispatched`; that explicit outcome permits a later different recovery slot, but the consumed attempt and exact same phase are not automatically retried. The v3 reusable-callee route makes one self-revocation attempt for each usable read-token candidate, including a token rejected for expiry or scope, and does not run provider preflight for rejected issuance. The per-attempt journal records `read_only_token_revocation_started` before DELETE. A caller-token collision records `read_only_token_revocation_skipped` and stops without a provider request, leaving the account intent pending. After a successful DELETE and a durable per-attempt `read_only_token_revoked` record, the broker syncs `read_token_lifecycle_confirmed` to the account ledger. If the per-attempt success record or the account confirmation cannot be synced, a best-effort `read_only_token_revocation_uncertain` record is attempted and the unmatched lifecycle intent blocks every later phase, including `inspect` and `cleanup`, before another authenticated request, management-token issuance or child handoff. An incomplete legacy reusable-callee claim without workflow/read lifecycle or no-dispatch evidence also fails closed because its earlier outcomes cannot be reconstructed. Correctly paired historical `read_token_revocation_started` / `read_token_revocation_confirmed` records remain accepted as completed history. The next open re-syncs the ledger before interpreting a visible confirmation. There is no automatic retry or token refresh/replacement. An uncertain workflow observation or revocation may leave the corresponding authority valid until expiry. A management token issued before a later failure may also remain valid until expiry; it is not automatically revoked. A future operator approval must include both potential issuances and the workflow/read-token effects, not just the eventual read/phase. The maximum remaining operation lifetime is ten minutes, approval expiry, or one minute before the management installation-token expiry, whichever occurs first. The self-revocation request uses the [GitHub App installation-token revocation endpoint](https://docs.github.com/en/rest/apps/installations#revoke-an-installation-access-token), which accepts an installation access token and requires no additional permissions. This API contract is official documentation; fixture evidence does not prove live GitHub behavior for the selected installation. diff --git a/docs/evidence/g01-live-canary.md b/docs/evidence/g01-live-canary.md index e1e9cbff..2edaff48 100644 --- a/docs/evidence/g01-live-canary.md +++ b/docs/evidence/g01-live-canary.md @@ -829,3 +829,100 @@ roots, broker-only execution, management-token lifecycle, conditional Scale Set deletion, trusted runtime/isolation/resource qualification, the authorized Linux ARM64/JIT canary and sanitized live exit evidence remain open. Issue #1 stays OPEN and Project #2 stays In progress. + +### Workflow-run provenance GET accounting fence — 2026-11-04 + +Issue #160 starts from the exact local base `a4d6f03b81cbc77f51bf9eb0d37fdf47d67c02f3`. +The initial authenticated workflow-run GET now has a two-record lifecycle in +the fixed account ledger, bound to the already-open per-attempt journal, +attempt/journal device+inode, controller-approval digest, source and exact run +identity. The per-attempt intent is durable first; the account start is synced +before dispatch; the account confirmation follows the validated GET and durable +per-attempt observation while the account lock remains held. Timeout, transport +failure, lost response, crash-visible start and confirmation failure remain +pending and block later replay before any installation-token mint. Only an +explicitly durable pre-dispatch outcome permits a later different recovery +slot; the consumed attempt and same phase are not automatically retried. + +The causal fixture records the workflow GET itself in its ordered event stream, +including the synthetic lost-response and timeout cases, while its API fixture +rejects any installation-token mint before the workflow fence. A journal-boundary +failure records `workflow_provenance_get_not_dispatched` and permits a later +inspect; ambiguous GET outcomes leave `workflow_provenance_get_started` without +confirmation and block the inspect before any API call. + +Historical local offline verification at exact candidate head +`6950a80639931e497b55e665049af4d4956eacdf` used the cached Go 1.27.3 toolchain +with external GOCACHE/GOMODCACHE/GOTMPDIR and downloads disabled: + +The RED fixture files were applied to a clean archive of exact base +`a4d6f03b81cbc77f51bf9eb0d37fdf47d67c02f3`; no implementation files were +changed in that archive. The identical selector was rerun on a fresh archive +of the same base. Both runs showed no account workflow intent, then `inspect` +completed and minted twice. + +| Check | Result | +|---|---| +| `go test -race -count=1 -timeout=120s -run '^TestBrokerExperimentV3LostWorkflowProvenanceResponseBlocksLaterInspect$' .` on exact base | FAIL as expected; `inspect` completed (`controller_completed`) and minted twice | +| Same RED command on a fresh archive of exact base | Same failure and behavior | +| Focused lost-response, timeout, no-dispatch and replay tests on candidate | PASS (4.573s) | +| G02 `go test -race -p=1 -count=1 -timeout=300s ./...` | PASS (all packages; library 138.676s) | +| G02 `go vet -p=1 ./...` | PASS | +| Provider/credential/workflow/runner/Scale Set/Docker/host/live-canary actions | Not run | + +These are synthetic offline fixture results only. No provider credential or +installation token was minted, no workflow or runner was invoked, and no live +canary operation was attempted. At this historical `6950a80` candidate, +independent exact-head review and hosted quick checks were still pending; the +later PR #161 status is recorded below. All live authorization gates remain +unresolved. + +### Historical workflow-provenance claim replay binding — 2026-11-04 + +Independent Luna Max review of PR #161 at exact head +`b9ca7c96c7881391a1cc9ed6f06b6a7848e9f1b4` found a P2 replay-binding gap: +historical reusable-callee claims required a confirmed workflow GET record to +exist, but did not require that record to match the claim's authority and +workflow identity. A test-only overlay on a clean archive of that exact source +reproduced the issue: after changing the historical event's attempt/journal, +approval digest and workflow identity while retaining the claim, `inspect` +completed and minted twice. + +Commit `eefe8dfde5134b014b54ca45277640c6401c0395` fixes replay by matching the +claim to the confirmed record across slot, attempt and journal identities, +controller-approval digest, provenance source, workflow run ID, ref, SHA and +path. The exact-base RED was reproduced twice; the corrected focused race test +passed in 4.498s, the full G02 race suite passed all packages (root package +143.977s), and `go vet -p=1 ./...` passed. These checks were offline and used +the cached Go 1.27.3 toolchain with caches and temporary files configured on an +external volume. + +The corrected code and initial regression test were pushed as PR head +`73c1299319441da6499dca55b79c237a3df5e4e2`; hosted Go checks passed and GitHub +Codex review completed on that exact SHA with no major findings. The subsequent +independent Luna Max review returned HOLD/P2: the regression changed several +binding fields together, lacked isolated slot/source cases and did not name a +matching-history positive control. The reviewer found the source comparisons +correct and reported no P0/P1 source defect. A follow-up test-only change now +mutates slot, attempt, journal, approval digest, source, run ID, ref, SHA and +path independently, checks each rejection before API calls or minting, and +includes a named matching-history replay control. On a clean archive of exact +prior head `b9ca7c96c7881391a1cc9ed6f06b6a7848e9f1b4` with only this test file +overlaid, the focused race test reproduced unsafe replay for each mismatched +field except slot (which already failed closed); the matching-history control +passed. The same focused race selector passes on the corrected local source, +covering all nine mismatch cases and the positive control in 7.673s. + +The test refinement was committed and pushed in PR head +`f0b4e97d323a0dc637dfe6cff9c74fa62ca1d922`; hosted Go checks passed. At the +time of the independent Luna Max re-review, the exact-head Codex review was +still running. Luna Max confirmed the P2 binding-test gap was resolved and no +source P0/P1/P2 finding remained, but held overall because this section still +described the pushed candidate as local and pending. The historical `6950a80` +pending-review statements above are now explicitly labeled as such. At the time +this review result arrived, the documentation correction was committed locally +but not pushed; the follow-up PR head still required a hosted check +and fresh exact-head Codex/Luna Max reviews. No live GitHub provider, +credential, runner, Scale Set, Docker or host operation was performed. Issue +#160 and parent issue #1 remain OPEN / Project #2 In progress; all live canary +and production qualification gates remain unresolved. diff --git a/experiments/g02-auth/README.md b/experiments/g02-auth/README.md index e4ecdda8..0a916e4e 100644 --- a/experiments/g02-auth/README.md +++ b/experiments/g02-auth/README.md @@ -58,10 +58,27 @@ Controller and paired-terminal entrypoints require a one-shot signed `BrokerProvenanceReceipt` before reading credential input. Its Ed25519 payload binds the controller-approval SHA-256, workflow run ID, workflow ref and commit, selected outer phase, owner nonce, receipt nonce, and named source. -The admission ledger retains the receipt digest, receipt nonce, and source so a -completed receipt cannot be replayed for another claim; direct regular-file, -FIFO, or tagged-entry paths without a receipt stop before credential input or -GitHub API effects. +The authenticated workflow-run GET that supplies the receipt is itself +accounted: the per-attempt journal records its non-secret intent, then the +fixed account admission ledger records `workflow_provenance_get_started` +before dispatch and `workflow_provenance_get_confirmed` only after the +validated observation and journal record are durable. The ledger retains the +receipt digest, receipt nonce, and source so a completed receipt cannot be +replayed for another claim; direct regular-file, FIFO, or tagged-entry paths +without a receipt stop before credential input or GitHub API effects. +During account-ledger replay, every historical reusable-callee claim must +match its confirmed GET event by slot, attempt and journal identities, +controller-approval digest, source, workflow run ID, ref, SHA and path. A +present confirmation with mismatched claim identity is rejected before API +calls or token minting. + +Timeout, transport failure, lost response, crash-visible start, or failed +confirmation leaves the workflow GET intent pending and blocks later replay +before installation-token minting. Only a failure proven before HTTP dispatch +may record the matching `workflow_provenance_get_not_dispatched` outcome; +that permits a later different recovery slot, while the consumed attempt and +same phase are never automatically retried. No provider credential or +workflow action is used by these fixture tests. The current GitHub REST response does not attest which workflow input selected the controller phase or whether an ambiguous `head_branch` names a branch or a diff --git a/experiments/g02-auth/broker.go b/experiments/g02-auth/broker.go index 2d3dd555..7317b379 100644 --- a/experiments/g02-auth/broker.go +++ b/experiments/g02-auth/broker.go @@ -63,9 +63,10 @@ const organizationOnlyManagementScope = "organization-only" func brokerSlotAllowed(slot string) bool { return brokerPhases[slot] || brokerSpecialSlots[slot] } // One header, one claim and one completion for every finite schema slot, up to -// one read-token revocation start/confirmation pair per slot, and the final -// empty split element from the required trailing newline. -func brokerLedgerMaxLines() int { return 1 + 4*(len(brokerPhases)+len(brokerSpecialSlots)) + 1 } +// one workflow-provenance GET start/confirmation pair and one read-token +// revocation start/confirmation pair per slot, plus the final empty split +// element from the required trailing newline. +func brokerLedgerMaxLines() int { return 1 + 6*(len(brokerPhases)+len(brokerSpecialSlots)) + 1 } const ( // Terminal collection has seven five-second cadence gaps in production. diff --git a/experiments/g02-auth/broker_admission.go b/experiments/g02-auth/broker_admission.go index 22174c54..09a28b72 100644 --- a/experiments/g02-auth/broker_admission.go +++ b/experiments/g02-auth/broker_admission.go @@ -78,9 +78,10 @@ func brokerOwnedDirectory(i os.FileInfo, private bool) bool { } // The finite slot schema is the union of the named phases and special slots. -// Each stored controller authority is bounded by the 16 KiB input -// contract; allow one claim/completion and one read-token revocation -// start/confirmation pair for every schema slot. +// Each stored controller authority is bounded by the 16 KiB input contract; +// allow one claim/completion, one workflow-provenance GET start/confirmation +// pair and one read-token revocation start/confirmation pair for every schema +// slot. const maxBrokerLedgerBytes = 512 << 10 type brokerInode struct { @@ -155,6 +156,24 @@ type brokerReadTokenLifecycleEvent struct { Attempt brokerInode `json:"attempt"` Journal brokerInode `json:"journal"` } + +// brokerWorkflowProvenanceEvent binds the authenticated workflow-run GET to +// the exact attempt, journal, approval and observed run identity. The event +// carries no credential or response body. A started event is durable before +// dispatch; only its exact confirmation or an explicit no-dispatch outcome +// may release replay. +type brokerWorkflowProvenanceEvent struct { + Kind string `json:"kind"` + Slot string `json:"slot"` + Attempt brokerInode `json:"attempt"` + Journal brokerInode `json:"journal"` + ControllerApprovalSHA256 string `json:"controller_approval_sha256"` + Source string `json:"source"` + WorkflowRunID int64 `json:"workflow_run_id"` + WorkflowRef string `json:"workflow_ref"` + WorkflowSHA string `json:"workflow_sha"` + WorkflowPath string `json:"workflow_path"` +} type brokerLedgerHeader struct { Version int `json:"version"` Resource string `json:"resource"` @@ -171,6 +190,12 @@ type brokerAdmission struct { data []byte event brokerClaimEvent readTokenLifecyclePending bool + workflowProvenancePending *brokerWorkflowProvenanceEvent + workflowProvenanceConfirmed map[string]brokerWorkflowProvenanceEvent + workflowProvenanceNoDispatch map[string]bool + ledgerOnly bool + ledgerSlot string + ledgerAttempt, ledgerJournal brokerInode sync func(*os.Root) error } @@ -269,7 +294,61 @@ func (c *brokerAdmission) recordReadTokenRequestNotDispatched() error { } return nil } -func openBrokerAdmission(directory string, a BrokerApproval, j *brokerJournal, p *brokerControllerPlan, syncRoot func(*os.Root) error) (claim *brokerAdmission, err error) { + +func (c *brokerAdmission) beginWorkflowProvenance(event brokerWorkflowProvenanceEvent) error { + if c == nil || !c.ledgerOnly || c.workflowProvenancePending != nil || !validBrokerWorkflowProvenanceEvent(event) || event.Kind != "workflow_provenance_get_started" { + return errBroker + } + _, confirmed := c.workflowProvenanceConfirmed[event.Slot] + if event.Slot != c.ledgerSlot || event.Attempt != c.ledgerAttempt || event.Journal != c.ledgerJournal || confirmed || c.workflowProvenanceNoDispatch[event.Slot] || c.check() != nil { + return errBroker + } + if c.append(event) != nil || c.check() != nil { + return errBroker + } + c.workflowProvenancePending = &event + return nil +} + +func (c *brokerAdmission) confirmWorkflowProvenance(event brokerWorkflowProvenanceEvent) error { + if c == nil || !c.ledgerOnly || c.workflowProvenancePending == nil || event.Kind != "workflow_provenance_get_confirmed" || !sameBrokerWorkflowProvenance(*c.workflowProvenancePending, event) || c.check() != nil { + return errBroker + } + if c.append(event) != nil || c.check() != nil { + return errBroker + } + c.workflowProvenanceConfirmed[event.Slot] = event + c.workflowProvenancePending = nil + return nil +} + +func (c *brokerAdmission) recordWorkflowProvenanceNotDispatched(event brokerWorkflowProvenanceEvent) error { + if c == nil || !c.ledgerOnly || event.Kind != "workflow_provenance_get_not_dispatched" || !validBrokerWorkflowProvenanceEvent(event) || event.Slot != c.ledgerSlot || event.Attempt != c.ledgerAttempt || event.Journal != c.ledgerJournal || c.workflowProvenanceNoDispatch[event.Slot] || c.workflowProvenanceConfirmed[event.Slot].Kind != "" || c.check() != nil { + return errBroker + } + if c.workflowProvenancePending != nil && !sameBrokerWorkflowProvenance(*c.workflowProvenancePending, event) { + return errBroker + } + if c.append(event) != nil || c.check() != nil { + return errBroker + } + c.workflowProvenanceNoDispatch[event.Slot] = true + c.workflowProvenancePending = nil + return nil +} + +func openBrokerAdmission(directory string, a BrokerApproval, j *brokerJournal, p *brokerControllerPlan, syncRoot func(*os.Root) error) (*brokerAdmission, error) { + return openBrokerAdmissionMode(directory, a, j, p, syncRoot, false) +} + +// openBrokerAdmissionLedger acquires the same fixed account lock and replays +// the same ledger as claim admission, but deliberately creates no phase claim. +// The caller holds the returned lock across the authenticated provenance GET. +func openBrokerAdmissionLedger(directory string, a BrokerApproval, j *brokerJournal, syncRoot func(*os.Root) error) (*brokerAdmission, error) { + return openBrokerAdmissionMode(directory, a, j, nil, syncRoot, true) +} + +func openBrokerAdmissionMode(directory string, a BrokerApproval, j *brokerJournal, p *brokerControllerPlan, syncRoot func(*os.Root) error, ledgerOnly bool) (claim *brokerAdmission, err error) { if syncRoot == nil || !filepath.IsAbs(directory) || filepath.Clean(directory) != directory { return nil, errBroker } @@ -356,8 +435,11 @@ func openBrokerAdmission(directory string, a BrokerApproval, j *brokerJournal, p provenanceNonces := map[string]bool{} readTokenLifecycleSlots := map[string]bool{} readTokenRequestNotDispatchedSlots := map[string]bool{} + workflowProvenanceConfirmed := map[string]brokerWorkflowProvenanceEvent{} + workflowProvenanceNoDispatch := map[string]bool{} latestSlot := "" var pendingReadTokenLifecycle *brokerReadTokenLifecycleEvent + var pendingWorkflowProvenance *brokerWorkflowProvenanceEvent for _, line := range lines[1 : len(lines)-1] { var kind struct { Kind string `json:"kind"` @@ -366,6 +448,44 @@ func openBrokerAdmission(directory string, a BrokerApproval, j *brokerJournal, p return nil, errBroker } switch kind.Kind { + case "workflow_provenance_get_started", "workflow_provenance_get_confirmed", "workflow_provenance_get_not_dispatched": + var event brokerWorkflowProvenanceEvent + if decodeBrokerJSON(line, &event, true) != nil || !validBrokerWorkflowProvenanceEvent(event) { + return nil, errBroker + } + switch event.Kind { + case "workflow_provenance_get_started": + if pendingWorkflowProvenance != nil || event.Slot == "discover-actions-host" || workflowProvenanceConfirmed[event.Slot].Kind != "" || workflowProvenanceNoDispatch[event.Slot] { + return nil, errBroker + } + if _, exists := slots[event.Slot]; exists { + return nil, errBroker + } + for _, receipt := range slots { + if receipt.Attempt == event.Attempt || receipt.Journal == event.Journal { + return nil, errBroker + } + } + pendingWorkflowProvenance = &event + case "workflow_provenance_get_confirmed": + if pendingWorkflowProvenance == nil || !sameBrokerWorkflowProvenance(*pendingWorkflowProvenance, event) { + return nil, errBroker + } + workflowProvenanceConfirmed[event.Slot] = event + pendingWorkflowProvenance = nil + case "workflow_provenance_get_not_dispatched": + if workflowProvenanceNoDispatch[event.Slot] || workflowProvenanceConfirmed[event.Slot].Kind != "" { + return nil, errBroker + } + if _, exists := slots[event.Slot]; exists { + return nil, errBroker + } + if pendingWorkflowProvenance != nil && !sameBrokerWorkflowProvenance(*pendingWorkflowProvenance, event) { + return nil, errBroker + } + workflowProvenanceNoDispatch[event.Slot] = true + pendingWorkflowProvenance = nil + } case "read_token_lifecycle_started", "read_token_lifecycle_confirmed", "read_token_revocation_started", "read_token_revocation_confirmed", "read_token_request_not_dispatched": var event brokerReadTokenLifecycleEvent if decodeBrokerJSON(line, &event, true) != nil || !validBrokerReadTokenLifecycleEvent(event) { @@ -401,7 +521,7 @@ func openBrokerAdmission(directory string, a BrokerApproval, j *brokerJournal, p readTokenRequestNotDispatchedSlots[event.Slot] = true } case "claim", "complete": - if pendingReadTokenLifecycle != nil { + if pendingReadTokenLifecycle != nil || pendingWorkflowProvenance != nil { return nil, errBroker } var event brokerClaimEvent @@ -458,7 +578,7 @@ func openBrokerAdmission(directory string, a BrokerApproval, j *brokerJournal, p return nil, errBroker } } - if pendingReadTokenLifecycle != nil { + if pendingReadTokenLifecycle != nil || pendingWorkflowProvenance != nil { return nil, errBroker } for slot, event := range slots { @@ -468,6 +588,15 @@ func openBrokerAdmission(directory string, a BrokerApproval, j *brokerJournal, p // read-token POST was accepted, so recovery must fail closed. return nil, errBroker } + if event.Provenance != nil && event.Provenance.Source == handoff.ReusableCalleeSource { + confirmed, ok := workflowProvenanceConfirmed[slot] + if !ok || !brokerClaimMatchesWorkflowProvenance(event, confirmed) { + // A reusable-callee claim is valid only after the exact + // authenticated workflow-run GET for this claim was confirmed + // in account history. + return nil, errBroker + } + } } slot := a.Phase if a.Mode == "discover-actions-host" { @@ -475,7 +604,7 @@ func openBrokerAdmission(directory string, a BrokerApproval, j *brokerJournal, p } else if a.Mode == "paired-terminal" { slot = "paired-terminal" } - if _, ok := slots[slot]; ok { + if _, ok := slots[slot]; ok || workflowProvenanceNoDispatch[slot] { return nil, errBroker } for previous := range slots { @@ -483,12 +612,26 @@ func openBrokerAdmission(directory string, a BrokerApproval, j *brokerJournal, p return nil, errBroker } } + if j == nil || j.file == nil || j.root == nil { + return nil, errBroker + } ji, e := j.file.Stat() ri, re := j.root.Stat(".") if e != nil || re != nil || j.check() != nil { return nil, errBroker } + if ledgerOnly { + c.ledgerOnly = true + c.ledgerSlot = slot + c.ledgerAttempt = brokerFileIdentity(ri) + c.ledgerJournal = brokerFileIdentity(ji) + c.workflowProvenanceConfirmed = workflowProvenanceConfirmed + c.workflowProvenanceNoDispatch = workflowProvenanceNoDispatch + return c, nil + } c.event = brokerClaimEvent{Kind: "claim", Slot: slot, Attempt: brokerFileIdentity(ri), Journal: brokerFileIdentity(ji)} + c.workflowProvenanceConfirmed = workflowProvenanceConfirmed + c.workflowProvenanceNoDispatch = workflowProvenanceNoDispatch if p != nil { b, e := p.binding() if e != nil || (binding != nil && *binding != b) { @@ -506,6 +649,13 @@ func openBrokerAdmission(directory string, a BrokerApproval, j *brokerJournal, p return nil, errBroker } c.event.Provenance = &binding + if p.provenance.Source == handoff.ReusableCalleeSource { + expected, e := brokerWorkflowProvenanceEventFor(a, p.controller, j, p.provenance.Source) + confirmed, ok := workflowProvenanceConfirmed[slot] + if e != nil || !ok || !sameBrokerWorkflowProvenance(expected, confirmed) { + return nil, errBroker + } + } } c.event.Snapshot = brokerFileIdentity(p.snapshotInfo) c.event.SnapshotDigest = a.ControllerApprovalSHA256 @@ -617,3 +767,61 @@ func sameBrokerReadTokenLifecycle(started, confirmed brokerReadTokenLifecycleEve lifecyclePair := started.Kind == "read_token_lifecycle_started" && confirmed.Kind == "read_token_lifecycle_confirmed" return (legacyPair || lifecyclePair) && sameBrokerReadTokenIdentity(started, confirmed) } + +func brokerWorkflowProvenanceEventFor(a BrokerApproval, c controllerApproval, j *brokerJournal, source string) (brokerWorkflowProvenanceEvent, error) { + if j == nil || j.file == nil || j.root == nil || (a.Mode != "controller" && a.Mode != "paired-terminal") || !brokerProvenanceSource.MatchString(source) { + return brokerWorkflowProvenanceEvent{}, errBroker + } + ji, e := j.file.Stat() + ri, re := j.root.Stat(".") + if e != nil || re != nil || j.check() != nil { + return brokerWorkflowProvenanceEvent{}, errBroker + } + slot := a.Phase + if a.Mode == "paired-terminal" { + slot = "paired-terminal" + } + event := brokerWorkflowProvenanceEvent{ + Kind: "workflow_provenance_get_started", + Slot: slot, + Attempt: brokerFileIdentity(ri), + Journal: brokerFileIdentity(ji), + ControllerApprovalSHA256: a.ControllerApprovalSHA256, + Source: source, + WorkflowRunID: c.WorkflowRunID, + WorkflowRef: c.WorkflowRef, + WorkflowSHA: c.WorkflowSHA, + WorkflowPath: c.WorkflowPath, + } + if !validBrokerWorkflowProvenanceEvent(event) { + return brokerWorkflowProvenanceEvent{}, errBroker + } + return event, nil +} + +func validBrokerWorkflowProvenanceEvent(e brokerWorkflowProvenanceEvent) bool { + return (e.Kind == "workflow_provenance_get_started" || e.Kind == "workflow_provenance_get_confirmed" || e.Kind == "workflow_provenance_get_not_dispatched") && + brokerSlotAllowed(e.Slot) && e.Slot != "discover-actions-host" && + e.Attempt.Device != 0 && e.Attempt.Inode != 0 && e.Journal.Device != 0 && e.Journal.Inode != 0 && + brokerSHA256.MatchString(e.ControllerApprovalSHA256) && brokerProvenanceSource.MatchString(e.Source) && e.WorkflowRunID > 0 && + brokerWorkflowRef.MatchString(e.WorkflowRef) && brokerSHA40.MatchString(e.WorkflowSHA) && brokerWorkflow.MatchString(e.WorkflowPath) +} + +func sameBrokerWorkflowProvenance(started, event brokerWorkflowProvenanceEvent) bool { + return started.Slot == event.Slot && started.Attempt == event.Attempt && started.Journal == event.Journal && + started.ControllerApprovalSHA256 == event.ControllerApprovalSHA256 && started.Source == event.Source && + started.WorkflowRunID == event.WorkflowRunID && started.WorkflowRef == event.WorkflowRef && + started.WorkflowSHA == event.WorkflowSHA && started.WorkflowPath == event.WorkflowPath && + event.Kind != started.Kind +} + +func brokerClaimMatchesWorkflowProvenance(claim brokerClaimEvent, confirmed brokerWorkflowProvenanceEvent) bool { + if claim.Provenance == nil || claim.Authority == nil || confirmed.Kind != "workflow_provenance_get_confirmed" { + return false + } + approval := claim.Authority.Approval + return confirmed.Slot == claim.Slot && confirmed.Attempt == claim.Attempt && confirmed.Journal == claim.Journal && + confirmed.ControllerApprovalSHA256 == claim.SnapshotDigest && confirmed.Source == claim.Provenance.Source && + confirmed.WorkflowRunID == approval.WorkflowRunID && confirmed.WorkflowRef == approval.WorkflowRef && + confirmed.WorkflowSHA == approval.WorkflowSHA && confirmed.WorkflowPath == approval.WorkflowPath +} diff --git a/experiments/g02-auth/broker_admission_test.go b/experiments/g02-auth/broker_admission_test.go index 8ee53a5c..611a8cca 100644 --- a/experiments/g02-auth/broker_admission_test.go +++ b/experiments/g02-auth/broker_admission_test.go @@ -109,8 +109,8 @@ func TestBrokerFinitePhasesAndUnknownRetention(t *testing.T) { } func TestBrokerLedgerCapacityDerivesFromFiniteSlotSchema(t *testing.T) { - want := 1 + 4*(len(brokerPhases)+len(brokerSpecialSlots)) + 1 - if got := brokerLedgerMaxLines(); got != want || got != 54 || len(brokerPhases) != 11 || len(brokerSpecialSlots) != 2 { + want := 1 + 6*(len(brokerPhases)+len(brokerSpecialSlots)) + 1 + if got := brokerLedgerMaxLines(); got != want || got != 80 || len(brokerPhases) != 11 || len(brokerSpecialSlots) != 2 { t.Fatalf("ledger line bound=%d want schema-derived %d", got, want) } if brokerSlotAllowed("unreviewed-slot") || !brokerSlotAllowed("paired-terminal") || !brokerSlotAllowed("discover-actions-host") || !brokerSlotAllowed("session-replacement") || !brokerSlotAllowed("before-jit") { diff --git a/experiments/g02-auth/broker_entry.go b/experiments/g02-auth/broker_entry.go index b6dda040..5326e72d 100644 --- a/experiments/g02-auth/broker_entry.go +++ b/experiments/g02-auth/broker_entry.go @@ -200,24 +200,102 @@ func brokerWorkflowReceiptForRequest(ctx context.Context, api *brokerAPI, a Brok if err != nil || request.ControllerApprovalSHA256 != controllerDigest { return nil, errBroker } + var provenanceLedger *brokerAdmission + var provenanceEvent brokerWorkflowProvenanceEvent + dispatchState := brokerWorkflowProvenanceDispatchState{attempted: true} + _, accountWorkflowGET := adapter.(*workflowProvenanceAdapter) + recordNoDispatch := func() { + if provenanceLedger == nil { + return + } + provenanceEvent.Kind = "workflow_provenance_get_not_dispatched" + if claimed[0].append("workflow_provenance_get_not_dispatched", brokerWorkflowProvenanceMetadata(provenanceEvent)) == nil && provenanceLedger.recordWorkflowProvenanceNotDispatched(provenanceEvent) == nil { + return + } + // A failed no-dispatch record must not release the account-wide + // intent. If no start was durable yet, retain one now so replay + // remains fenced rather than guessing that no request happened. + provenanceEvent.Kind = "workflow_provenance_get_started" + if provenanceLedger.workflowProvenancePending == nil { + _ = provenanceLedger.beginWorkflowProvenance(provenanceEvent) + } + } + if len(claimed) == 1 && accountWorkflowGET { + if api.admissionDirectory == nil || api.syncDirectory == nil { + return nil, errBroker + } + directory, e := api.admissionDirectory() + if e != nil { + return nil, errBroker + } + provenanceLedger, e = openBrokerAdmissionLedger(directory, a, claimed[0], api.syncDirectory) + if e != nil { + return nil, errBroker + } + defer provenanceLedger.close() + provenanceEvent, e = brokerWorkflowProvenanceEventFor(a, c, claimed[0], adapter.Source()) + if e != nil { + return nil, errBroker + } + // The attempt journal is already held and records the non-secret GET + // intent. The account-wide start follows while its fixed lock is held; + // either durable record precedes the HTTP boundary. + if claimed[0].append("workflow_provenance_get_started", brokerWorkflowProvenanceMetadata(provenanceEvent)) != nil { + recordNoDispatch() + return nil, errBroker + } + if provenanceLedger.beginWorkflowProvenance(provenanceEvent) != nil { + recordNoDispatch() + return nil, errBroker + } + } provenanceCtx, cancel, err := brokerProvenanceContext(ctx, a, c, api.now()) if err != nil { + recordNoDispatch() return nil, errBroker } defer cancel() if claimCheck() != nil { + recordNoDispatch() return nil, errBroker } + // Once the account start is durable, a timeout, transport failure or lost + // response is ambiguous and intentionally remains pending. The adapter only + // clears that ambiguity when it proves failure before entering observation. + provenanceCtx = context.WithValue(provenanceCtx, brokerWorkflowProvenanceDispatchStateKey{}, &dispatchState) receipt, err := adapter.Attest(provenanceCtx, request) if err != nil || provenanceCtx.Err() != nil || claimCheck() != nil || receipt.Validate(request, api.now()) != nil || receipt.ExpiresAt.Before(a.ExpiresAt) { + if provenanceLedger != nil && !dispatchState.attempted { + recordNoDispatch() + } return nil, errBroker } if provenanceCtx.Err() != nil || claimCheck() != nil || root.Verify(request, receipt, api.now()) != nil || adapter.Verify(provenanceCtx, request, receipt) != nil || provenanceCtx.Err() != nil || claimCheck() != nil { return nil, errBroker } + if provenanceLedger != nil { + if claimed[0].append("workflow_provenance_get_observed", brokerWorkflowProvenanceMetadata(provenanceEvent)) != nil { + return nil, errBroker + } + provenanceEvent.Kind = "workflow_provenance_get_confirmed" + if provenanceLedger.confirmWorkflowProvenance(provenanceEvent) != nil { + return nil, errBroker + } + } return &receipt, nil } +func brokerWorkflowProvenanceMetadata(event brokerWorkflowProvenanceEvent) map[string]any { + return map[string]any{ + "controller_approval_sha256": event.ControllerApprovalSHA256, + "source": event.Source, + "workflow_run_id": event.WorkflowRunID, + "workflow_ref": event.WorkflowRef, + "workflow_sha": event.WorkflowSHA, + "workflow_path": event.WorkflowPath, + } +} + // runBrokerWithAPI is a test seam for the shared broker phases. The public // command entrypoint uses RunBrokerExperiment and its sealed v3 grant selection. // RunBroker is an inert compatibility stub. Live-capable G01 issuance must diff --git a/experiments/g02-auth/broker_test.go b/experiments/g02-auth/broker_test.go index 14c92f7b..cdb03cdb 100644 --- a/experiments/g02-auth/broker_test.go +++ b/experiments/g02-auth/broker_test.go @@ -28,23 +28,26 @@ func TestPairedTerminalBrokerApprovalUsesDedicatedMode(t *testing.T) { } type brokerHTTPFixture struct { - t *testing.T - calls []string - events []string - tokenCalls int - mintRequests []brokerMintRequestFixture - badGroup bool - badGroupPolicy bool - mintError bool - revokeError bool - readTokenFault string - readTokenToken string - loseReadTokenResponse bool - readTokenAccepted bool - requireReadTokenLifecycle bool - token string - root string - admissionRoot string + t *testing.T + calls []string + events []string + tokenCalls int + mintRequests []brokerMintRequestFixture + badGroup bool + badGroupPolicy bool + mintError bool + revokeError bool + readTokenFault string + readTokenToken string + loseReadTokenResponse bool + loseWorkflowResponse bool + timeoutWorkflowResponse bool + requireWorkflowGETBeforeMint bool + readTokenAccepted bool + requireReadTokenLifecycle bool + token string + root string + admissionRoot string } type brokerMintRequestFixture struct { @@ -68,6 +71,18 @@ func (f *brokerHTTPFixture) RoundTrip(r *http.Request) (*http.Response, error) { case "/orgs/org-a/installation": payload = map[string]any{"id": 201, "app_id": 71, "target_id": 101, "target_type": "Organization", "suspended_at": nil, "account": map[string]any{"id": 101, "login": "org-a", "type": "Organization"}, "permissions": map[string]string{"organization_self_hosted_runners": "write", "metadata": "read"}} case "/app/installations/201/access_tokens": + if f.requireWorkflowGETBeforeMint { + observed := false + for _, event := range f.events { + if strings.HasPrefix(event, "GET /repos/") && strings.Contains(event, "/actions/runs/") { + observed = true + break + } + } + if !observed { + f.t.Fatal("installation-token mint preceded the observed workflow provenance GET") + } + } f.tokenCalls++ data, _ := os.ReadFile(filepath.Join(f.root, "broker.jsonl")) if !strings.Contains(string(data), "token_request_started") { diff --git a/experiments/g02-auth/broker_v3_final_integration_test.go b/experiments/g02-auth/broker_v3_final_integration_test.go index b51478bc..37906ea4 100644 --- a/experiments/g02-auth/broker_v3_final_integration_test.go +++ b/experiments/g02-auth/broker_v3_final_integration_test.go @@ -33,6 +33,7 @@ func newBrokerV3RunFixture(t *testing.T, phase string, changedCallee bool, rootM a.Mode, a.Phase = "controller", phase a.GroupPolicy = groupPolicyFixture() fixture.requireReadTokenLifecycle = true + fixture.requireWorkflowGETBeforeMint = true a.AllowVerificationAuthority = phase == "after-ack" now := time.Now() a.ExpiresAt = now.Add(time.Hour) @@ -113,7 +114,15 @@ func newBrokerV3RunFixture(t *testing.T, phase string, changedCallee bool, rootM transport := transportFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == workflowRunPath(request.Repository, request.WorkflowRunID) { *workflowGET = *workflowGET + 1 + fixture.calls = append(fixture.calls, "GET "+r.URL.Path) fixture.events = append(fixture.events, "GET "+r.URL.Path) + if fixture.loseWorkflowResponse && *workflowGET == 1 { + return nil, errors.New("synthetic accepted workflow response lost") + } + if fixture.timeoutWorkflowResponse && *workflowGET == 1 { + <-r.Context().Done() + return nil, r.Context().Err() + } body := goodRun if *changeCalleeAt != 0 && *workflowGET == *changeCalleeAt { body = badRun @@ -342,6 +351,158 @@ func TestBrokerExperimentV3LostReadTokenMintResponseBlocksLaterInspect(t *testin } } +func TestBrokerExperimentV3LostWorkflowProvenanceResponseBlocksLaterInspect(t *testing.T) { + create := newBrokerV3RunFixture(t, "create", false) + create.fixture.loseWorkflowResponse = true + _, createErr := executeBrokerV3Fixture(t, context.Background(), create) + if createErr == nil || create.fixture.tokenCalls != 0 || *create.workflowGET != 1 { + t.Fatalf("fixture did not model a lost workflow provenance response before mint: err=%v mints=%d workflow_gets=%d calls=%v", createErr, create.fixture.tokenCalls, *create.workflowGET, create.fixture.calls) + } + if len(create.fixture.events) == 0 || create.fixture.events[0] != "GET /repos/org-a/canary/actions/runs/7" { + t.Fatalf("workflow provenance GET was not causally observed: events=%v", create.fixture.events) + } + admission, err := os.ReadFile(filepath.Join(create.fixture.admissionRoot, "broker-admission.jsonl")) + if err != nil || !bytes.Contains(admission, []byte(`"kind":"workflow_provenance_get_started"`)) || bytes.Contains(admission, []byte(`"kind":"workflow_provenance_get_confirmed"`)) { + t.Fatalf("lost response did not leave an unresolved workflow provenance intent: err=%v ledger=%s", err, admission) + } + + inspect := newBrokerV3RunFixture(t, "inspect", false) + inspect.fixture.admissionRoot = create.fixture.admissionRoot + inspect.files.ControllerStateDirectory = create.files.ControllerStateDirectory + result, inspectErr := executeBrokerV3Fixture(t, context.Background(), inspect) + if inspectErr == nil || result.Status != "" || inspect.fixture.tokenCalls != 0 || len(inspect.fixture.calls) != 0 { + t.Fatalf("later inspect crossed unresolved workflow provenance: result=%+v err=%v mints=%d calls=%v", result, inspectErr, inspect.fixture.tokenCalls, inspect.fixture.calls) + } +} + +func TestBrokerExperimentV3WorkflowProvenanceTimeoutBlocksLaterInspect(t *testing.T) { + create := newBrokerV3RunFixture(t, "create", false) + create.fixture.timeoutWorkflowResponse = true + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, createErr := executeBrokerV3Fixture(t, ctx, create) + if createErr == nil || create.fixture.tokenCalls != 0 || *create.workflowGET != 1 { + t.Fatalf("workflow provenance timeout crossed the mint fence: err=%v mints=%d workflow_gets=%d calls=%v", createErr, create.fixture.tokenCalls, *create.workflowGET, create.fixture.calls) + } + admission, err := os.ReadFile(filepath.Join(create.fixture.admissionRoot, "broker-admission.jsonl")) + if err != nil || !bytes.Contains(admission, []byte(`"kind":"workflow_provenance_get_started"`)) || bytes.Contains(admission, []byte(`"kind":"workflow_provenance_get_confirmed"`)) { + t.Fatalf("timeout did not leave an unresolved workflow provenance intent: err=%v ledger=%s", err, admission) + } + + inspect := newBrokerV3RunFixture(t, "inspect", false) + inspect.fixture.admissionRoot = create.fixture.admissionRoot + inspect.files.ControllerStateDirectory = create.files.ControllerStateDirectory + result, inspectErr := executeBrokerV3Fixture(t, context.Background(), inspect) + if inspectErr == nil || result.Status != "" || inspect.fixture.tokenCalls != 0 || len(inspect.fixture.calls) != 0 { + t.Fatalf("later inspect crossed timeout-ambiguous workflow provenance: result=%+v err=%v mints=%d calls=%v", result, inspectErr, inspect.fixture.tokenCalls, inspect.fixture.calls) + } +} + +func TestBrokerExperimentV3WorkflowProvenancePreRequestFailureRecordsNoDispatchForLaterInspect(t *testing.T) { + create := newBrokerV3RunFixture(t, "create", false) + _, createErr := executeBrokerV3Fixture(t, brokerJournalSyncFailureContext(context.Background(), "workflow_provenance_get_started"), create) + if createErr == nil || create.fixture.tokenCalls != 0 || *create.workflowGET != 0 || len(create.fixture.calls) != 0 { + t.Fatalf("workflow provenance journal failure crossed the HTTP boundary: err=%v mints=%d workflow_gets=%d calls=%v", createErr, create.fixture.tokenCalls, *create.workflowGET, create.fixture.calls) + } + admission, err := os.ReadFile(filepath.Join(create.fixture.admissionRoot, "broker-admission.jsonl")) + if err != nil || !bytes.Contains(admission, []byte(`"kind":"workflow_provenance_get_not_dispatched"`)) || bytes.Contains(admission, []byte(`"kind":"workflow_provenance_get_started"`)) { + t.Fatalf("pre-request failure lacks an explicit no-dispatch outcome: err=%v ledger=%s", err, admission) + } + + inspect := newBrokerV3RunFixture(t, "inspect", false) + inspect.fixture.admissionRoot = create.fixture.admissionRoot + inspect.files.ControllerStateDirectory = create.files.ControllerStateDirectory + result, inspectErr := executeBrokerV3Fixture(t, context.Background(), inspect) + if inspectErr != nil || result.Status != "controller_completed" || inspect.fixture.tokenCalls != 2 || *inspect.workflowGET != 3 { + t.Fatalf("explicit no-dispatch proof did not permit later inspect: result=%+v err=%v mints=%d workflow_gets=%d calls=%v", result, inspectErr, inspect.fixture.tokenCalls, *inspect.workflowGET, inspect.fixture.calls) + } +} + +func TestBrokerExperimentV3HistoricalWorkflowProvenanceClaimReplayBinding(t *testing.T) { + create := newBrokerV3RunFixture(t, "create", false) + if _, err := executeBrokerV3Fixture(t, context.Background(), create); err != nil { + t.Fatalf("complete synthetic create: %v", err) + } + ledgerPath := filepath.Join(create.fixture.admissionRoot, "broker-admission.jsonl") + ledger, err := os.ReadFile(ledgerPath) + if err != nil { + t.Fatalf("read admission ledger: %v", err) + } + mutateLedger := func(mutate func(*brokerWorkflowProvenanceEvent)) []byte { + t.Helper() + lines := strings.Split(strings.TrimSuffix(string(ledger), "\n"), "\n") + mutated := 0 + for i, line := range lines { + var kind struct { + Kind string `json:"kind"` + } + if decodeBrokerJSON([]byte(line), &kind, false) != nil { + t.Fatal("decode admission event kind") + } + if kind.Kind != "workflow_provenance_get_started" && kind.Kind != "workflow_provenance_get_confirmed" { + continue + } + var event brokerWorkflowProvenanceEvent + if decodeBrokerJSON([]byte(line), &event, true) != nil { + t.Fatal("decode workflow provenance event") + } + mutate(&event) + encoded, err := json.Marshal(event) + if err != nil { + t.Fatalf("encode changed workflow provenance event: %v", err) + } + lines[i] = string(encoded) + mutated++ + } + if mutated != 2 { + t.Fatalf("mutated %d workflow provenance lifecycle events, want start and confirmation", mutated) + } + return []byte(strings.Join(lines, "\n") + "\n") + } + + cases := []struct { + name string + mutate func(*brokerWorkflowProvenanceEvent) + }{ + {name: "slot", mutate: func(event *brokerWorkflowProvenanceEvent) { event.Slot = "cleanup" }}, + {name: "attempt", mutate: func(event *brokerWorkflowProvenanceEvent) { event.Attempt.Inode++ }}, + {name: "journal", mutate: func(event *brokerWorkflowProvenanceEvent) { event.Journal.Inode++ }}, + {name: "controller-approval-digest", mutate: func(event *brokerWorkflowProvenanceEvent) { event.ControllerApprovalSHA256 = strings.Repeat("e", 64) }}, + {name: "source", mutate: func(event *brokerWorkflowProvenanceEvent) { event.Source = "other-source" }}, + {name: "workflow-run-id", mutate: func(event *brokerWorkflowProvenanceEvent) { event.WorkflowRunID++ }}, + {name: "workflow-ref", mutate: func(event *brokerWorkflowProvenanceEvent) { event.WorkflowRef = "refs/heads/release" }}, + {name: "workflow-sha", mutate: func(event *brokerWorkflowProvenanceEvent) { event.WorkflowSHA = strings.Repeat("f", 40) }}, + {name: "workflow-path", mutate: func(event *brokerWorkflowProvenanceEvent) { event.WorkflowPath = ".github/workflows/other.yml" }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := os.WriteFile(ledgerPath, mutateLedger(tc.mutate), 0600); err != nil { + t.Fatalf("write mismatched workflow provenance history: %v", err) + } + inspect := newBrokerV3RunFixture(t, "inspect", false) + inspect.fixture.admissionRoot = create.fixture.admissionRoot + inspect.files.ControllerStateDirectory = create.files.ControllerStateDirectory + result, inspectErr := executeBrokerV3Fixture(t, context.Background(), inspect) + if inspectErr == nil || result.Status != "" || inspect.fixture.tokenCalls != 0 || *inspect.workflowGET != 0 || len(inspect.fixture.calls) != 0 { + t.Fatalf("inspect crossed mismatched historical %s: result=%+v err=%v mints=%d workflow_gets=%d calls=%v", tc.name, result, inspectErr, inspect.fixture.tokenCalls, *inspect.workflowGET, inspect.fixture.calls) + } + }) + } + + if err := os.WriteFile(ledgerPath, ledger, 0600); err != nil { + t.Fatalf("restore matching workflow provenance history: %v", err) + } + t.Run("matching-history", func(t *testing.T) { + inspect := newBrokerV3RunFixture(t, "inspect", false) + inspect.fixture.admissionRoot = create.fixture.admissionRoot + inspect.files.ControllerStateDirectory = create.files.ControllerStateDirectory + result, inspectErr := executeBrokerV3Fixture(t, context.Background(), inspect) + if inspectErr != nil || result.Status != "controller_completed" || inspect.fixture.tokenCalls != 2 || *inspect.workflowGET != 3 { + t.Fatalf("inspect rejected matching historical workflow identity: result=%+v err=%v mints=%d workflow_gets=%d calls=%v", result, inspectErr, inspect.fixture.tokenCalls, *inspect.workflowGET, inspect.fixture.calls) + } + }) +} + func TestBrokerExperimentV3LegacyIncompleteClaimWithoutLifecycleBlocksLaterInspect(t *testing.T) { create := newBrokerV3RunFixture(t, "create", false) if _, err := executeBrokerV3Fixture(t, context.Background(), create); err != nil { @@ -354,7 +515,7 @@ func TestBrokerExperimentV3LegacyIncompleteClaimWithoutLifecycleBlocksLaterInspe } var legacyLines []string for _, line := range strings.Split(strings.TrimSuffix(string(ledger), "\n"), "\n") { - if strings.Contains(line, `"kind":"read_token_lifecycle_`) || strings.Contains(line, `"kind":"complete"`) { + if strings.Contains(line, `"kind":"read_token_lifecycle_`) || strings.Contains(line, `"kind":"workflow_provenance_get_`) || strings.Contains(line, `"kind":"complete"`) { continue } legacyLines = append(legacyLines, line) diff --git a/experiments/g02-auth/broker_workflow_provenance.go b/experiments/g02-auth/broker_workflow_provenance.go index 940d2f18..ad274f8d 100644 --- a/experiments/g02-auth/broker_workflow_provenance.go +++ b/experiments/g02-auth/broker_workflow_provenance.go @@ -41,6 +41,12 @@ type workflowProvenanceAdapter struct { checkAttempt func() error } +// The broker may release a durable provenance intent only when it can prove +// that this adapter failed before the HTTP boundary. Once observation is +// entered, a timeout or transport error is treated as an ambiguous response. +type brokerWorkflowProvenanceDispatchState struct{ attempted bool } +type brokerWorkflowProvenanceDispatchStateKey struct{} + func newWorkflowProvenanceAdapter(grant reviewedWorkflowGrant, observer *workflowRunObserver, readCredential func(context.Context) (string, error), signer receiptSigner, keyID string, now func() time.Time, nonce func() ([16]byte, error), checkAttempt func() error) (BrokerProvenanceAdapter, error) { if !grant.valid() || observer == nil || observer.client == nil || readCredential == nil || signer == nil || !brokerComponent.MatchString(keyID) || now == nil || checkAttempt == nil { return nil, errBroker @@ -75,6 +81,11 @@ func (a *workflowProvenanceAdapter) check(ctx context.Context) error { func (a *workflowProvenanceAdapter) Attest(parent context.Context, request BrokerProvenanceRequest) (BrokerProvenanceReceipt, error) { fail := BrokerProvenanceReceipt{} + if parent != nil { + if state, ok := parent.Value(brokerWorkflowProvenanceDispatchStateKey{}).(*brokerWorkflowProvenanceDispatchState); ok && state != nil { + state.attempted = false + } + } if request != a.grant.Request || a.check(parent) != nil { return fail, errBroker } @@ -89,6 +100,9 @@ func (a *workflowProvenanceAdapter) Attest(parent context.Context, request Broke if err != nil || a.check(ctx) != nil { return fail, errBroker } + if state, ok := ctx.Value(brokerWorkflowProvenanceDispatchStateKey{}).(*brokerWorkflowProvenanceDispatchState); ok && state != nil { + state.attempted = true + } if _, err = a.observer.observe(ctx, a.grant, token); err != nil || a.check(ctx) != nil { return fail, errBroker }