From cd7886aa443625fb16327cab336a342cc3d96535 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 11 Jul 2026 19:49:17 +0100 Subject: [PATCH] Enforce server-owned action approval authority --- docs/release-control/v6/internal/status.json | 115 ++- .../v6/internal/subsystems/agent-lifecycle.md | 13 + .../v6/internal/subsystems/ai-runtime.md | 10 + .../v6/internal/subsystems/api-contracts.md | 25 + .../subsystems/organization-settings.md | 10 + .../subsystems/performance-and-scalability.md | 15 + .../v6/internal/subsystems/relay-runtime.md | 10 + .../internal/subsystems/security-privacy.md | 13 + .../internal/subsystems/storage-recovery.md | 9 + .../internal/subsystems/unified-resources.md | 12 + .../actionlifecycle/actor_approval_test.go | 698 ++++++++++++++++++ internal/actionlifecycle/service.go | 325 ++++++-- internal/actionlifecycle/service_test.go | 220 ++++-- internal/actionplanner/planner.go | 59 +- internal/actionplanner/planner_test.go | 5 + internal/agentcapabilities/errors.go | 8 + internal/agentcapabilities/manifest.go | 22 +- internal/agentcapabilities/manifest_test.go | 17 +- internal/agentcapabilities/scopes_test.go | 2 +- internal/api/action_authority.go | 199 +++++ internal/api/action_authority_test.go | 163 ++++ internal/api/actions.go | 110 ++- internal/api/actions_test.go | 237 +++--- internal/api/agent_activity_telemetry_test.go | 2 +- internal/api/agent_capabilities_test.go | 6 +- internal/api/agent_resource_context_test.go | 41 +- internal/api/agent_substrate_e2e_test.go | 12 +- .../api/assistant_typed_action_planner.go | 3 +- internal/api/contract_test.go | 236 +++--- .../docker_container_action_executor_test.go | 22 +- internal/api/patrol_action_broker.go | 6 + internal/api/patrol_action_broker_test.go | 6 +- .../api/proxmox_guest_action_executor_test.go | 14 +- internal/api/relay_mobile_capability.go | 22 +- internal/api/relay_mobile_capability_test.go | 23 +- internal/api/resources.go | 37 +- internal/api/router.go | 3 + internal/api/router_routes_monitoring.go | 15 +- .../api/telemetry_pulse_intelligence_test.go | 27 +- internal/config/api_tokens.go | 3 + .../unifiedresources/action_dispatch_store.go | 6 +- internal/unifiedresources/actions.go | 468 +++++++++++- internal/unifiedresources/actions_test.go | 42 +- .../unifiedresources/code_standards_test.go | 10 +- internal/unifiedresources/store.go | 221 ++++-- internal/unifiedresources/store_test.go | 118 ++- pkg/auth/permissions.go | 12 +- pkg/auth/scopes.go | 6 + pkg/pulsecli/actions.go | 15 +- pkg/pulsecli/actions_test.go | 68 +- 50 files changed, 3125 insertions(+), 616 deletions(-) create mode 100644 internal/actionlifecycle/actor_approval_test.go create mode 100644 internal/api/action_authority.go create mode 100644 internal/api/action_authority_test.go diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index dd3d4798d..e1af19f66 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -4212,7 +4212,7 @@ "./internal/actionlifecycle", "./internal/api", "-run", - "TestMemoryStoreCreateActionAuditConcurrentReturnsCurrent|TestSQLiteStoreCreateActionAuditConcurrentAcrossTwoInstances|TestSQLiteStoreActionTransitionCASAcrossTwoInstances|TestSQLiteStoreConcurrentExecutionStartAcrossTwoInstancesHasOneWinner|TestSQLiteStoreCreateActionAuditRollsBackWhenInitialEventInsertFails|TestSQLiteStoreActionTransitionRollsBackWhenEventInsertFails|TestSQLiteStoreLifecycleRestartPreservesMonotonicState|TestSQLiteStoreRestartDoesNotReadmitExecutingAction|TestConcurrentPlanReplayCannotRewindTerminalActionMemoryStore|TestConcurrentPlanReplayCannotRewindTerminalActionSQLiteStore|TestConcurrentExecuteAdmitsExecutorExactlyOnceMemoryStore|TestConcurrentExecuteAdmitsExecutorExactlyOnceSQLiteStore|TestPlanReplayRejectsConflictingOriginForDeterministicActionID|TestPatrolActionBrokerBarrierReplayAdmitsExecutorExactlyOnce|TestPatrolActionBrokerTerminalReplayPreservesAuditAndEvents|TestPatrolActionReconciliationHydratesTerminalAuditAfterRestart|TestPatrolActionReconciliationCannotRegressFromOutOfOrderCallback", + "TestMemoryStoreCreateActionAuditConcurrentReturnsCurrent|TestSQLiteStoreCreateActionAuditConcurrentAcrossTwoInstances|TestSQLiteStoreActionTransitionCASAcrossTwoInstances|TestSQLiteStoreConcurrentExecutionStartAcrossTwoInstancesHasOneWinner|TestSQLiteStoreCreateActionAuditRollsBackWhenInitialEventInsertFails|TestSQLiteStoreActionDecisionRollsBackWhenDecisionEventInsertFails|TestSQLiteStoreActionDecisionRollsBackWhenResultingTransitionInsertFails|TestSQLiteStoreLifecycleRestartPreservesMonotonicState|TestSQLiteStoreRestartDoesNotReadmitExecutingAction|TestConcurrentPlanReplayCannotRewindTerminalActionMemoryStore|TestConcurrentPlanReplayCannotRewindTerminalActionSQLiteStore|TestConcurrentExecuteAdmitsExecutorExactlyOnceMemoryStore|TestConcurrentExecuteAdmitsExecutorExactlyOnceSQLiteStore|TestPlanReplayRejectsConflictingOriginForDeterministicActionID|TestPatrolActionBrokerBarrierReplayAdmitsExecutorExactlyOnce|TestPatrolActionBrokerTerminalReplayPreservesAuditAndEvents|TestPatrolActionReconciliationHydratesTerminalAuditAfterRestart|TestPatrolActionReconciliationCannotRegressFromOutOfOrderCallback", "-count=1" ] } @@ -4252,6 +4252,95 @@ "evidence_tier": "test-proof" } ] + }, + { + "id": "RA37", + "summary": "Governed-action actor and approval strength are server-owned: requester attribution is derived from authenticated authority, current tenant/RBAC/token scope is rechecked at decision and execution, approvals are revision-CAS append-only facts, and no surface may claim MFA without server-verified action-bound cryptographic evidence.", + "kind": "invariant", + "blocking_level": "repo-ready", + "proof_type": "automated", + "lane_ids": [ + "L6", + "L7", + "L13", + "L14", + "L20" + ], + "subsystem_ids": [ + "ai-runtime", + "api-contracts", + "organization-settings", + "relay-runtime", + "security-privacy", + "unified-resources" + ], + "release_gate_ids": [], + "proof_commands": [ + { + "id": "ra37-server-owned-action-authority", + "run": [ + "go", + "test", + "./internal/actionplanner", + "./internal/actionlifecycle", + "./internal/unifiedresources", + "./internal/api", + "-run", + "TestHandlePlanActionBindsActorAndPlanHashToAuthenticatedOrg|TestHandlePlanActionRejectsOrIgnoresPublicRequestedByAndStampsAuthenticatedActor|TestHandleDecideActionRejectsViewerSessionDespiteSessionScopeBypass|TestHandleDecideActionRejectsDetachedTokenAsHumanApprover|TestHandleDecideActionRejectsOwnerBoundTokenAfterRoleRevocation|TestHandleExecuteActionRejectsCrossOrgAuditLookup|TestHandleExecuteActionRejectsExecutorWithoutCurrentCapability|TestActionAuthorityAllowsOwnerBoundTokenWithCanonicalApproveAndExecuteScopes|TestActionAuthorityRejectsOwnerBoundTokenWithoutApplicableScope|TestActionAuthorityLegacyCompatibilityScopesAreExactlyEnumerated|TestDecideRejectsMFAPolicyWithAPIMethodOnly|TestDecideRejectsUnsignedOrTamperedStepUpEvidence|TestDecideRejectsEvidenceForDifferentActorOrgActionPlanOrOutcome|TestDecideRejectsExpiredOrReplayedChallenge|TestConcurrentDistinctApprovalsRetainQuorumMemoryStore|TestConcurrentDistinctApprovalsRetainQuorumAcrossSQLiteHandles|TestDecisionEventIdentityAndTransitionUniquenessMemoryStore|TestDecisionEventIdentityAndTransitionUniquenessSQLiteStore|TestRejectedDecisionPersistsDecisionAndRejectedTransitionAtomically|TestSQLiteReopenPreservesDecisionEventRevisionOrdering|TestSQLiteActionLifecycleMigrationRetainsHistoricalDuplicatesAndRestoresTransitionUniqueness|TestSQLiteStoreActionDecisionRollsBackWhenDecisionEventInsertFails|TestSQLiteStoreActionDecisionRollsBackWhenResultingTransitionInsertFails", + "-count=1" + ] + } + ], + "evidence": [ + { + "repo": "pulse", + "path": "internal/actionlifecycle/actor_approval_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "internal/actionlifecycle/service.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/action_authority.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/action_authority_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "internal/api/actions.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/actions.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/store.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/store_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "pkg/auth/scopes.go", + "kind": "file" + } + ] } ], "evidence_reference_policy": { @@ -5809,7 +5898,7 @@ "status": "partial", "completion": { "state": "bounded-residual", - "summary": "Action governance has a first-class governed floor AND the typed action lifecycle is now the only autonomous route for Patrol: investigations run under a core-owned, non-interactive, read-only execution profile and can only emit a side-effect-free typed action proposal (patrol_propose_action), which the shared action lifecycle plans, approves, and executes with plan hashing, remediation locks, plan-drift revalidation, and read-after-write verification. Three policy-authorized verticals are live. Docker/Podman restart is capability-owned low-risk eligible. Agent-managed APT host updates are capability-owned elevated eligible and cross a closed typed host_update protocol with no command, package-selection, removal, or reboot authority; the agent owns metadata refresh, preflight simulation, guarded installation, and post-install simulation, while the audit records verified, failed, or honestly inconclusive outcomes plus reboot-required state. Agent-managed package-cache cleanup is capability-owned low-risk eligible and crosses a separate fingerprint-bound host_storage_cleanup protocol with no command, path, package selector, installed-package removal, or reboot authority; it is available only for meaningful reclaimable bytes on the actual pressured cache filesystem, and the audit verifies the bytes reclaimed. All three require explicit per-resource capability allowlists and optional recurring timezone windows, remain bounded by tenant Patrol mode and full-mode unlock, recheck policy/readiness before decision and dispatch, and record server-owned policy approval plus terminal verification in the same audit trail. Never-auto-remediate, missing/unknown policy, unsupported capabilities, stale/error/empty package or cleanup inventories, cleared storage pressure, dry-run/MFA floors, closed windows, and mode downgrades all fail closed. Action continuity is audit-authoritative and now has a Phase B1 durable transport foundation: execution admission atomically creates the deterministic dispatch attempt/outbox, a one-shot pre-send CAS prevents concurrent duplicate sends, expired pre-send claims safely requeue, post-send uncertainty remains receipt-pending for query-only reconciliation, explicit expiry is durable, and canonical detail plus pending/settled reads are tenant-scoped. Generic transport errors cannot fabricate receipts or terminal truth, legacy executing rows without attempts remain inert, callbacks trigger org-scoped re-reads, investigation reads recover missed transitions by trusted origin, desktop and mobile decisions use the canonical /api/actions routes, terminal verification maps honestly onto Patrol findings and push outcomes, and legacy command-shaped history remains inert. Remaining post-RC hardening includes Task 10-owned terminal execution/verification/evidence/compensation truth before Task 07 Phase B2 compensation and verification-attempt recovery, mobile/relay inbox and push-dedup consumption, broader tenant defaults/inheritance and automation rate budgets, richer dry-run providers, multi-actor approvals, additional package-manager and bounded cleanup providers beyond APT, separately governed reboot orchestration, and a real-lab detect-plan-execute-verify-rollback journey proof.", + "summary": "Action governance has a first-class governed floor AND the typed action lifecycle is now the only autonomous route for Patrol: investigations run under a core-owned, non-interactive, read-only execution profile and can only emit a side-effect-free typed action proposal (patrol_propose_action), which the shared action lifecycle plans, approves, and executes with plan hashing, remediation locks, plan-drift revalidation, and read-after-write verification. Three policy-authorized verticals are live. Docker/Podman restart is capability-owned low-risk eligible. Agent-managed APT host updates are capability-owned elevated eligible and cross a closed typed host_update protocol with no command, package-selection, removal, or reboot authority; the agent owns metadata refresh, preflight simulation, guarded installation, and post-install simulation, while the audit records verified, failed, or honestly inconclusive outcomes plus reboot-required state. Agent-managed package-cache cleanup is capability-owned low-risk eligible and crosses a separate fingerprint-bound host_storage_cleanup protocol with no command, path, package selector, installed-package removal, or reboot authority; it is available only for meaningful reclaimable bytes on the actual pressured cache filesystem, and the audit verifies the bytes reclaimed. All three require explicit per-resource capability allowlists and optional recurring timezone windows, remain bounded by tenant Patrol mode and full-mode unlock, recheck policy/readiness before decision and dispatch, and record server-owned policy approval plus terminal verification in the same audit trail. Never-auto-remediate, missing/unknown policy, unsupported capabilities, stale/error/empty package or cleanup inventories, cleared storage pressure, dry-run/MFA floors, closed windows, and mode downgrades all fail closed. Action continuity is audit-authoritative and now has a Phase B1 durable transport foundation: execution admission atomically creates the deterministic dispatch attempt/outbox, a one-shot pre-send CAS prevents concurrent duplicate sends, expired pre-send claims safely requeue, post-send uncertainty remains receipt-pending for query-only reconciliation, explicit expiry is durable, and canonical detail plus pending/settled reads are tenant-scoped. Generic transport errors cannot fabricate receipts or terminal truth, legacy executing rows without attempts remain inert, callbacks trigger org-scoped re-reads, investigation reads recover missed transitions by trusted origin, desktop and mobile decisions use the canonical /api/actions routes, terminal verification maps honestly onto Patrol findings and push outcomes, and legacy command-shaped history remains inert. Task 08 Phase B1 also makes requester and decision authority server-owned, binds actor and approval requirement into plan identity, rechecks current tenant/RBAC/token scope, and records multi-actor approvals through revision-CAS append-only decision facts plus atomic lifecycle transitions. MFA-required decisions remain explicitly unavailable until a core verifier validates and consumes action-bound cryptographic evidence; labels and local biometrics are not MFA. Remaining post-RC hardening includes Task 10-owned terminal execution/verification/evidence/compensation truth before Task 07 Phase B2 compensation and verification-attempt recovery, mobile/relay inbox and push-dedup consumption, broader tenant defaults/inheritance and automation rate budgets, richer dry-run providers, multi-actor UI and MSP delegation/inheritance, device-key/WebAuthn enrollment and verifier integration, additional package-manager and bounded cleanup providers beyond APT, separately governed reboot orchestration, and a real-lab detect-plan-execute-verify-rollback journey proof.", "tracking": [ { "kind": "lane-followup", @@ -5928,6 +6017,12 @@ "path": "frontend-modern/src/utils/actionAuditPresentation.ts", "kind": "file" }, + { + "repo": "pulse", + "path": "internal/actionlifecycle/actor_approval_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, { "repo": "pulse", "path": "internal/actionlifecycle/service.go", @@ -5975,6 +6070,22 @@ "path": "internal/ai/tools/action_audit.go", "kind": "file" }, + { + "repo": "pulse", + "path": "internal/api/action_authority.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/action_authority_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "internal/api/actions.go", + "kind": "file" + }, { "repo": "pulse", "path": "internal/api/agent_capabilities.go", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 51c2de0d7..e2cd20e6b 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -110,6 +110,19 @@ later automatic-policy revocation. Emergency stop blocks admission before `executing`; cancellation after that boundary is best effort and is not rollback proof. +Governed action admission also treats requester and decision identity as +server-owned lifecycle authority. The authenticated session or owner-bound API +token supplies the immutable `ActionActor`, and the captured +`ApprovalRequirement` is bound into the request, plan identity, and plan hash. +Each accepted approval increments a durable decision revision through an exact +prior-prefix CAS, appends one typed decision fact, and atomically appends the +approved or rejected transition only when state changes. Exact replay appends +no event, while conflicting replay and legacy nonterminal records without actor +or requirement bindings fail closed as replan-required. MFA remains unavailable +and fail-closed unless the lifecycle boundary verifies action-bound +cryptographic step-up evidence through its configured verifier; session, +API-token, relay, and local-biometric labels alone are never MFA proof. + Assistant transport scopes do not grant agent command authority. `ai:chat` and `relay:mobile:access` remain conversation/read/session scopes; an interactive infrastructure invocation must carry server-bound `ai:execute` diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 68a1f86d1..90e9dbb7e 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -5762,3 +5762,13 @@ Enterprise command-remediation records are readable historical imports only. Production code contains no command or rollback execution algorithm; exported approve/execute/rollback interfaces and HTTP endpoints are permanently inert even when a command executor is injected. + +Action planning and approval attribution are now server-owned across Assistant, +Patrol, and MCP projections. Trusted brokers use explicit service/policy actor +contexts; public `requestedBy` content cannot become audit authority. The agent +manifest advertises the granular `actions:plan`, `actions:approve`, and +`actions:execute` scopes while the server alone owns the bounded legacy-scope +compatibility window. AI-facing method labels, model assertions, and local +biometric claims never satisfy an MFA floor. Until the core step-up verifier +accepts action-bound cryptographic evidence, the honest runtime outcome is +step-up unavailable, not MFA approved. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index ac02accb5..d5f0988cb 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -7366,3 +7366,28 @@ exact residual unowned by `registry.json` is `pulse-enterprise/internal/aiautofix/remediation_handlers.go`. Their contract is enforced by enterprise inertness/static tests and remains a Task 12 governance input rather than being hidden behind an invalid cross-repo registry path. + +Task 08 Phase B1 makes action authority server-owned. Public plan payloads may +retain `requestedBy` as a compatibility field, but it has no authority: the API +stamps the authenticated session or durable owner-bound API-token actor and +organization, and trusted in-process brokers must supply an explicit server +actor. Actor binding and the versioned approval requirement are immutable plan +identity and plan-hash inputs. Decisions and execution recheck current tenant +membership, RBAC capability, token ownership, credential identity, and the +explicit `actions:plan`, `actions:approve`, or `actions:execute` scope. The +bounded migration window admits only the enumerated `ai:execute` and +relay-mobile compatibility scopes. Exact decision retries return authoritative +state without consuming step-up evidence or appending events; conflicting +retries fail closed. + +Human decisions use a durable monotonic `decisionRevision` CAS. Every accepted +approval atomically appends one revision-keyed decision fact containing the +bound approval/evidence; a decision that changes state also appends exactly one +approved or rejected lifecycle transition in the same transaction. Quorum- +pending decisions append no transition. Memory and SQLite stores share these +semantics, including append-only approval-prefix checks, unique decision +revisions, unique true state transitions, crash rollback, and reload/reapply on +CAS loss. Legacy nonterminal records without canonical actor/approval binding +are replan-required. MFA is not implemented by labels: until a server verifier +can validate and consume action-bound cryptographic evidence, MFA-required +decisions remain unavailable and no API or product surface may claim otherwise. diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index 564b8a7c2..66508d1c8 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -339,3 +339,13 @@ token fallback only when no session exists. Mobile proof must render Overview, Access, Sharing, and Billing with populated multitenant fixtures at supported phone widths; hosted Billing Admin requires a real hosted-mode capability runtime and expanded-row proof rather than being counted through a redirect. + +Governed actions now bind the authenticated actor to the request organization +and re-evaluate current membership plus granular plan/approve/execute RBAC at +the lifecycle decision and execution boundaries. No caller-supplied requester, +browser scope shortcut, or detached token can substitute for current tenant +authority. This Phase B1 slice defines the canonical per-action requirement and +supports quorum/separation semantics with durable decision CAS, but it does not +claim provider/MSP delegation, provider inheritance, tenant policy-default UI, +distributed budgets, or multi-actor UI; those remain governed L20 residuals +rather than organization-settings-local policy models. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index dba1dd52a..66b200787 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -154,6 +154,21 @@ admission records a stable refusal without invoking executor or network code. pre-protected-state fetch, route preload, organization probe, or commercial posture request just to resolve display identity. 17. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. + +Governed action decisions preserve SQLite and MemoryStore parity through one +shared pure append command. Every accepted approval advances a monotonic +decision revision and compares the complete prior approval prefix before +mutation; SQLite performs the state, revision, identity, and prior-prefix CAS +atomically, while MemoryStore applies the same command under its store lock. +Contention across independent SQLite handles is bounded to CAS failure, reload, +replay/conflict/quorum re-evaluation, and retry, so a stale writer cannot erase +another actor's approval. Decision events are uniquely indexed by action and +decision revision, true lifecycle transitions retain their partial +action/state uniqueness, and a completing approval writes its decision fact +plus state transition in one transaction. Schema migration retains historical +events, reopen preserves decision revision ordering, and no action-decision +change may globally weaken the Task 03 lifecycle-state idempotency invariant. + ## Extension Points 1. Add performance budgets through SLO or contract tests diff --git a/docs/release-control/v6/internal/subsystems/relay-runtime.md b/docs/release-control/v6/internal/subsystems/relay-runtime.md index 1ae770822..1351e7917 100644 --- a/docs/release-control/v6/internal/subsystems/relay-runtime.md +++ b/docs/release-control/v6/internal/subsystems/relay-runtime.md @@ -144,3 +144,13 @@ Patrol approval pushes use `decide_action` with a canonical action id; terminal pushes distinguish verified, unverified, verification-failed, and execution-failed outcomes. Relay must not revive `/api/ai/approvals` as a live mutation route or claim that executor completion alone verified the change. + +The action routes in the relay-mobile inventory now name the granular approval +and execution scopes. `relay:mobile:access` and `ai:execute` remain explicitly +enumerated compatibility scopes only for this bounded migration window; the +central lifecycle authority still requires a current durable token owner, +matching organization and credential, current RBAC capability, and action/plan +binding. Detached relay/service tokens fail closed. A mobile-local biometric +label is not server-verifiable MFA evidence, so device-key enrollment and +cryptographic mobile step-up remain a later governed slice and relay must not +claim that local biometric success satisfied the core MFA floor. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index cb9a39050..5deb11244 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -1314,3 +1314,16 @@ effective tenant Patrol mode that admits the eligibility class, and an absent Never-auto-remediate lock. Missing or unknown state denies automatic execution. The policy actor/method are server-stamped and cannot be supplied by the model, enterprise orchestrator, browser, relay, or action-proposal payload. + +The same server-derived boundary now applies to every public governed action. +Session actors are credential-bound and remain subject to current organization +membership and RBAC; browser-session scope bypass does not bypass those checks. +API-token actors must prove a current durable human owner, matching credential +and organization, current owner membership/role, and an explicitly enumerated +action or compatibility scope. Detached/service tokens cannot approve or +execute as humans, and owner-bound tokens can satisfy only authenticated/admin +floors, never cryptographic step-up. Ordinary session, API-token, mobile-local- +biometric, or UI method labels are not MFA evidence. MFA-required actions fail +closed unless the core verifier validates and single-use consumes evidence +bound to the actor, credential, organization, action, plan, outcome, and +challenge. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 32acefb82..2fa1ceab4 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2031,6 +2031,15 @@ fields and nullable mode/kind metadata before presenting canonical item labels, while storage detail drawers and filter controls must route summary series IDs, source tones, and disk metrics through the shared storage helpers instead of reconstructing them from local table state. + +The adjacent unified action store migration now adds a durable action decision +revision plus typed lifecycle-event kind, decision revision, and bound decision +JSON. Migration retains duplicate historical state rows as typed legacy facts, +restores partial uniqueness for true `(action,state)` transitions, and adds +unique `(action,decisionRevision)` decision identity. Audit update, decision +fact, and any resulting approved/rejected transition commit or roll back +together across restart; storage/recovery code must not introduce a parallel +approval or recovery journal for this authority. Storage and recovery may depend on the adjacent Patrol-control status projection staying content-free, including its Patrol control starter count, completed/resolved loop counts, `patrolControlValueState`, legacy diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 57324f296..287a43871 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1688,6 +1688,18 @@ through the canonical resource model, but unified-resource consumers must not reintroduce removed workload aliases or feature-local resource-type shims just to satisfy one table, drawer, or badge surface. +The canonical action resource contract now owns immutable `ActionActor` and +versioned `ApprovalRequirement` bindings. Actor subject/kind/credential/org and +requirement floor/quorum/separation are part of deterministic action identity +and plan hashing. Human approvals are append-only under a durable monotonic +`decisionRevision`: SQLite and MemoryStore compare pending state, revision, and +the complete prior approval prefix before accepting the next decision. Each +accepted revision persists a typed decision event with the bound approval and +evidence; approved/rejected state changes persist a separate unique transition +atomically, while pending quorum decisions do not fabricate a state change. +Legacy nonterminal approvals without these bindings are readable but fail +closed as replan-required. + Action audits are the durable source of truth for Patrol action continuity. The store exposes optional `ActionAuditOriginReader` and `PendingActionAuditReader` capabilities; origin lookup is scoped by org and diff --git a/internal/actionlifecycle/actor_approval_test.go b/internal/actionlifecycle/actor_approval_test.go new file mode 100644 index 000000000..613793852 --- /dev/null +++ b/internal/actionlifecycle/actor_approval_test.go @@ -0,0 +1,698 @@ +package actionlifecycle + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +func actorApprovalService(t *testing.T, floor unified.ActionApprovalLevel) (*Service, unified.ResourceStore) { + t.Helper() + store := unified.NewMemoryStore() + service := serviceForStore(t, store, testResource(time.Now().UTC(), floor), &stubExecutor{result: &unified.ExecutionResult{Success: true}}) + return service, store +} + +func planForApprovalTest(t *testing.T, service *Service, requirement *unified.ApprovalRequirement) unified.ActionPlan { + t.Helper() + options := PlanOptions{Actor: testActionActor("requester", "default"), ApprovalRequirement: requirement} + plan, err := service.PlanWithOptions(context.Background(), "default", restartRequest(), options) + if err != nil { + t.Fatalf("PlanWithOptions: %v", err) + } + return plan +} + +func decisionForApprovalTest(plan unified.ActionPlan, actor unified.ActionActor, outcome unified.ApprovalOutcome, reason string, method unified.ApprovalMethod, challenge string, now time.Time) unified.ActionDecision { + return unified.ActionDecision{ + Actor: actor, Outcome: outcome, Reason: reason, + Evidence: unified.ApprovalEvidence{ + Version: 1, Method: method, Actor: actor, OrgID: actor.OrgID, ActionID: plan.ActionID, + PlanHash: plan.PlanHash, Outcome: outcome, ChallengeID: challenge, IssuedAt: now, + ExpiresAt: now.Add(time.Minute), + }, + } +} + +func TestDecideRejectsMFAPolicyWithAPIMethodOnly(t *testing.T) { + service, _ := actorApprovalService(t, unified.ApprovalMultiFactor) + plan := planForApprovalTest(t, service, nil) + decision := decisionForApprovalTest(plan, testActionActor("admin", "default"), unified.OutcomeApproved, "approve", unified.MethodAPIToken, "", time.Now().UTC()) + decision.Actor.Kind = unified.ActionActorAPIToken + decision.Evidence.Actor = decision.Actor + if _, err := service.Decide(context.Background(), "default", plan.ActionID, decision); !errors.Is(err, ErrApprovalStepUpUnavailable) { + t.Fatalf("Decide error=%v, want step-up unavailable", err) + } +} + +func TestDecideRejectsUnsignedOrTamperedStepUpEvidence(t *testing.T) { + service, _ := actorApprovalService(t, unified.ApprovalMultiFactor) + plan := planForApprovalTest(t, service, nil) + actor := testActionActor("admin", "default") + unsigned := decisionForApprovalTest(plan, actor, unified.OutcomeApproved, "approve", unified.MethodWebAuthnUV, "", time.Now().UTC()) + if _, err := service.Decide(context.Background(), "default", plan.ActionID, unsigned); !errors.Is(err, ErrApprovalEvidenceInvalid) { + t.Fatalf("unsigned error=%v", err) + } + tampered := decisionForApprovalTest(plan, actor, unified.OutcomeApproved, "approve", unified.MethodWebAuthnUV, "challenge-1", time.Now().UTC()) + tampered.Evidence.PlanHash = "sha256:tampered" + if _, err := service.Decide(context.Background(), "default", plan.ActionID, tampered); !errors.Is(err, ErrApprovalEvidenceInvalid) { + t.Fatalf("tampered error=%v", err) + } +} + +func TestDecideRejectsEvidenceForDifferentActorOrgActionPlanOrOutcome(t *testing.T) { + service, _ := actorApprovalService(t, unified.ApprovalAdmin) + plan := planForApprovalTest(t, service, nil) + baseActor := testActionActor("admin", "default") + base := decisionForApprovalTest(plan, baseActor, unified.OutcomeApproved, "approve", unified.MethodSession, "", time.Now().UTC()) + cases := map[string]func(*unified.ActionDecision){ + "actor": func(d *unified.ActionDecision) { d.Evidence.Actor.CredentialID = "session:other" }, + "org": func(d *unified.ActionDecision) { d.Evidence.OrgID = "other" }, + "action": func(d *unified.ActionDecision) { d.Evidence.ActionID = "act_other" }, + "plan": func(d *unified.ActionDecision) { d.Evidence.PlanHash = "sha256:other" }, + "outcome": func(d *unified.ActionDecision) { d.Evidence.Outcome = unified.OutcomeRejected }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + decision := base + mutate(&decision) + if _, err := service.Decide(context.Background(), "default", plan.ActionID, decision); !errors.Is(err, ErrApprovalEvidenceInvalid) { + t.Fatalf("error=%v", err) + } + }) + } +} + +func TestDecideRejectsExpiredOrReplayedChallenge(t *testing.T) { + service, store := actorApprovalService(t, unified.ApprovalMultiFactor) + plan := planForApprovalTest(t, service, nil) + actor := testActionActor("admin", "default") + expired := decisionForApprovalTest(plan, actor, unified.OutcomeApproved, "approve", unified.MethodWebAuthnUV, "expired", time.Now().UTC().Add(-2*time.Minute)) + if _, err := service.Decide(context.Background(), "default", plan.ActionID, expired); !errors.Is(err, ErrApprovalEvidenceInvalid) { + t.Fatalf("expired error=%v", err) + } + var verifierCalls int + service.StepUpVerifier = StepUpVerifierFunc(func(context.Context, unified.ActionAuditRecord, unified.ActionDecision) error { + verifierCalls++ + return nil + }) + now := time.Now().UTC() + decision := decisionForApprovalTest(plan, actor, unified.OutcomeApproved, "approve", unified.MethodWebAuthnUV, "challenge-1", now) + first, err := service.Decide(context.Background(), "default", plan.ActionID, decision) + if err != nil { + t.Fatal(err) + } + retry := decision + retry.Evidence.IssuedAt = now.Add(10 * time.Second) + retry.Evidence.ExpiresAt = now.Add(70 * time.Second) + second, err := service.Decide(context.Background(), "default", plan.ActionID, retry) + if err != nil || second.State != first.State || verifierCalls != 1 { + t.Fatalf("retry=%#v err=%v verifierCalls=%d", second, err, verifierCalls) + } + events, _ := store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 20) + if len(second.Approvals) != 1 || len(events) != 4 { + t.Fatalf("approvals=%d events=%d", len(second.Approvals), len(events)) + } + conflict := retry + conflict.Reason = "changed" + if _, err := service.Decide(context.Background(), "default", plan.ActionID, conflict); !errors.Is(err, ErrDecisionReplayConflict) { + t.Fatalf("conflict error=%v", err) + } +} + +func TestDecideRejectsRequesterWhenSeparationOfDutiesEnabled(t *testing.T) { + service, _ := actorApprovalService(t, unified.ApprovalAdmin) + requirement := unified.ApprovalRequirementForFloor(unified.ApprovalAdmin) + requirement.DisallowRequester = true + plan := planForApprovalTest(t, service, &requirement) + decision := decisionForApprovalTest(plan, testActionActor("requester", "default"), unified.OutcomeApproved, "approve", unified.MethodSession, "", time.Now().UTC()) + if _, err := service.Decide(context.Background(), "default", plan.ActionID, decision); !errors.Is(err, ErrApprovalSeparationRequired) { + t.Fatalf("error=%v", err) + } +} + +func TestRejectedDecisionPersistsDecisionAndRejectedTransitionAtomically(t *testing.T) { + service, store := actorApprovalService(t, unified.ApprovalAdmin) + plan := planForApprovalTest(t, service, nil) + decision := decisionForApprovalTest(plan, testActionActor("admin", "default"), unified.OutcomeRejected, "reject", unified.MethodSession, "", time.Now().UTC()) + record, err := service.Decide(context.Background(), "default", plan.ActionID, decision) + if err != nil { + t.Fatal(err) + } + if record.State != unified.ActionStateRejected || record.DecisionRevision != 1 { + t.Fatalf("record state=%q revision=%d", record.State, record.DecisionRevision) + } + events, err := store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 20) + if err != nil { + t.Fatal(err) + } + decisions, rejectedTransitions := 0, 0 + for _, event := range events { + if event.Kind == unified.ActionLifecycleEventDecision { + decisions++ + } + if event.Kind == unified.ActionLifecycleEventTransition && event.State == unified.ActionStateRejected { + rejectedTransitions++ + } + } + if len(events) != 4 || decisions != 1 || rejectedTransitions != 1 { + t.Fatalf("events=%#v, want one decision and one rejected transition", events) + } +} + +func TestDecideKeepsPendingUntilDistinctActorQuorumReached(t *testing.T) { + service, store := actorApprovalService(t, unified.ApprovalAdmin) + requirement := unified.ApprovalRequirementForFloor(unified.ApprovalAdmin) + requirement.Quorum = 2 + plan := planForApprovalTest(t, service, &requirement) + now := time.Now().UTC() + firstDecision := decisionForApprovalTest(plan, testActionActor("admin-one", "default"), unified.OutcomeApproved, "approve", unified.MethodSession, "", now) + first, err := service.Decide(context.Background(), "default", plan.ActionID, firstDecision) + if err != nil || first.State != unified.ActionStatePending { + t.Fatalf("first=%#v err=%v", first, err) + } + retry := firstDecision + retry.Evidence.IssuedAt = now.Add(time.Second) + replayed, err := service.Decide(context.Background(), "default", plan.ActionID, retry) + if err != nil || replayed.State != unified.ActionStatePending || len(replayed.Approvals) != 1 { + t.Fatalf("replay=%#v err=%v", replayed, err) + } + secondDecision := decisionForApprovalTest(plan, testActionActor("admin-two", "default"), unified.OutcomeApproved, "approve", unified.MethodSession, "", now) + approved, err := service.Decide(context.Background(), "default", plan.ActionID, secondDecision) + if err != nil || approved.State != unified.ActionStateApproved || len(approved.Approvals) != 2 { + t.Fatalf("approved=%#v err=%v", approved, err) + } + events, _ := store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 20) + if len(events) != 5 { + t.Fatalf("events=%d, want planned+pending+two decisions+approved transition", len(events)) + } + if approved.DecisionRevision != 2 { + t.Fatalf("decision revision=%d, want 2", approved.DecisionRevision) + } +} + +type decisionCASBarrier struct { + mu sync.Mutex + calls int + arrived chan struct{} + release chan struct{} +} + +func newDecisionCASBarrier() *decisionCASBarrier { + return &decisionCASBarrier{arrived: make(chan struct{}, 2), release: make(chan struct{})} +} + +func (b *decisionCASBarrier) wait() { + b.mu.Lock() + b.calls++ + call := b.calls + b.mu.Unlock() + if call <= 2 { + b.arrived <- struct{}{} + <-b.release + } +} + +type barrierDecisionStore struct { + unified.ResourceStore + barrier *decisionCASBarrier +} + +func (s *barrierDecisionStore) RecordActionDecision(record unified.ActionAuditRecord, event unified.ActionLifecycleEvent) error { + s.barrier.wait() + return s.ResourceStore.RecordActionDecision(record, event) +} + +func runConcurrentDistinctApprovalCAS(t *testing.T, firstStore, secondStore unified.ResourceStore) { + t.Helper() + barrier := newDecisionCASBarrier() + first := serviceForStore(t, &barrierDecisionStore{ResourceStore: firstStore, barrier: barrier}, testResource(time.Now().UTC(), unified.ApprovalAdmin), &stubExecutor{}) + second := serviceForStore(t, &barrierDecisionStore{ResourceStore: secondStore, barrier: barrier}, testResource(time.Now().UTC(), unified.ApprovalAdmin), &stubExecutor{}) + requirement := unified.ApprovalRequirementForFloor(unified.ApprovalAdmin) + requirement.Quorum = 2 + plan := planForApprovalTest(t, first, &requirement) + now := time.Now().UTC() + decisions := []unified.ActionDecision{ + decisionForApprovalTest(plan, testActionActor("admin-one", "default"), unified.OutcomeApproved, "approve one", unified.MethodSession, "", now), + decisionForApprovalTest(plan, testActionActor("admin-two", "default"), unified.OutcomeApproved, "approve two", unified.MethodSession, "", now), + } + results := make(chan error, 2) + go func() { + _, err := first.Decide(context.Background(), "default", plan.ActionID, decisions[0]) + results <- err + }() + go func() { + _, err := second.Decide(context.Background(), "default", plan.ActionID, decisions[1]) + results <- err + }() + <-barrier.arrived + <-barrier.arrived + close(barrier.release) + for range 2 { + if err := <-results; err != nil { + t.Fatalf("concurrent decision: %v", err) + } + } + record, found, err := firstStore.GetActionAudit(plan.ActionID) + if err != nil || !found { + t.Fatalf("authoritative record found=%v err=%v", found, err) + } + if record.State != unified.ActionStateApproved || record.DecisionRevision != 2 || len(record.Approvals) != 2 { + t.Fatalf("authoritative record state=%q revision=%d approvals=%#v", record.State, record.DecisionRevision, record.Approvals) + } + events, err := firstStore.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 20) + if err != nil { + t.Fatal(err) + } + approvedTransitions := 0 + decisionEvents := 0 + for _, event := range events { + if event.Kind == unified.ActionLifecycleEventDecision { + decisionEvents++ + } + if event.Kind == unified.ActionLifecycleEventTransition && event.State == unified.ActionStateApproved { + approvedTransitions++ + } + } + if len(events) != 5 || decisionEvents != 2 || approvedTransitions != 1 { + t.Fatalf("events=%#v, want two decisions and one approved transition", events) + } + beforeRevision := record.DecisionRevision + beforeEvents := len(events) + if _, err := first.Decide(context.Background(), "default", plan.ActionID, decisions[0]); err != nil { + t.Fatalf("exact replay: %v", err) + } + replayed, _, _ := firstStore.GetActionAudit(plan.ActionID) + events, _ = firstStore.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 20) + if replayed.DecisionRevision != beforeRevision || len(events) != beforeEvents { + t.Fatalf("exact replay revision/events=%d/%d, want %d/%d", replayed.DecisionRevision, len(events), beforeRevision, beforeEvents) + } + conflict := decisions[0] + conflict.Reason = "changed reason" + if _, err := first.Decide(context.Background(), "default", plan.ActionID, conflict); !errors.Is(err, ErrDecisionReplayConflict) { + t.Fatalf("conflicting replay error=%v", err) + } +} + +func TestConcurrentDistinctApprovalsRetainQuorumMemoryStore(t *testing.T) { + store := unified.NewMemoryStore() + runConcurrentDistinctApprovalCAS(t, store, store) +} + +func TestConcurrentDistinctApprovalsRetainQuorumAcrossSQLiteHandles(t *testing.T) { + dir := t.TempDir() + first, err := unified.NewSQLiteResourceStore(dir, "default") + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := unified.NewSQLiteResourceStore(dir, "default") + if err != nil { + t.Fatal(err) + } + defer second.Close() + runConcurrentDistinctApprovalCAS(t, first, second) +} + +func persistTwoPendingDecisionRevisions(t *testing.T, store unified.ResourceStore) string { + t.Helper() + service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalAdmin), &stubExecutor{}) + requirement := unified.ApprovalRequirementForFloor(unified.ApprovalAdmin) + requirement.Quorum = 3 + requirement.DisallowRequester = true + plan := planForApprovalTest(t, service, &requirement) + now := time.Now().UTC() + for index, subject := range []string{"admin-one", "admin-two"} { + decision := decisionForApprovalTest(plan, testActionActor(subject, "default"), unified.OutcomeApproved, "approve "+subject, unified.MethodSession, "", now) + record, err := service.Decide(context.Background(), "default", plan.ActionID, decision) + if err != nil { + t.Fatalf("decision %d: %v", index+1, err) + } + if record.State != unified.ActionStatePending || record.DecisionRevision != uint64(index+1) { + t.Fatalf("decision %d record state=%q revision=%d", index+1, record.State, record.DecisionRevision) + } + } + return plan.ActionID +} + +func ledgerValidationRecord(id string, floor unified.ActionApprovalLevel, quorum int, disallowRequester bool, now time.Time) unified.ActionAuditRecord { + requester := unified.ActionActor{SubjectID: "requester", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"} + requirement := unified.ApprovalRequirementForFloor(floor) + requirement.Quorum = quorum + requirement.DisallowRequester = disallowRequester + return unified.ActionAuditRecord{ + ID: id, CreatedAt: now, UpdatedAt: now, State: unified.ActionStatePending, + Request: unified.ActionRequest{RequestID: "req-" + id, ResourceID: "vm:42", CapabilityName: "restart", Reason: "ledger validation", RequestedBy: requester.SubjectID, Actor: requester}, + Plan: unified.ActionPlan{ActionID: id, RequestID: "req-" + id, Allowed: true, RequiresApproval: true, ApprovalPolicy: floor, ApprovalRequirement: requirement, PlannedAt: now, ExpiresAt: now.Add(time.Hour), ResourceVersion: "resource:test", PolicyVersion: "policy:test", PlanHash: "sha256:" + id}, + } +} + +func ledgerValidationApproval(record unified.ActionAuditRecord, actor unified.ActionActor, method unified.ApprovalMethod, now time.Time) unified.ActionApprovalRecord { + evidence := unified.ApprovalEvidence{Version: 1, Method: method, Actor: actor, OrgID: record.Request.Actor.OrgID, ActionID: record.ID, PlanHash: record.Plan.PlanHash, Outcome: unified.OutcomeApproved, IssuedAt: now} + if method == unified.MethodWebAuthnUV || method == unified.MethodDeviceKeyUV { + evidence.ChallengeID = "challenge-" + record.ID + evidence.ExpiresAt = now.Add(time.Minute) + } + return unified.ActionApprovalRecord{Actor: actor.SubjectID, ActorBinding: actor, Method: method, Timestamp: now, Outcome: unified.OutcomeApproved, Reason: "approve", Evidence: &evidence} +} + +func assertMalformedLedgerAppendRejected(t *testing.T, store unified.ResourceStore, record unified.ActionAuditRecord, approval unified.ActionApprovalRecord, mutate func(*unified.ActionAuditRecord, *unified.ActionLifecycleEvent)) { + t.Helper() + initial := []unified.ActionLifecycleEvent{ + {ActionID: record.ID, Timestamp: record.CreatedAt, State: unified.ActionStatePlanned, Actor: record.Request.Actor.SubjectID, Message: "Action plan created."}, + {ActionID: record.ID, Timestamp: record.CreatedAt, State: unified.ActionStatePending, Actor: record.Request.Actor.SubjectID, Message: "Action is waiting for approval before execution."}, + } + if _, created, err := store.CreateActionAudit(record, initial); err != nil || !created { + t.Fatalf("create audit created=%v err=%v", created, err) + } + proposed, event, err := unified.ApplyActionDecision(record, approval, approval.Timestamp) + if err != nil { + t.Fatalf("apply malformed fixture: %v", err) + } + if mutate != nil { + mutate(&proposed, &event) + } + beforeEvents, _ := store.GetActionLifecycleEvents(record.ID, time.Time{}, 20) + if err := store.RecordActionDecision(proposed, event); err == nil { + t.Fatal("malformed ledger append unexpectedly persisted") + } + current, found, err := store.GetActionAudit(record.ID) + if err != nil || !found || current.DecisionRevision != 0 || len(current.Approvals) != 0 || current.State != unified.ActionStatePending { + t.Fatalf("malformed append mutated record: found=%v err=%v record=%#v", found, err, current) + } + afterEvents, _ := store.GetActionLifecycleEvents(record.ID, time.Time{}, 20) + if len(afterEvents) != len(beforeEvents) { + t.Fatalf("malformed append mutated events: before=%#v after=%#v", beforeEvents, afterEvents) + } +} + +type ledgerValidationExpectation struct { + actionID string + decisionRevision uint64 + approvalCount int +} + +func assertStoreLedgerRejectsMalformedAuthority(t *testing.T, store unified.ResourceStore) []ledgerValidationExpectation { + t.Helper() + now := time.Now().UTC() + expectations := make([]ledgerValidationExpectation, 0, 13) + user := func(subject string) unified.ActionActor { + return unified.ActionActor{SubjectID: subject, Kind: unified.ActionActorUser, CredentialID: "session:" + subject, OrgID: "default"} + } + apiToken := unified.ActionActor{SubjectID: "token-owner", Kind: unified.ActionActorAPIToken, CredentialID: "api-token:test", OrgID: "default"} + tests := []struct { + name string + floor unified.ActionApprovalLevel + separate bool + actor unified.ActionActor + method unified.ApprovalMethod + mutate func(*unified.ActionAuditRecord, *unified.ActionLifecycleEvent) + }{ + {name: "malformed actor", floor: unified.ApprovalAdmin, actor: unified.ActionActor{SubjectID: "bad", Kind: unified.ActionActorUser, OrgID: "default"}, method: unified.MethodSession}, + {name: "requester under separation", floor: unified.ApprovalAdmin, separate: true, actor: user("requester"), method: unified.MethodSession}, + {name: "wrong evidence version", floor: unified.ApprovalAdmin, actor: user("version"), method: unified.MethodSession, mutate: func(record *unified.ActionAuditRecord, event *unified.ActionLifecycleEvent) { + record.Approvals[0].Evidence.Version = 2 + event.Decision = &record.Approvals[0] + }}, + {name: "zero issued time", floor: unified.ApprovalAdmin, actor: user("zero-time"), method: unified.MethodSession, mutate: func(record *unified.ActionAuditRecord, event *unified.ActionLifecycleEvent) { + record.Approvals[0].Evidence.IssuedAt = time.Time{} + event.Decision = &record.Approvals[0] + }}, + {name: "bad expiry", floor: unified.ApprovalAdmin, actor: user("expiry"), method: unified.MethodSession, mutate: func(record *unified.ActionAuditRecord, event *unified.ActionLifecycleEvent) { + record.Approvals[0].Evidence.ExpiresAt = record.Approvals[0].Evidence.IssuedAt.Add(-time.Second) + event.Decision = &record.Approvals[0] + }}, + {name: "future issued evidence", floor: unified.ApprovalAdmin, actor: user("future-issued"), method: unified.MethodSession, mutate: func(record *unified.ActionAuditRecord, event *unified.ActionLifecycleEvent) { + record.Approvals[0].Evidence.IssuedAt = record.Approvals[0].Timestamp.Add(time.Second) + event.Decision = &record.Approvals[0] + }}, + {name: "expired at decision", floor: unified.ApprovalAdmin, actor: user("expired-decision"), method: unified.MethodSession, mutate: func(record *unified.ActionAuditRecord, event *unified.ActionLifecycleEvent) { + record.Approvals[0].Evidence.IssuedAt = record.Approvals[0].Timestamp.Add(-2 * time.Minute) + record.Approvals[0].Evidence.ExpiresAt = record.Approvals[0].Timestamp.Add(-time.Minute) + event.Decision = &record.Approvals[0] + }}, + {name: "session challenge identity", floor: unified.ApprovalAdmin, actor: user("session-challenge"), method: unified.MethodSession, mutate: func(record *unified.ActionAuditRecord, event *unified.ActionLifecycleEvent) { + record.Approvals[0].Evidence.ChallengeID = "unexpected-challenge" + event.Decision = &record.Approvals[0] + }}, + {name: "missing crypto challenge", floor: unified.ApprovalMultiFactor, actor: user("missing-challenge"), method: unified.MethodWebAuthnUV, mutate: func(record *unified.ActionAuditRecord, event *unified.ActionLifecycleEvent) { + record.Approvals[0].Evidence.ChallengeID = "" + event.Decision = &record.Approvals[0] + }}, + {name: "weak method at mfa", floor: unified.ApprovalMultiFactor, actor: user("weak-mfa"), method: unified.MethodSession}, + {name: "api token at mfa", floor: unified.ApprovalMultiFactor, actor: apiToken, method: unified.MethodAPIToken}, + {name: "approved dry run", floor: unified.ApprovalDryRun, actor: user("dry-run"), method: unified.MethodSession}, + } + for index, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + record := ledgerValidationRecord(fmt.Sprintf("act_ledger_%d", index), tc.floor, 1, tc.separate, now.Add(time.Duration(index)*time.Second)) + approval := ledgerValidationApproval(record, tc.actor, tc.method, record.CreatedAt.Add(time.Minute)) + assertMalformedLedgerAppendRejected(t, store, record, approval, tc.mutate) + expectations = append(expectations, ledgerValidationExpectation{actionID: record.ID}) + }) + } + + duplicateRecord := ledgerValidationRecord("act_ledger_duplicate", unified.ApprovalAdmin, 2, false, now.Add(20*time.Second)) + firstApproval := ledgerValidationApproval(duplicateRecord, user("duplicate"), unified.MethodSession, duplicateRecord.CreatedAt.Add(time.Minute)) + initial := []unified.ActionLifecycleEvent{{ActionID: duplicateRecord.ID, Timestamp: duplicateRecord.CreatedAt, State: unified.ActionStatePlanned}, {ActionID: duplicateRecord.ID, Timestamp: duplicateRecord.CreatedAt, State: unified.ActionStatePending}} + if _, created, err := store.CreateActionAudit(duplicateRecord, initial); err != nil || !created { + t.Fatalf("create duplicate audit created=%v err=%v", created, err) + } + first, firstEvent, _ := unified.ApplyActionDecision(duplicateRecord, firstApproval, firstApproval.Timestamp) + if err := store.RecordActionDecision(first, firstEvent); err != nil { + t.Fatal(err) + } + duplicateApproval := ledgerValidationApproval(first, user("duplicate"), unified.MethodSession, firstApproval.Timestamp.Add(time.Second)) + proposed := first + proposed.DecisionRevision++ + proposed.Approvals = append(append([]unified.ActionApprovalRecord(nil), first.Approvals...), duplicateApproval) + duplicateEvent := unified.ActionLifecycleEvent{ActionID: proposed.ID, Timestamp: duplicateApproval.Timestamp, State: unified.ActionStatePending, Kind: unified.ActionLifecycleEventDecision, DecisionRevision: proposed.DecisionRevision, Decision: &proposed.Approvals[1], Actor: duplicateApproval.Actor, Message: "Approval recorded; 1 of 2 distinct approvals collected."} + beforeEvents, _ := store.GetActionLifecycleEvents(proposed.ID, time.Time{}, 20) + if err := store.RecordActionDecision(proposed, duplicateEvent); err == nil { + t.Fatal("duplicate actor append unexpectedly persisted") + } + authoritative, _, _ := store.GetActionAudit(proposed.ID) + afterEvents, _ := store.GetActionLifecycleEvents(proposed.ID, time.Time{}, 20) + if authoritative.DecisionRevision != 1 || len(authoritative.Approvals) != 1 || len(afterEvents) != len(beforeEvents) { + t.Fatalf("duplicate actor mutated ledger: record=%#v events=%#v", authoritative, afterEvents) + } + expectations = append(expectations, ledgerValidationExpectation{actionID: proposed.ID, decisionRevision: 1, approvalCount: 1}) + return expectations +} + +func assertDecisionEventIdentityAndTransitionUniqueness(t *testing.T, store unified.ResourceStore) { + t.Helper() + actionID := persistTwoPendingDecisionRevisions(t, store) + events, err := store.GetActionLifecycleEvents(actionID, time.Time{}, 20) + if err != nil { + t.Fatal(err) + } + decisionEvents := map[uint64]unified.ActionLifecycleEvent{} + for _, event := range events { + if event.Kind == unified.ActionLifecycleEventDecision { + decisionEvents[event.DecisionRevision] = event + } + } + if len(decisionEvents) != 2 || decisionEvents[1].Decision == nil || decisionEvents[2].Decision == nil { + t.Fatalf("decision events=%#v, want durable revisions one and two", decisionEvents) + } + if err := store.RecordActionLifecycleEvent(decisionEvents[1]); err == nil { + t.Fatal("duplicate decision revision should be rejected") + } + current, found, err := store.GetActionAudit(actionID) + if err != nil || !found { + t.Fatalf("current audit found=%v err=%v", found, err) + } + thirdActor := testActionActor("admin-three", "default") + thirdEvidence := unified.ApprovalEvidence{Version: 1, Method: unified.MethodSession, Actor: thirdActor, OrgID: "default", ActionID: actionID, PlanHash: current.Plan.PlanHash, Outcome: unified.OutcomeApproved, IssuedAt: time.Now().UTC()} + thirdApproval := unified.ActionApprovalRecord{Actor: thirdActor.SubjectID, ActorBinding: thirdActor, Method: unified.MethodSession, Timestamp: thirdEvidence.IssuedAt, Outcome: unified.OutcomeApproved, Reason: "approve admin-three", Evidence: &thirdEvidence} + desired, desiredEvent, err := unified.ApplyActionDecision(current, thirdApproval, thirdApproval.Timestamp) + if err != nil { + t.Fatal(err) + } + cloneAppend := func(record unified.ActionAuditRecord, event unified.ActionLifecycleEvent) (unified.ActionAuditRecord, unified.ActionLifecycleEvent) { + record.Approvals = append([]unified.ActionApprovalRecord(nil), record.Approvals...) + for index := range record.Approvals { + if record.Approvals[index].Evidence != nil { + evidence := *record.Approvals[index].Evidence + record.Approvals[index].Evidence = &evidence + } + } + event.Decision = &record.Approvals[len(record.Approvals)-1] + return record, event + } + type maliciousAppend struct { + name string + record unified.ActionAuditRecord + event unified.ActionLifecycleEvent + } + var attempts []maliciousAppend + replaced, replacedEvent := cloneAppend(desired, desiredEvent) + replaced.Approvals[0] = replaced.Approvals[1] + attempts = append(attempts, maliciousAppend{"replaced prior approval", replaced, replacedEvent}) + reordered, reorderedEvent := cloneAppend(desired, desiredEvent) + reordered.Approvals[0], reordered.Approvals[1] = reordered.Approvals[1], reordered.Approvals[0] + attempts = append(attempts, maliciousAppend{"reordered prior approvals", reordered, reorderedEvent}) + removed, removedEvent := cloneAppend(desired, desiredEvent) + removed.Approvals = removed.Approvals[1:] + removedEvent.Decision = &removed.Approvals[len(removed.Approvals)-1] + attempts = append(attempts, maliciousAppend{"removed prior approval", removed, removedEvent}) + edited, editedEvent := cloneAppend(desired, desiredEvent) + edited.Approvals[0].Reason = "edited prior reason" + attempts = append(attempts, maliciousAppend{"edited prior approval", edited, editedEvent}) + stateMismatch, stateMismatchEvent := cloneAppend(desired, desiredEvent) + stateMismatchEvent.State = unified.ActionStatePending + attempts = append(attempts, maliciousAppend{"suppressed approved transition", stateMismatch, stateMismatchEvent}) + for _, field := range []string{"org", "plan", "action"} { + wrongEvidence, wrongEvidenceEvent := cloneAppend(desired, desiredEvent) + switch field { + case "org": + wrongEvidence.Approvals[2].Evidence.OrgID = "other" + case "plan": + wrongEvidence.Approvals[2].Evidence.PlanHash = "sha256:other" + case "action": + wrongEvidence.Approvals[2].Evidence.ActionID = "act_other" + } + wrongEvidenceEvent.Decision = &wrongEvidence.Approvals[2] + attempts = append(attempts, maliciousAppend{"wrong evidence " + field, wrongEvidence, wrongEvidenceEvent}) + } + rejectActor := testActionActor("rejector", "default") + rejectEvidence := unified.ApprovalEvidence{Version: 1, Method: unified.MethodSession, Actor: rejectActor, OrgID: "default", ActionID: actionID, PlanHash: current.Plan.PlanHash, Outcome: unified.OutcomeRejected, IssuedAt: thirdApproval.Timestamp} + rejectApproval := unified.ActionApprovalRecord{Actor: rejectActor.SubjectID, ActorBinding: rejectActor, Method: unified.MethodSession, Timestamp: thirdApproval.Timestamp, Outcome: unified.OutcomeRejected, Reason: "reject", Evidence: &rejectEvidence} + forgedRejected, forgedRejectedEvent, err := unified.ApplyActionDecision(current, rejectApproval, rejectApproval.Timestamp) + if err != nil { + t.Fatal(err) + } + forgedRejected.State = unified.ActionStateApproved + forgedRejectedEvent.State = unified.ActionStateApproved + forgedRejectedEvent.Message = "Action approved. Execution remains pending a separate execution contract." + attempts = append(attempts, maliciousAppend{"forged rejected transition", forgedRejected, forgedRejectedEvent}) + for _, attempt := range attempts { + if err := store.RecordActionDecision(attempt.record, attempt.event); err == nil { + t.Fatalf("%s error=%v", attempt.name, err) + } + } + afterConflict, _, _ := store.GetActionAudit(actionID) + if afterConflict.DecisionRevision != current.DecisionRevision || len(afterConflict.Approvals) != len(current.Approvals) || afterConflict.State != current.State { + t.Fatalf("conflicting rewrite mutated audit: before=%#v after=%#v", current, afterConflict) + } + for _, state := range []unified.ActionState{unified.ActionStatePlanned, unified.ActionStateExecuting, unified.ActionStateCompleted} { + event := unified.ActionLifecycleEvent{ActionID: actionID, Timestamp: time.Now().UTC(), State: state, Actor: "test"} + if state != unified.ActionStatePlanned { + if err := store.RecordActionLifecycleEvent(event); err != nil { + t.Fatalf("first %s transition: %v", state, err) + } + } + if err := store.RecordActionLifecycleEvent(event); err == nil { + t.Fatalf("duplicate %s transition should be rejected", state) + } + } +} + +func TestDecisionEventIdentityAndTransitionUniquenessMemoryStore(t *testing.T) { + store := unified.NewMemoryStore() + assertDecisionEventIdentityAndTransitionUniqueness(t, store) + _ = assertStoreLedgerRejectsMalformedAuthority(t, store) +} + +func TestDecisionEventIdentityAndTransitionUniquenessSQLiteStore(t *testing.T) { + dir := t.TempDir() + store, err := unified.NewSQLiteResourceStore(dir, "default") + if err != nil { + t.Fatal(err) + } + defer store.Close() + assertDecisionEventIdentityAndTransitionUniqueness(t, store) + expectations := assertStoreLedgerRejectsMalformedAuthority(t, store) + reopened, err := unified.NewSQLiteResourceStore(dir, "default") + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + for _, expectation := range expectations { + authoritative, found, err := reopened.GetActionAudit(expectation.actionID) + if err != nil || !found || authoritative.DecisionRevision != expectation.decisionRevision || len(authoritative.Approvals) != expectation.approvalCount { + t.Fatalf("reopen changed rejected ledger chain for %s: found=%v err=%v record=%#v", expectation.actionID, found, err, authoritative) + } + } +} + +func TestSQLiteReopenPreservesDecisionEventRevisionOrdering(t *testing.T) { + dir := t.TempDir() + store, err := unified.NewSQLiteResourceStore(dir, "default") + if err != nil { + t.Fatal(err) + } + actionID := persistTwoPendingDecisionRevisions(t, store) + if err := store.Close(); err != nil { + t.Fatal(err) + } + reopened, err := unified.NewSQLiteResourceStore(dir, "default") + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + events, err := reopened.GetActionLifecycleEvents(actionID, time.Time{}, 20) + if err != nil { + t.Fatal(err) + } + var revisions []uint64 + for _, event := range events { + if event.Kind == unified.ActionLifecycleEventDecision { + revisions = append(revisions, event.DecisionRevision) + if event.Decision == nil || event.Decision.Evidence == nil { + t.Fatalf("reopened decision event lost binding: %#v", event) + } + } + } + if len(revisions) != 2 || revisions[0] != 2 || revisions[1] != 1 { + t.Fatalf("reopened decision revisions=%v, want [2 1]", revisions) + } +} + +func TestDecideRejectsDuplicateActorTowardQuorum(t *testing.T) { + service, _ := actorApprovalService(t, unified.ApprovalAdmin) + requirement := unified.ApprovalRequirementForFloor(unified.ApprovalAdmin) + requirement.Quorum = 2 + plan := planForApprovalTest(t, service, &requirement) + actor := testActionActor("admin-one", "default") + first := decisionForApprovalTest(plan, actor, unified.OutcomeApproved, "approve", unified.MethodSession, "", time.Now().UTC()) + if _, err := service.Decide(context.Background(), "default", plan.ActionID, first); err != nil { + t.Fatal(err) + } + conflict := first + conflict.Evidence.Method = unified.MethodAPIToken + if _, err := service.Decide(context.Background(), "default", plan.ActionID, conflict); !errors.Is(err, ErrDecisionReplayConflict) { + t.Fatalf("error=%v", err) + } +} + +func TestExecuteRejectsLegacyUnboundApproval(t *testing.T) { + service, store := actorApprovalService(t, unified.ApprovalAdmin) + now := time.Now().UTC() + record := unified.ActionAuditRecord{ + ID: "act_legacy", CreatedAt: now, UpdatedAt: now, State: unified.ActionStateApproved, + Request: unified.ActionRequest{RequestID: "legacy", ResourceID: "vm:42", CapabilityName: "restart", Reason: "legacy", RequestedBy: "legacy"}, + Plan: unified.ActionPlan{ActionID: "act_legacy", RequestID: "legacy", Allowed: true, RequiresApproval: true, ApprovalPolicy: unified.ApprovalAdmin, PlannedAt: now, ExpiresAt: now.Add(time.Minute), PlanHash: "sha256:legacy"}, + Approvals: []unified.ActionApprovalRecord{{Actor: "legacy-admin", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved, Timestamp: now}}, + } + if err := store.RecordActionAudit(record); err != nil { + t.Fatal(err) + } + if _, err := service.Execute(context.Background(), "default", record.ID, testActionActor("admin", "default"), ""); !errors.Is(err, unified.ErrActionReplanRequired) { + t.Fatalf("error=%v", err) + } +} + +func TestExecuteRejectsDryRunOnlyRegardlessOfTenantOverride(t *testing.T) { + service, _ := actorApprovalService(t, unified.ApprovalDryRun) + requirement := unified.ApprovalRequirementForFloor(unified.ApprovalAdmin) + if _, err := service.PlanWithOptions(context.Background(), "default", restartRequest(), PlanOptions{Actor: testActionActor("requester", "default"), ApprovalRequirement: &requirement}); err == nil { + t.Fatal("expected dry-run capability floor lowering to fail") + } +} diff --git a/internal/actionlifecycle/service.go b/internal/actionlifecycle/service.go index e0677cc5e..6ba14ccc9 100644 --- a/internal/actionlifecycle/service.go +++ b/internal/actionlifecycle/service.go @@ -73,10 +73,13 @@ type Store interface { // dependencies are resolved per call so late-bound wiring (executors and // publishers set after construction) stays current. type Service struct { - Registry func(orgID string) (*unified.ResourceRegistry, error) - Store func(orgID string) (Store, error) - Executor Executor - EmergencyStop func(orgID string) (bool, error) + Registry func(orgID string) (*unified.ResourceRegistry, error) + Store func(orgID string) (Store, error) + Executor Executor + EmergencyStop func(orgID string) (bool, error) + DecisionAuthorizer DecisionAuthorizer + ExecutionAuthorizer ExecutionAuthorizer + StepUpVerifier StepUpVerifier // OnActionCompleted receives every terminal (completed/failed) audit // record, including refused-before-dispatch failures, so SSE bridges // and reconcilers observe the full lifecycle regardless of transport. @@ -139,17 +142,64 @@ func (s *Service) WithPolicyMutation(write func() error) error { // PlanOptions carries broker-owned planning metadata that must never be // accepted from a public transport request body. type PlanOptions struct { + // Actor is trusted server context. Public transports derive it from the + // authenticated request and internal brokers stamp their fixed identity. + Actor unified.ActionActor + // ApprovalRequirement is an optional trusted, policy-resolved + // strengthening of the capability floor. Public transports cannot set it. + ApprovalRequirement *unified.ApprovalRequirement // Origin identifies the internal proposing surface and its // correlation IDs. Nil for operator/API-initiated plans. Origin *unified.ActionOrigin } +type DecisionAuthorizer interface { + AuthorizeDecision(ctx context.Context, orgID string, record unified.ActionAuditRecord, decision unified.ActionDecision) error +} + +type DecisionAuthorizerFunc func(context.Context, string, unified.ActionAuditRecord, unified.ActionDecision) error + +func (f DecisionAuthorizerFunc) AuthorizeDecision(ctx context.Context, orgID string, record unified.ActionAuditRecord, decision unified.ActionDecision) error { + return f(ctx, orgID, record, decision) +} + +type ExecutionAuthorizer interface { + AuthorizeExecution(ctx context.Context, orgID string, record unified.ActionAuditRecord, actor unified.ActionActor) error +} + +type ExecutionAuthorizerFunc func(context.Context, string, unified.ActionAuditRecord, unified.ActionActor) error + +func (f ExecutionAuthorizerFunc) AuthorizeExecution(ctx context.Context, orgID string, record unified.ActionAuditRecord, actor unified.ActionActor) error { + return f(ctx, orgID, record, actor) +} + +// StepUpVerifier verifies and atomically consumes a cryptographic challenge. +// No default verifier exists: MFA approvals fail closed until a durable +// server-owned verifier is installed. +type StepUpVerifier interface { + VerifyAndConsume(ctx context.Context, record unified.ActionAuditRecord, decision unified.ActionDecision) error +} + +type StepUpVerifierFunc func(context.Context, unified.ActionAuditRecord, unified.ActionDecision) error + +func (f StepUpVerifierFunc) VerifyAndConsume(ctx context.Context, record unified.ActionAuditRecord, decision unified.ActionDecision) error { + return f(ctx, record, decision) +} + // Sentinel errors for dependency failures. Callers map these to their // transport's unavailability semantics. var ( - ErrRegistryUnavailable = errors.New("resource registry unavailable") - ErrStoreUnavailable = errors.New("action audit store unavailable") - ErrExecutorUnavailable = errors.New("no action executor is configured") + ErrRegistryUnavailable = errors.New("resource registry unavailable") + ErrStoreUnavailable = errors.New("action audit store unavailable") + ErrExecutorUnavailable = errors.New("no action executor is configured") + ErrDecisionAuthorizationUnavailable = errors.New("action decision authorization unavailable") + ErrExecutionAuthorizationUnavailable = errors.New("action execution authorization unavailable") + ErrActionAuthorizationDenied = errors.New("action authorization denied") + ErrApprovalEvidenceInvalid = errors.New("approval evidence is not bound to this actor, organization, action, plan, and outcome") + ErrApprovalStepUpUnavailable = errors.New("cryptographic step-up approval is unavailable") + ErrApprovalActorNotHuman = errors.New("detached or service actors cannot satisfy human approval") + ErrApprovalSeparationRequired = errors.New("requester cannot approve this action") + ErrDecisionReplayConflict = errors.New("action decision replay conflicts with the persisted decision") ) // ResourceNotFoundError reports that the requested resource is not present @@ -283,14 +333,24 @@ func NormalizeRequest(req unified.ActionRequest) unified.ActionRequest { // availability check, and persists the plan-stage audit trail. Approval // requirements come from the capability's declared policy, never from the // caller. -func (s *Service) Plan(ctx context.Context, orgID string, req unified.ActionRequest) (unified.ActionPlan, error) { - return s.PlanWithOptions(ctx, orgID, req, PlanOptions{}) +func (s *Service) Plan(ctx context.Context, orgID string, req unified.ActionRequest, actor unified.ActionActor) (unified.ActionPlan, error) { + return s.PlanWithOptions(ctx, orgID, req, PlanOptions{Actor: actor}) } // PlanWithOptions is Plan plus broker-owned metadata. In-process proposing // surfaces use it to stamp the action's origin; the HTTP adapter always // calls plain Plan so a public request can never claim a first-party origin. func (s *Service) PlanWithOptions(ctx context.Context, orgID string, req unified.ActionRequest, opts PlanOptions) (unified.ActionPlan, error) { + orgID = strings.TrimSpace(orgID) + opts.Actor = unified.NormalizeActionActor(opts.Actor) + if err := unified.ValidateActionActor(opts.Actor); err != nil { + return unified.ActionPlan{}, &actionplanner.ValidationError{Field: "actor", Message: err.Error()} + } + if opts.Actor.OrgID != orgID { + return unified.ActionPlan{}, &actionplanner.ValidationError{Field: "actor.orgId", Message: "actor organization does not match request organization"} + } + req.Actor = opts.Actor + req.RequestedBy = opts.Actor.SubjectID req.ResourceID = unified.CanonicalResourceID(req.ResourceID) if req.ResourceID == "" { return unified.ActionPlan{}, &actionplanner.ValidationError{Field: "resourceId", Message: "resource id is required"} @@ -305,7 +365,13 @@ func (s *Service) PlanWithOptions(ctx context.Context, orgID string, req unified return unified.ActionPlan{}, &ResourceNotFoundError{ResourceID: req.ResourceID} } - plan, err := (actionplanner.Planner{}).Plan(req, *resource) + planner := actionplanner.Planner{} + var plan unified.ActionPlan + if opts.ApprovalRequirement != nil { + plan, err = planner.PlanWithRequirement(req, *resource, *opts.ApprovalRequirement) + } else { + plan, err = planner.Plan(req, *resource) + } if err != nil { if errors.Is(err, actionplanner.ErrCapabilityNotFound) { return unified.ActionPlan{}, &CapabilityNotFoundError{ @@ -519,12 +585,10 @@ func PlannedActionState(plan unified.ActionPlan) unified.ActionState { return unified.ActionStatePlanned } -// Decide applies an approval outcome to a pending action. The caller -// supplies the approval's actor, method, outcome, and reason; the service -// stamps the decision time when unset and persists the resulting state -// transition and lifecycle event atomically through the store contract. -func (s *Service) Decide(ctx context.Context, orgID, actionID string, approval unified.ActionApprovalRecord) (unified.ActionAuditRecord, error) { - _ = ctx +// Decide is the single human decision boundary. Trusted adapters provide a +// server-derived actor and evidence binding; authorization and approval-floor +// enforcement happen here before the append-only decision is persisted. +func (s *Service) Decide(ctx context.Context, orgID, actionID string, decision unified.ActionDecision) (unified.ActionAuditRecord, error) { actionID = strings.TrimSpace(actionID) if actionID == "" { return unified.ActionAuditRecord{}, &ActionNotFoundError{ActionID: actionID} @@ -547,12 +611,38 @@ func (s *Service) Decide(ctx context.Context, orgID, actionID string, approval u if record.State == unified.ActionStateExpired { return record, unified.ErrActionPlanExpired } + decision.Actor = unified.NormalizeActionActor(decision.Actor) + decision.Reason = strings.TrimSpace(decision.Reason) + decision.Evidence.Actor = unified.NormalizeActionActor(decision.Evidence.Actor) + if exact, conflict := decisionReplay(record, decision); exact { + return record, nil + } else if conflict { + return unified.ActionAuditRecord{}, ErrDecisionReplayConflict + } + if err := unified.ValidateHumanActionBinding(record, orgID); err != nil { + return unified.ActionAuditRecord{}, err + } + if err := validateDecisionBinding(record, orgID, decision); err != nil { + return unified.ActionAuditRecord{}, err + } + if s.DecisionAuthorizer == nil { + return unified.ActionAuditRecord{}, ErrDecisionAuthorizationUnavailable + } + if err := s.DecisionAuthorizer.AuthorizeDecision(ctx, orgID, record, decision); err != nil { + return unified.ActionAuditRecord{}, err + } + if err := s.validateApprovalFloor(ctx, record, decision, true); err != nil { + return unified.ActionAuditRecord{}, err + } + approval := unified.ActionApprovalRecord{ + Actor: decision.Actor.SubjectID, + ActorBinding: decision.Actor, + Method: decision.Evidence.Method, + Outcome: decision.Outcome, + Reason: decision.Reason, + Evidence: &decision.Evidence, + } if record.State != unified.ActionStatePending { - for _, existing := range record.Approvals { - if existing.Outcome == approval.Outcome { - return record, nil - } - } return unified.ActionAuditRecord{}, unified.ErrActionNotPending } @@ -560,26 +650,151 @@ func (s *Service) Decide(ctx context.Context, orgID, actionID string, approval u if approval.Timestamp.IsZero() { approval.Timestamp = now } - updated, event, err := unified.ApplyActionDecision(record, approval, now) - if err != nil { - return unified.ActionAuditRecord{}, err - } - if err := store.RecordActionDecision(updated, event); err != nil { - if errors.Is(err, unified.ErrActionNotPending) { - current, found, queryErr := store.GetActionAudit(actionID) - if queryErr == nil && found { - for _, existing := range current.Approvals { - if existing.Outcome == approval.Outcome { - return current, nil - } - } - } + for attempt := 0; attempt < 16; attempt++ { + updated, event, err := unified.ApplyActionDecision(record, approval, now) + if err != nil { return unified.ActionAuditRecord{}, err } - return unified.ActionAuditRecord{}, &PersistError{Op: "action decision", Err: err} + if err := store.RecordActionDecision(updated, event); err != nil { + if errors.Is(err, unified.ErrActionDecisionRevisionConflict) { + current, found, queryErr := store.GetActionAudit(actionID) + if queryErr != nil { + return unified.ActionAuditRecord{}, &QueryError{Op: "action decision retry", Err: queryErr} + } + if !found { + return unified.ActionAuditRecord{}, &ActionNotFoundError{ActionID: actionID} + } + if exact, conflict := decisionReplay(current, decision); exact { + return current, nil + } else if conflict { + return unified.ActionAuditRecord{}, ErrDecisionReplayConflict + } + if current.State != unified.ActionStatePending { + return unified.ActionAuditRecord{}, unified.ErrActionNotPending + } + if err := unified.ValidateHumanActionBinding(current, orgID); err != nil { + return unified.ActionAuditRecord{}, err + } + if err := validateDecisionBinding(current, orgID, decision); err != nil { + return unified.ActionAuditRecord{}, err + } + if err := s.DecisionAuthorizer.AuthorizeDecision(ctx, orgID, current, decision); err != nil { + return unified.ActionAuditRecord{}, err + } + if err := s.validateApprovalFloor(ctx, current, decision, false); err != nil { + return unified.ActionAuditRecord{}, err + } + record = current + continue + } + if errors.Is(err, unified.ErrActionNotPending) { + current, found, queryErr := store.GetActionAudit(actionID) + if queryErr == nil && found { + if exact, conflict := decisionReplay(current, decision); exact { + return current, nil + } else if conflict { + return unified.ActionAuditRecord{}, ErrDecisionReplayConflict + } + } + return unified.ActionAuditRecord{}, err + } + return unified.ActionAuditRecord{}, &PersistError{Op: "action decision", Err: err} + } + s.publishTransition(orgID, updated) + return updated, nil + } + return unified.ActionAuditRecord{}, &PersistError{Op: "action decision", Err: unified.ErrActionDecisionRevisionConflict} +} + +func decisionReplay(record unified.ActionAuditRecord, decision unified.ActionDecision) (exact, conflict bool) { + for _, approval := range record.Approvals { + actor := unified.NormalizeActionActor(approval.ActorBinding) + if !unified.ActionActorsEqual(actor, decision.Actor) { + continue + } + if approval.Outcome != decision.Outcome || strings.TrimSpace(approval.Reason) != strings.TrimSpace(decision.Reason) || approval.Evidence == nil { + return false, true + } + persisted := *approval.Evidence + persisted.Actor = unified.NormalizeActionActor(persisted.Actor) + requested := decision.Evidence + requested.Actor = unified.NormalizeActionActor(requested.Actor) + if decisionEvidenceReplayEqual(persisted, requested) { + return true, false + } + return false, true + } + return false, false +} + +func decisionEvidenceReplayEqual(persisted, requested unified.ApprovalEvidence) bool { + if persisted.Version != requested.Version || persisted.Method != requested.Method || + !unified.ActionActorsEqual(persisted.Actor, requested.Actor) || persisted.OrgID != requested.OrgID || + persisted.ActionID != requested.ActionID || persisted.PlanHash != requested.PlanHash || persisted.Outcome != requested.Outcome { + return false + } + switch persisted.Method { + case unified.MethodWebAuthnUV, unified.MethodDeviceKeyUV: + return strings.TrimSpace(persisted.ChallengeID) != "" && persisted.ChallengeID == requested.ChallengeID + case unified.MethodSession, unified.MethodAPIToken: + return true + default: + return false + } +} + +func validateDecisionBinding(record unified.ActionAuditRecord, orgID string, decision unified.ActionDecision) error { + if err := unified.ValidateActionActor(decision.Actor); err != nil || + (decision.Actor.Kind != unified.ActionActorUser && decision.Actor.Kind != unified.ActionActorAPIToken) || + decision.Actor.OrgID != strings.TrimSpace(orgID) { + return ErrApprovalActorNotHuman + } + requirement := unified.NormalizeApprovalRequirement(record.Plan.ApprovalRequirement, record.Plan.ApprovalPolicy) + if requirement.DisallowRequester && strings.EqualFold(decision.Actor.SubjectID, record.Request.Actor.SubjectID) { + return ErrApprovalSeparationRequired + } + evidence := decision.Evidence + evidence.Actor = unified.NormalizeActionActor(evidence.Actor) + if evidence.Version != 1 || !unified.ActionActorsEqual(evidence.Actor, decision.Actor) || evidence.OrgID != strings.TrimSpace(orgID) || + evidence.ActionID != record.ID || evidence.PlanHash != record.Plan.PlanHash || evidence.Outcome != decision.Outcome || evidence.IssuedAt.IsZero() { + return ErrApprovalEvidenceInvalid + } + return nil +} + +func (s *Service) validateApprovalFloor(ctx context.Context, record unified.ActionAuditRecord, decision unified.ActionDecision, consumeStepUp bool) error { + if decision.Outcome == unified.OutcomeRejected { + return nil + } + requirement := unified.NormalizeApprovalRequirement(record.Plan.ApprovalRequirement, record.Plan.ApprovalPolicy) + switch requirement.Floor { + case unified.ApprovalDryRun: + return unified.ErrActionDryRunOnly + case unified.ApprovalMultiFactor: + if decision.Actor.Kind == unified.ActionActorAPIToken { + return ErrApprovalStepUpUnavailable + } + if decision.Evidence.Method != unified.MethodWebAuthnUV && decision.Evidence.Method != unified.MethodDeviceKeyUV { + return ErrApprovalStepUpUnavailable + } + if strings.TrimSpace(decision.Evidence.ChallengeID) == "" || decision.Evidence.ExpiresAt.IsZero() || !s.now().Before(decision.Evidence.ExpiresAt) { + return ErrApprovalEvidenceInvalid + } + if s.StepUpVerifier == nil { + return ErrApprovalStepUpUnavailable + } + if !consumeStepUp { + return nil + } + return s.StepUpVerifier.VerifyAndConsume(ctx, record, decision) + case unified.ApprovalAdmin, unified.ApprovalNone: + if decision.Evidence.Method != unified.MethodSession && decision.Evidence.Method != unified.MethodAPIToken { + return ErrApprovalEvidenceInvalid + } + return nil + default: + return unified.ErrActionReplanRequired } - s.publishTransition(orgID, updated) - return updated, nil } // Execute runs an approved action to a terminal audit state. Every refusal @@ -588,7 +803,7 @@ func (s *Service) Decide(ctx context.Context, orgID, actionID string, approval u // locks are all persisted as refused executions (never silently dropped) // and published to the completion hook. There is no bypass that reaches // the executor without passing every gate. -func (s *Service) Execute(ctx context.Context, orgID, actionID, actor, reason string) (unified.ActionAuditRecord, error) { +func (s *Service) Execute(ctx context.Context, orgID, actionID string, actor unified.ActionActor, reason string) (unified.ActionAuditRecord, error) { actionID = strings.TrimSpace(actionID) if actionID == "" { return unified.ActionAuditRecord{}, &ActionNotFoundError{ActionID: actionID} @@ -604,8 +819,24 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID, actor, reason st if !ok { return unified.ActionAuditRecord{}, &ActionNotFoundError{ActionID: actionID} } + actor = unified.NormalizeActionActor(actor) + if err := unified.ValidateActionActor(actor); err != nil || + (actor.Kind != unified.ActionActorUser && actor.Kind != unified.ActionActorAPIToken) || + actor.OrgID != strings.TrimSpace(orgID) { + return unified.ActionAuditRecord{}, ErrApprovalActorNotHuman + } + if err := unified.ValidateHumanActionBinding(record, orgID); err != nil { + return unified.ActionAuditRecord{}, err + } + if s.ExecutionAuthorizer == nil { + return unified.ActionAuditRecord{}, ErrExecutionAuthorizationUnavailable + } + if err := s.ExecutionAuthorizer.AuthorizeExecution(ctx, orgID, record, actor); err != nil { + return unified.ActionAuditRecord{}, err + } + actorID := actor.SubjectID if record.State == unified.ActionStateExecuting { - return s.dispatchCommitted(ctx, orgID, store, record, actor) + return s.dispatchCommitted(ctx, orgID, store, record, actorID) } if record.State == unified.ActionStateCompleted || record.State == unified.ActionStateFailed { return record, nil @@ -623,7 +854,7 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID, actor, reason st if stopErr != nil { return unified.ActionAuditRecord{}, &PolicyCheckError{Err: stopErr} } - failed, persistErr := RecordRefusedExecution(store, record, actor, now, unified.ErrActionEmergencyStop) + failed, persistErr := RecordRefusedExecution(store, record, actorID, now, unified.ErrActionEmergencyStop) if persistErr != nil { return unified.ActionAuditRecord{}, &PersistError{Op: "emergency-stop refusal", Err: persistErr} } @@ -633,7 +864,7 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID, actor, reason st } if err := unified.ValidateActionExecutionStart(record, now); err != nil { if unified.IsPermanentActionExecutionRefusal(err) { - failed, persistErr := RecordRefusedExecution(store, record, actor, now, err) + failed, persistErr := RecordRefusedExecution(store, record, actorID, now, err) if persistErr != nil { return unified.ActionAuditRecord{}, &PersistError{Op: "refused action execution", Err: persistErr} } @@ -648,7 +879,7 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID, actor, reason st } if err := s.ValidatePlanFresh(orgID, record); err != nil { if errors.Is(err, unified.ErrActionPlanDrift) { - failed, persistErr := RecordRefusedExecution(store, record, actor, now, err) + failed, persistErr := RecordRefusedExecution(store, record, actorID, now, err) if persistErr != nil { return unified.ActionAuditRecord{}, &PersistError{Op: "refused action execution", Err: persistErr} } @@ -660,7 +891,7 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID, actor, reason st } if err := validateExecutionPolicy(store, record); err != nil { if unified.IsPermanentActionExecutionRefusal(err) { - failed, persistErr := RecordRefusedExecution(store, record, actor, now, err) + failed, persistErr := RecordRefusedExecution(store, record, actorID, now, err) if persistErr != nil { return unified.ActionAuditRecord{}, &PersistError{Op: "refused action execution", Err: persistErr} } @@ -671,7 +902,7 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID, actor, reason st return unified.ActionAuditRecord{}, &PolicyCheckError{Err: err} } - started, startEvent, err := unified.BeginActionExecution(record, actor, now) + started, startEvent, err := unified.BeginActionExecution(record, actorID, now) if err != nil { return unified.ActionAuditRecord{}, err } @@ -687,7 +918,7 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID, actor, reason st current, found, queryErr := store.GetActionAudit(actionID) if queryErr == nil && found { if current.State == unified.ActionStateExecuting { - return s.dispatchCommitted(ctx, orgID, store, current, actor) + return s.dispatchCommitted(ctx, orgID, store, current, actorID) } return current, nil } @@ -695,7 +926,7 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID, actor, reason st return unified.ActionAuditRecord{}, &PersistError{Op: "action execution start", Err: err} } s.publishTransition(orgID, started) - return s.dispatchCommitted(ctx, orgID, store, started, actor) + return s.dispatchCommitted(ctx, orgID, store, started, actorID) } // ExecuteUnderPolicy is the only automatic dispatch boundary. It revalidates diff --git a/internal/actionlifecycle/service_test.go b/internal/actionlifecycle/service_test.go index 1ad5b313c..4887b830a 100644 --- a/internal/actionlifecycle/service_test.go +++ b/internal/actionlifecycle/service_test.go @@ -3,6 +3,7 @@ package actionlifecycle import ( "context" "errors" + "strings" "sync/atomic" "testing" "time" @@ -61,28 +62,71 @@ func serviceForStore(t *testing.T, store unified.ResourceStore, resource unified registry := unified.NewRegistry(store) registry.IngestResources([]unified.Resource{resource}) return &Service{ - Registry: func(string) (*unified.ResourceRegistry, error) { return registry, nil }, - Store: func(string) (Store, error) { return store, nil }, - Executor: executor, + Registry: func(string) (*unified.ResourceRegistry, error) { return registry, nil }, + Store: func(string) (Store, error) { return store, nil }, + Executor: executor, + DecisionAuthorizer: DecisionAuthorizerFunc(func(context.Context, string, unified.ActionAuditRecord, unified.ActionDecision) error { return nil }), + ExecutionAuthorizer: ExecutionAuthorizerFunc(func(context.Context, string, unified.ActionAuditRecord, unified.ActionActor) error { return nil }), } } +func testActionActor(subject, orgID string) unified.ActionActor { + if strings.TrimSpace(subject) == "" { + subject = "operator" + } + return unified.ActionActor{SubjectID: subject, Kind: unified.ActionActorUser, CredentialID: "session:test", OrgID: orgID} +} + +func testActionDecision(t *testing.T, service *Service, orgID, actionID string, approval unified.ActionApprovalRecord) unified.ActionDecision { + t.Helper() + record, found, _ := service.Get(orgID, actionID) + planHash := "sha256:test" + if found { + planHash = record.Plan.PlanHash + } + actor := testActionActor(approval.Actor, orgID) + return unified.ActionDecision{ + Actor: actor, + Outcome: approval.Outcome, + Reason: approval.Reason, + Evidence: unified.ApprovalEvidence{ + Version: 1, Method: unified.MethodSession, Actor: actor, OrgID: orgID, + ActionID: actionID, PlanHash: planHash, Outcome: approval.Outcome, IssuedAt: time.Now().UTC(), + }, + } +} + +func testBoundLifecycleApproval(record unified.ActionAuditRecord, subject string, method unified.ApprovalMethod, outcome unified.ApprovalOutcome, at time.Time) unified.ActionApprovalRecord { + kind := unified.ActionActorUser + credential := "session:test" + if method == unified.MethodPolicy { + kind = unified.ActionActorPolicy + credential = "policy:test" + } + actor := unified.ActionActor{SubjectID: subject, Kind: kind, CredentialID: credential, OrgID: "default"} + evidence := unified.ApprovalEvidence{Version: 1, Method: method, Actor: actor, OrgID: "default", ActionID: record.ID, PlanHash: record.Plan.PlanHash, Outcome: outcome, IssuedAt: at} + return unified.ActionApprovalRecord{Actor: subject, ActorBinding: actor, Method: method, Timestamp: at, Outcome: outcome, Evidence: &evidence} +} + func runConcurrentPlanReplayCannotRewindTerminalAction(t *testing.T, store unified.ResourceStore) { t.Helper() delayed := &delayedCreateStore{ResourceStore: store, secondArrived: make(chan struct{}), releaseSecond: make(chan struct{})} executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}} service := serviceForStore(t, delayed, testResource(time.Now().UTC(), unified.ApprovalAdmin), executor) - firstPlan, err := service.Plan(context.Background(), "default", restartRequest()) + firstPlan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } secondResult := make(chan error, 1) - go func() { _, err := service.Plan(context.Background(), "default", restartRequest()); secondResult <- err }() + go func() { + _, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) + secondResult <- err + }() <-delayed.secondArrived - if _, err := service.Decide(context.Background(), "default", firstPlan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved}); err != nil { + if _, err := service.Decide(context.Background(), "default", firstPlan.ActionID, testActionDecision(t, service, "default", firstPlan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved})); err != nil { t.Fatal(err) } - if _, err := service.Execute(context.Background(), "default", firstPlan.ActionID, "operator", "proof"); err != nil { + if _, err := service.Execute(context.Background(), "default", firstPlan.ActionID, testActionActor("operator", "default"), "proof"); err != nil { t.Fatal(err) } close(delayed.releaseSecond) @@ -115,17 +159,17 @@ func runConcurrentExecuteAdmitsExecutorExactlyOnce(t *testing.T, store unified.R t.Helper() executor := &blockingExecutor{entered: make(chan struct{}), release: make(chan struct{})} service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalNone), executor) - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } first := make(chan error, 1) go func() { - _, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "first") + _, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "first") first <- err }() <-executor.entered - secondRecord, secondErr := service.Execute(context.Background(), "default", plan.ActionID, "operator", "second") + secondRecord, secondErr := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "second") if secondErr != nil { t.Fatalf("duplicate Execute: %v", secondErr) } @@ -159,17 +203,17 @@ func runConcurrentDuplicateExecuteAtClaimBoundaryCallsExecutorOnce(t *testing.T, blocked := &claimBoundaryStore{ResourceStore: store, entered: make(chan struct{}), release: make(chan struct{})} executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}} service := serviceForStore(t, blocked, testResource(time.Now().UTC(), unified.ApprovalNone), executor) - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } first := make(chan error, 1) go func() { - _, executeErr := service.Execute(context.Background(), "default", plan.ActionID, "operator", "first") + _, executeErr := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "first") first <- executeErr }() <-blocked.entered - second, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "duplicate") + second, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "duplicate") if err != nil || second.State != unified.ActionStateExecuting { t.Fatalf("duplicate=%#v err=%v", second, err) } @@ -201,7 +245,7 @@ func TestPlanReplayReturnsAuthoritativeApprovedExecutingAndTerminalRecords(t *te t.Run(string(state), func(t *testing.T) { store := unified.NewMemoryStore() service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalAdmin), &stubExecutor{}) - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } @@ -210,7 +254,8 @@ func TestPlanReplayReturnsAuthoritativeApprovedExecutingAndTerminalRecords(t *te if state == unified.ActionStateRejected { decisionOutcome = unified.OutcomeRejected } - decided, decisionEvent, err := unified.ApplyActionDecision(record, unified.ActionApprovalRecord{Actor: "operator", Outcome: decisionOutcome}, time.Now().UTC()) + decisionAt := time.Now().UTC() + decided, decisionEvent, err := unified.ApplyActionDecision(record, testBoundLifecycleApproval(record, "operator", unified.MethodSession, decisionOutcome, decisionAt), decisionAt) if err != nil || store.RecordActionDecision(decided, decisionEvent) != nil { t.Fatalf("decision: %v", err) } @@ -227,7 +272,7 @@ func TestPlanReplayReturnsAuthoritativeApprovedExecutingAndTerminalRecords(t *te } } } - returned, err := service.Plan(context.Background(), "default", restartRequest()) + returned, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } @@ -245,7 +290,7 @@ func TestPlanReplayReturnsAuthoritativeApprovedExecutingAndTerminalRecords(t *te func TestPlanReplayRejectsConflictingOriginForDeterministicActionID(t *testing.T) { store := unified.NewMemoryStore() service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalAdmin), &stubExecutor{}) - first := PlanOptions{Origin: &unified.ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "proposal-1"}} + first := PlanOptions{Actor: testActionActor("requester", "default"), Origin: &unified.ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "proposal-1"}} if _, err := service.PlanWithOptions(context.Background(), "default", restartRequest(), first); err != nil { t.Fatal(err) } @@ -262,11 +307,11 @@ func TestExecutePublishesPersistedExecutingTransition(t *testing.T) { service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalNone), executor) var states []unified.ActionState service.OnActionTransition = func(_ string, record unified.ActionAuditRecord) { states = append(states, record.State) } - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } - if _, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", ""); err != nil { + if _, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), ""); err != nil { t.Fatal(err) } want := []unified.ActionState{unified.ActionStatePlanned, unified.ActionStateExecuting, unified.ActionStateCompleted} @@ -288,7 +333,7 @@ func TestExecuteAfterSQLiteRestartDoesNotReadmitExecutingAction(t *testing.T) { } executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}} service := serviceForStore(t, first, testResource(time.Now().UTC(), unified.ApprovalNone), executor) - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } @@ -304,7 +349,7 @@ func TestExecuteAfterSQLiteRestartDoesNotReadmitExecutingAction(t *testing.T) { } defer second.Close() service = serviceForStore(t, second, testResource(time.Now().UTC(), unified.ApprovalNone), executor) - current, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "retry") + current, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "retry") if err != nil || current.State != unified.ActionStateExecuting || executor.calls != 0 { t.Fatalf("current=%#v err=%v calls=%d", current, err, executor.calls) } @@ -314,11 +359,11 @@ func TestExecuteTimeoutAfterSendWaitsForCorrelatedLateResponseWithoutResend(t *t store := unified.NewMemoryStore() executor := &reconcilingExecutor{executeErr: context.DeadlineExceeded, result: &unified.ExecutionResult{Success: true}} service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalNone), executor) - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } - current, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "timeout proof") + current, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "timeout proof") if !errors.Is(err, context.DeadlineExceeded) || current.State != unified.ActionStateExecuting { t.Fatalf("current=%#v err=%v", current, err) } @@ -330,12 +375,12 @@ func TestExecuteTimeoutAfterSendWaitsForCorrelatedLateResponseWithoutResend(t *t t.Fatalf("receipt found=%v err=%v", found, err) } // Duplicate execute is a query-only resume and cannot call ExecuteAction. - current, err = service.Execute(context.Background(), "default", plan.ActionID, "operator", "duplicate resume") + current, err = service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "duplicate resume") if err != nil || current.State != unified.ActionStateExecuting || executor.executeCalls != 1 || executor.reconcileCalls != 1 { t.Fatalf("current=%#v err=%v execute=%d reconcile=%d", current, err, executor.executeCalls, executor.reconcileCalls) } executor.found = true - current, err = service.Execute(context.Background(), "default", plan.ActionID, "operator", "late response") + current, err = service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "late response") if err != nil || current.State != unified.ActionStateCompleted || executor.executeCalls != 1 || executor.reconcileCalls != 2 { t.Fatalf("current=%#v err=%v execute=%d reconcile=%d", current, err, executor.executeCalls, executor.reconcileCalls) } @@ -353,11 +398,11 @@ func TestSQLiteRestartRecoveryReconcilesReceiptPendingWithoutResend(t *testing.T } executor := &reconcilingExecutor{executeErr: context.DeadlineExceeded, result: &unified.ExecutionResult{Success: true}} service := serviceForStore(t, first, testResource(now, unified.ApprovalNone), executor) - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } - if _, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "before restart"); !errors.Is(err, context.DeadlineExceeded) { + if _, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "before restart"); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("Execute error=%v", err) } if err := first.Close(); err != nil { @@ -377,7 +422,7 @@ func TestSQLiteRestartRecoveryReconcilesReceiptPendingWithoutResend(t *testing.T if executor.executeCalls != 1 || executor.reconcileCalls != 1 { t.Fatalf("execute=%d reconcile=%d", executor.executeCalls, executor.reconcileCalls) } - current, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "duplicate after recovery") + current, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "duplicate after recovery") if err != nil || current.State != unified.ActionStateCompleted || executor.executeCalls != 1 { t.Fatalf("current=%#v err=%v execute=%d", current, err, executor.executeCalls) } @@ -388,7 +433,7 @@ func TestActionDetailAndInboxMaterializeExplicitExpiry(t *testing.T) { store := unified.NewMemoryStore() service := serviceForStore(t, store, testResource(now, unified.ApprovalAdmin), &stubExecutor{}) service.Now = func() time.Time { return now } - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } @@ -408,7 +453,7 @@ func TestActionDetailAndInboxMaterializeExplicitExpiry(t *testing.T) { if err != nil || len(settled) != 1 || settled[0].State != unified.ActionStateExpired { t.Fatalf("settled=%#v err=%v", settled, err) } - if _, err := service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Outcome: unified.OutcomeApproved}); !errors.Is(err, unified.ErrActionPlanExpired) { + if _, err := service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, service, "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Outcome: unified.OutcomeApproved})); !errors.Is(err, unified.ErrActionPlanExpired) { t.Fatalf("Decide error=%v", err) } } @@ -432,7 +477,7 @@ func runPolicyAdmissionCommitsAtomically(t *testing.T, store unified.ResourceSto executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}} service := serviceForStore(t, store, testResource(now, unified.ApprovalAdmin), executor) service.Now = func() time.Time { return now } - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } @@ -541,7 +586,7 @@ func runPolicyBarrierRevocations(t *testing.T, storeFactory func(t *testing.T) u executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}} service := serviceForStore(t, store, testResource(now, unified.ApprovalAdmin), executor) service.Now = func() time.Time { return now } - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } @@ -586,14 +631,14 @@ func runHumanApprovalSurvivesPolicyRevocation(t *testing.T, store unified.Resour now := time.Now().UTC() executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}} service := serviceForStore(t, store, testResource(now, unified.ApprovalAdmin), executor) - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } - if _, err = service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved}); err != nil { + if _, err = service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, service, "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved})); err != nil { t.Fatal(err) } - if _, err = service.Execute(context.Background(), "default", plan.ActionID, "operator", ""); err != nil || executor.calls != 1 { + if _, err = service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), ""); err != nil || executor.calls != 1 { t.Fatalf("err=%v calls=%d", err, executor.calls) } } @@ -616,19 +661,19 @@ func runEmergencyStopBlocksHumanAndPolicy(t *testing.T, store unified.ResourceSt service.EmergencyStop = func(string) (bool, error) { return true, nil } human := restartRequest() human.RequestID = "human-stop" - plan, err := service.Plan(context.Background(), "default", human) + plan, err := service.Plan(context.Background(), "default", human, testActionActor("requester", "default")) if err != nil { t.Fatal(err) } - if _, err = service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved}); err != nil { + if _, err = service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, service, "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved})); err != nil { t.Fatal(err) } - if _, err = service.Execute(context.Background(), "default", plan.ActionID, "operator", ""); !errors.Is(err, unified.ErrActionEmergencyStop) { + if _, err = service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), ""); !errors.Is(err, unified.ErrActionEmergencyStop) { t.Fatalf("human error=%v", err) } policy := restartRequest() policy.RequestID = "policy-stop" - policyPlan, err := service.Plan(context.Background(), "default", policy) + policyPlan, err := service.Plan(context.Background(), "default", policy, testActionActor("requester", "default")) if err != nil { t.Fatal(err) } @@ -660,13 +705,20 @@ func TestLegacyPolicyApprovalWithoutLeaseFailsClosedAfterRestart(t *testing.T) { t.Fatal(err) } now := time.Now().UTC() - service := serviceForStore(t, first, testResource(now, unified.ApprovalAdmin), &stubExecutor{}) - plan, err := service.Plan(context.Background(), "default", restartRequest()) + request := restartRequest() + request.Actor = testActionActor("requester", "default") + request.RequestedBy = request.Actor.SubjectID + plan, err := (actionplanner.Planner{Now: func() time.Time { return now }}).Plan(request, testResource(now, unified.ApprovalAdmin)) if err != nil { t.Fatal(err) } - if _, err = service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "pulse_patrol_policy", Method: unified.MethodPolicy, Outcome: unified.OutcomeApproved}); err != nil { - t.Fatal(err) + legacyApproved := unified.ActionAuditRecord{ + ID: plan.ActionID, CreatedAt: now, UpdatedAt: now, State: unified.ActionStateApproved, + Request: request, Plan: plan, + Approvals: []unified.ActionApprovalRecord{{Actor: "pulse_patrol_policy", Method: unified.MethodPolicy, Timestamp: now, Outcome: unified.OutcomeApproved}}, + } + if err := first.RecordActionAudit(legacyApproved); err != nil { + t.Fatalf("legacy audit: %v", err) } if err = first.Close(); err != nil { t.Fatal(err) @@ -677,7 +729,7 @@ func TestLegacyPolicyApprovalWithoutLeaseFailsClosedAfterRestart(t *testing.T) { } defer second.Close() executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}} - service = serviceForStore(t, second, testResource(now, unified.ApprovalAdmin), executor) + service := serviceForStore(t, second, testResource(now, unified.ApprovalAdmin), executor) failed, err := service.ExecuteUnderPolicy(context.Background(), "default", plan.ActionID, "pulse_patrol_policy", func(_ context.Context, record unified.ActionAuditRecord, at time.Time) (unified.ActionPolicyAuthorizationLease, string, error) { return policyTestLease(record, at), "policy", nil }) @@ -694,7 +746,7 @@ func TestQueuedPolicyActionRevalidatesAfterSQLiteRestart(t *testing.T) { } now := time.Now().UTC() service := serviceForStore(t, first, testResource(now, unified.ApprovalAdmin), &stubExecutor{}) - plan, err := service.Plan(context.Background(), "default", restartRequest()) + plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatal(err) } @@ -813,7 +865,9 @@ func newServiceEnv(t *testing.T, resource unified.Resource) *serviceEnv { } return env.store, nil }, - Executor: env.executor, + Executor: env.executor, + DecisionAuthorizer: DecisionAuthorizerFunc(func(context.Context, string, unified.ActionAuditRecord, unified.ActionDecision) error { return nil }), + ExecutionAuthorizer: ExecutionAuthorizerFunc(func(context.Context, string, unified.ActionAuditRecord, unified.ActionActor) error { return nil }), OnActionCompleted: func(record unified.ActionAuditRecord) { env.completed = append(env.completed, record) }, @@ -836,7 +890,7 @@ func TestPlanPersistsPendingAuditAndLifecycle(t *testing.T) { now := time.Now().UTC() env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) - plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatalf("Plan: %v", err) } @@ -860,7 +914,7 @@ func TestPlanPersistsPendingAuditAndLifecycle(t *testing.T) { } // Idempotent replan must not duplicate lifecycle events. - if _, err := env.service.Plan(context.Background(), "default", restartRequest()); err != nil { + if _, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")); err != nil { t.Fatalf("replan: %v", err) } events, err = env.store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 10) @@ -879,13 +933,13 @@ func TestPlanFailsClosedOnUnknownResourceAndCapability(t *testing.T) { missing := restartRequest() missing.ResourceID = "vm:404" var notFound *ResourceNotFoundError - if _, err := env.service.Plan(context.Background(), "default", missing); !errors.As(err, ¬Found) { + if _, err := env.service.Plan(context.Background(), "default", missing, testActionActor("requester", "default")); !errors.As(err, ¬Found) { t.Fatalf("unknown resource error = %v, want ResourceNotFoundError", err) } unknownCap := restartRequest() unknownCap.CapabilityName = "detonate" - _, err := env.service.Plan(context.Background(), "default", unknownCap) + _, err := env.service.Plan(context.Background(), "default", unknownCap, testActionActor("requester", "default")) var capErr *CapabilityNotFoundError if !errors.As(err, &capErr) || !errors.Is(err, actionplanner.ErrCapabilityNotFound) { t.Fatalf("unknown capability error = %v, want CapabilityNotFoundError wrapping ErrCapabilityNotFound", err) @@ -897,7 +951,7 @@ func TestPlanFailsClosedOnUnknownResourceAndCapability(t *testing.T) { empty := restartRequest() empty.ResourceID = " " var validation *actionplanner.ValidationError - if _, err := env.service.Plan(context.Background(), "default", empty); !errors.As(err, &validation) { + if _, err := env.service.Plan(context.Background(), "default", empty, testActionActor("requester", "default")); !errors.As(err, &validation) { t.Fatalf("empty resource id error = %v, want ValidationError", err) } @@ -920,7 +974,7 @@ func TestPlanAvailabilityRefusalPersistsNothing(t *testing.T) { Reason: "no connected command agent", } - _, err := env.service.Plan(context.Background(), "default", restartRequest()) + _, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) var refused *AvailabilityRefusedError if !errors.As(err, &refused) { t.Fatalf("error = %v, want AvailabilityRefusedError", err) @@ -941,17 +995,18 @@ func TestDecideApprovesPendingAction(t *testing.T) { now := time.Now().UTC() env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) - plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatalf("Plan: %v", err) } - updated, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{ + updated, err := env.service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, env.service, "default", plan.ActionID, unified.ActionApprovalRecord{ Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved, Reason: "confirmed outage", - }) + })) + if err != nil { t.Fatalf("Decide: %v", err) } @@ -963,9 +1018,9 @@ func TestDecideApprovesPendingAction(t *testing.T) { } var notFound *ActionNotFoundError - if _, err := env.service.Decide(context.Background(), "default", "act_missing", unified.ActionApprovalRecord{ + if _, err := env.service.Decide(context.Background(), "default", "act_missing", testActionDecision(t, env.service, "default", "act_missing", unified.ActionApprovalRecord{ Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved, - }); !errors.As(err, ¬Found) { + })); !errors.As(err, ¬Found) { t.Fatalf("unknown action error = %v, want ActionNotFoundError", err) } } @@ -974,17 +1029,17 @@ func TestExecuteRunsApprovedActionToTerminalAudit(t *testing.T) { now := time.Now().UTC() env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) - plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatalf("Plan: %v", err) } - if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{ + if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, env.service, "default", plan.ActionID, unified.ActionApprovalRecord{ Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved, - }); err != nil { + })); err != nil { t.Fatalf("Decide: %v", err) } - completed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "operator@example.com", "approved restart") + completed, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator@example.com", "default"), "approved restart") if err != nil { t.Fatalf("Execute: %v", err) } @@ -1011,11 +1066,11 @@ func TestExecuteRefusesUnapprovedActionWithoutDispatch(t *testing.T) { now := time.Now().UTC() env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) - plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatalf("Plan: %v", err) } - if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "operator@example.com", ""); !errors.Is(err, unified.ErrActionNotApproved) { + if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator@example.com", "default"), ""); !errors.Is(err, unified.ErrActionNotApproved) { t.Fatalf("error = %v, want ErrActionNotApproved", err) } if env.executor.calls != 0 { @@ -1027,7 +1082,7 @@ func TestExecuteRefusesRemediationLockedResource(t *testing.T) { now := time.Now().UTC() env := newServiceEnv(t, testResource(now, unified.ApprovalNone)) - plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatalf("Plan: %v", err) } @@ -1038,7 +1093,7 @@ func TestExecuteRefusesRemediationLockedResource(t *testing.T) { t.Fatalf("SetResourceOperatorState: %v", err) } - failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", "") + failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("agent:test", "default"), "") if !errors.Is(err, unified.ErrResourceRemediationLocked) { t.Fatalf("error = %v, want ErrResourceRemediationLocked", err) } @@ -1057,7 +1112,7 @@ func TestExecuteRefusesDriftedPlan(t *testing.T) { now := time.Now().UTC() env := newServiceEnv(t, testResource(now, unified.ApprovalNone)) - plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatalf("Plan: %v", err) } @@ -1068,7 +1123,7 @@ func TestExecuteRefusesDriftedPlan(t *testing.T) { env.registry = unified.NewRegistry(env.store) env.registry.IngestResources([]unified.Resource{drifted}) - failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", "") + failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("agent:test", "default"), "") if !errors.Is(err, unified.ErrActionPlanDrift) { t.Fatalf("error = %v, want ErrActionPlanDrift", err) } @@ -1084,12 +1139,12 @@ func TestExecuteFailsClosedWithoutExecutor(t *testing.T) { now := time.Now().UTC() env := newServiceEnv(t, testResource(now, unified.ApprovalNone)) - plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatalf("Plan: %v", err) } env.service.Executor = nil - if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", ""); !errors.Is(err, ErrExecutorUnavailable) { + if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("agent:test", "default"), ""); !errors.Is(err, ErrExecutorUnavailable) { t.Fatalf("error = %v, want ErrExecutorUnavailable", err) } } @@ -1099,13 +1154,13 @@ func TestLifecycleFailsClosedWithoutStore(t *testing.T) { env := newServiceEnv(t, testResource(now, unified.ApprovalNone)) env.service.Store = func(string) (Store, error) { return nil, errors.New("db offline") } - if _, err := env.service.Plan(context.Background(), "default", restartRequest()); !errors.Is(err, ErrStoreUnavailable) { + if _, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")); !errors.Is(err, ErrStoreUnavailable) { t.Fatalf("Plan error = %v, want ErrStoreUnavailable", err) } - if _, err := env.service.Decide(context.Background(), "default", "act_x", unified.ActionApprovalRecord{Outcome: unified.OutcomeApproved}); !errors.Is(err, ErrStoreUnavailable) { + if _, err := env.service.Decide(context.Background(), "default", "act_x", testActionDecision(t, env.service, "default", "act_x", unified.ActionApprovalRecord{Outcome: unified.OutcomeApproved})); !errors.Is(err, ErrStoreUnavailable) { t.Fatalf("Decide error = %v, want ErrStoreUnavailable", err) } - if _, err := env.service.Execute(context.Background(), "default", "act_x", "agent:test", ""); !errors.Is(err, ErrStoreUnavailable) { + if _, err := env.service.Execute(context.Background(), "default", "act_x", testActionActor("agent:test", "default"), ""); !errors.Is(err, ErrStoreUnavailable) { t.Fatalf("Execute error = %v, want ErrStoreUnavailable", err) } } @@ -1138,7 +1193,7 @@ func TestPlanWithOptionsPersistsOriginAcrossLifecycle(t *testing.T) { InvestigationID: "inv-9", ProposalID: "prop-9", } - plan, err := env.service.PlanWithOptions(context.Background(), "default", restartRequest(), PlanOptions{Origin: origin}) + plan, err := env.service.PlanWithOptions(context.Background(), "default", restartRequest(), PlanOptions{Actor: testActionActor("requester", "default"), Origin: origin}) if err != nil { t.Fatalf("PlanWithOptions: %v", err) } @@ -1154,9 +1209,10 @@ func TestPlanWithOptionsPersistsOriginAcrossLifecycle(t *testing.T) { // Origin must survive the decision transition: the decision persists // the record loaded from the store, so the broker-owned metadata // stays reconcilable at approval time. - updated, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{ + updated, err := env.service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, env.service, "default", plan.ActionID, unified.ActionApprovalRecord{ Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved, - }) + })) + if err != nil { t.Fatalf("Decide: %v", err) } @@ -1167,7 +1223,7 @@ func TestPlanWithOptionsPersistsOriginAcrossLifecycle(t *testing.T) { // Plain Plan must never stamp an origin. plain := restartRequest() plain.RequestID = "req-plain" - plainPlan, err := env.service.Plan(context.Background(), "default", plain) + plainPlan, err := env.service.Plan(context.Background(), "default", plain, testActionActor("requester", "default")) if err != nil { t.Fatalf("Plan: %v", err) } @@ -1190,16 +1246,16 @@ func TestOnActionTransitionFiresAfterEachPersistedState(t *testing.T) { transitionOrgs = append(transitionOrgs, orgID) } - plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatalf("Plan: %v", err) } - if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{ + if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, env.service, "default", plan.ActionID, unified.ActionApprovalRecord{ Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved, - }); err != nil { + })); err != nil { t.Fatalf("Decide: %v", err) } - if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "operator@example.com", ""); err != nil { + if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator@example.com", "default"), ""); err != nil { t.Fatalf("Execute: %v", err) } @@ -1225,7 +1281,7 @@ func TestOnActionTransitionFiresForPersistedRefusals(t *testing.T) { transitions = append(transitions, record.State) } - plan, err := env.service.Plan(context.Background(), "default", restartRequest()) + plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) if err != nil { t.Fatalf("Plan: %v", err) } @@ -1235,7 +1291,7 @@ func TestOnActionTransitionFiresForPersistedRefusals(t *testing.T) { }); err != nil { t.Fatalf("SetResourceOperatorState: %v", err) } - if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", ""); !errors.Is(err, unified.ErrResourceRemediationLocked) { + if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("agent:test", "default"), ""); !errors.Is(err, unified.ErrResourceRemediationLocked) { t.Fatalf("error = %v, want ErrResourceRemediationLocked", err) } diff --git a/internal/actionplanner/planner.go b/internal/actionplanner/planner.go index de6b8e8fb..ba68667b5 100644 --- a/internal/actionplanner/planner.go +++ b/internal/actionplanner/planner.go @@ -49,6 +49,13 @@ type Planner struct { } func (p Planner) Plan(req unified.ActionRequest, resource unified.Resource) (unified.ActionPlan, error) { + return p.PlanWithRequirement(req, resource, unified.ApprovalRequirement{}) +} + +// PlanWithRequirement lets trusted policy resolution strengthen the +// capability-owned floor while keeping the resulting requirement inside the +// deterministic action identity and plan hash. +func (p Planner) PlanWithRequirement(req unified.ActionRequest, resource unified.Resource, requested unified.ApprovalRequirement) (unified.ActionPlan, error) { req = normalizeRequest(req) if err := validateRequest(req); err != nil { return unified.ActionPlan{}, err @@ -73,25 +80,38 @@ func (p Planner) Plan(req unified.ActionRequest, resource unified.Resource) (uni plannedAt := p.now() ttl := p.ttl() policy := normalizeApprovalPolicy(capability.MinimumApprovalLevel) + requirement := requested + if requirement.Version == 0 { + requirement = unified.ApprovalRequirementForFloor(policy) + } else { + requirement = unified.NormalizeApprovalRequirement(requirement, policy) + } + if err := unified.ValidateApprovalRequirement(requirement, policy); err != nil { + return unified.ActionPlan{}, &ValidationError{Field: "approvalRequirement", Message: err.Error()} + } requiresApproval := policy == unified.ApprovalAdmin || policy == unified.ApprovalMultiFactor + if requirement.Floor == unified.ApprovalAdmin || requirement.Floor == unified.ApprovalMultiFactor { + requiresApproval = true + } resourceVersion := ResourceVersion(resource) policyVersion := PolicyVersion(capability) - actionID := actionID(req, resourceVersion, policyVersion) + actionID := actionID(req, requirement, resourceVersion, policyVersion) plan := unified.ActionPlan{ ActionID: actionID, RequestID: req.RequestID, Allowed: true, RequiresApproval: requiresApproval, - ApprovalPolicy: policy, + ApprovalPolicy: requirement.Floor, + ApprovalRequirement: requirement, PredictedBlastRadius: predictedBlastRadius(resource), RollbackAvailable: false, - Message: planMessage(resource, capability, policy), + Message: planMessage(resource, capability, requirement.Floor), PlannedAt: plannedAt, ExpiresAt: plannedAt.Add(ttl), ResourceVersion: resourceVersion, PolicyVersion: policyVersion, - Preflight: buildPreflight(resource, capability, req, actionID, policy, plannedAt), + Preflight: buildPreflight(resource, capability, req, actionID, requirement.Floor, plannedAt), } plan.PlanHash = planHash(req, plan) plan.Preflight = unified.NormalizeActionPreflight(plan.Preflight, req, plan) @@ -187,7 +207,8 @@ func normalizeRequest(req unified.ActionRequest) unified.ActionRequest { req.ResourceID = unified.CanonicalResourceID(req.ResourceID) req.CapabilityName = strings.TrimSpace(req.CapabilityName) req.Reason = strings.TrimSpace(req.Reason) - req.RequestedBy = strings.TrimSpace(req.RequestedBy) + req.Actor = unified.NormalizeActionActor(req.Actor) + req.RequestedBy = req.Actor.SubjectID if req.Params == nil { req.Params = map[string]any{} } @@ -207,8 +228,8 @@ func validateRequest(req unified.ActionRequest) error { if req.Reason == "" { return &ValidationError{Field: "reason", Message: "reason is required"} } - if req.RequestedBy == "" { - return &ValidationError{Field: "requestedBy", Message: "requester is required"} + if err := unified.ValidateActionActor(req.Actor); err != nil { + return &ValidationError{Field: "actor", Message: err.Error()} } return nil } @@ -410,23 +431,25 @@ func normalizeApprovalPolicy(level unified.ActionApprovalLevel) unified.ActionAp } } -func actionID(req unified.ActionRequest, resourceVersion string, policyVersion string) string { +func actionID(req unified.ActionRequest, requirement unified.ApprovalRequirement, resourceVersion string, policyVersion string) string { payload := struct { - RequestID string `json:"requestId"` - ResourceID string `json:"resourceId"` - CapabilityName string `json:"capabilityName"` - Params map[string]any `json:"params"` - Reason string `json:"reason"` - RequestedBy string `json:"requestedBy"` - ResourceVersion string `json:"resourceVersion"` - PolicyVersion string `json:"policyVersion"` + RequestID string `json:"requestId"` + ResourceID string `json:"resourceId"` + CapabilityName string `json:"capabilityName"` + Params map[string]any `json:"params"` + Reason string `json:"reason"` + Actor unified.ActionActor `json:"actor"` + Requirement unified.ApprovalRequirement `json:"approvalRequirement"` + ResourceVersion string `json:"resourceVersion"` + PolicyVersion string `json:"policyVersion"` }{ RequestID: req.RequestID, ResourceID: req.ResourceID, CapabilityName: req.CapabilityName, Params: req.Params, Reason: req.Reason, - RequestedBy: req.RequestedBy, + Actor: req.Actor, + Requirement: requirement, ResourceVersion: resourceVersion, PolicyVersion: policyVersion, } @@ -440,6 +463,7 @@ func planHash(req unified.ActionRequest, plan unified.ActionPlan) string { Allowed bool `json:"allowed"` RequiresApproval bool `json:"requiresApproval"` ApprovalPolicy unified.ActionApprovalLevel `json:"approvalPolicy"` + ApprovalRequirement unified.ApprovalRequirement `json:"approvalRequirement"` PredictedBlastRadius []string `json:"predictedBlastRadius"` RollbackAvailable bool `json:"rollbackAvailable"` ResourceVersion string `json:"resourceVersion"` @@ -450,6 +474,7 @@ func planHash(req unified.ActionRequest, plan unified.ActionPlan) string { Allowed: plan.Allowed, RequiresApproval: plan.RequiresApproval, ApprovalPolicy: plan.ApprovalPolicy, + ApprovalRequirement: plan.ApprovalRequirement, PredictedBlastRadius: append([]string(nil), plan.PredictedBlastRadius...), RollbackAvailable: plan.RollbackAvailable, ResourceVersion: plan.ResourceVersion, diff --git a/internal/actionplanner/planner_test.go b/internal/actionplanner/planner_test.go index 6c3e6c842..67fb5cbc0 100644 --- a/internal/actionplanner/planner_test.go +++ b/internal/actionplanner/planner_test.go @@ -48,6 +48,7 @@ func TestPlannerBuildsDeterministicGovernedPlan(t *testing.T) { Params: map[string]any{"mode": "graceful"}, Reason: "Recover after confirmed outage", RequestedBy: "agent:oncall-helper", + Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"}, } planner := Planner{Now: func() time.Time { return now }} @@ -120,6 +121,7 @@ func TestPlannerBuildsDryRunOnlyPlanWithoutExecutionApproval(t *testing.T) { CapabilityName: "restart", Reason: "Validate restart path without execution", RequestedBy: "agent:oncall-helper", + Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"}, } plan, err := (Planner{Now: func() time.Time { return now }}).Plan(req, resource) @@ -155,6 +157,7 @@ func TestPlannerRejectsUndeclaredParams(t *testing.T) { Params: map[string]any{"force": true}, Reason: "Recover after confirmed outage", RequestedBy: "agent:oncall-helper", + Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"}, } _, err := Planner{}.Plan(req, resource) @@ -219,6 +222,7 @@ func TestPlannerReturnsCapabilityNotFound(t *testing.T) { CapabilityName: "restart", Reason: "Recover after confirmed outage", RequestedBy: "agent:oncall-helper", + Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"}, } _, err := Planner{}.Plan(req, resource) @@ -244,6 +248,7 @@ func TestPlannerIdentityChangesWhenAutoAuthorizationEligibilityChanges(t *testin CapabilityName: "restart", Reason: "recover service", RequestedBy: "pulse_patrol", + Actor: unified.ActionActor{SubjectID: "pulse_patrol", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"}, } withoutEligibility, err := (Planner{}).Plan(req, resource) if err != nil { diff --git a/internal/agentcapabilities/errors.go b/internal/agentcapabilities/errors.go index f0d4d9c69..59ac17864 100644 --- a/internal/agentcapabilities/errors.go +++ b/internal/agentcapabilities/errors.go @@ -15,16 +15,24 @@ const ( AgentErrCodeInvalidActionRequest = "invalid_action_request" AgentErrCodeCapabilityNotFound = "capability_not_found" AgentErrCodeActionExecutionUnavailable = "action_execution_unavailable" + AgentErrCodeActionActorUnavailable = "action_actor_unavailable" AgentErrCodeMissingID = "missing_id" AgentErrCodeInvalidID = "invalid_id" AgentErrCodeInvalidActionDecision = "invalid_action_decision" AgentErrCodeActionNotFound = "action_not_found" AgentErrCodeActionNotPending = "action_not_pending" AgentErrCodeActionPlanExpired = "action_plan_expired" + AgentErrCodeActionApprovalForbidden = "action_approval_forbidden" + AgentErrCodeActionStepUpUnavailable = "action_step_up_unavailable" + AgentErrCodeActionDecisionConflict = "action_decision_conflict" + AgentErrCodeActionSeparationRequired = "action_separation_required" + AgentErrCodeActionReplanRequired = "action_replan_required" AgentErrCodeInvalidActionExecution = "invalid_action_execution" AgentErrCodeActionNotApproved = "action_not_approved" AgentErrCodeActionAlreadyExecuting = "action_already_executing" AgentErrCodeActionExecutionFinal = "action_execution_final" + AgentErrCodeActionExecutionForbidden = "action_execution_forbidden" + AgentErrCodeActionNotExecuting = "action_not_executing" AgentErrCodeActionDryRunOnly = "action_dry_run_only" AgentErrCodeActionPlanDrift = "action_plan_drift" AgentErrCodeResourceRemediationLocked = "resource_remediation_locked" diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 617f67b5a..fc8ea5a85 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -593,6 +593,9 @@ const ( agentCapabilityScopeSettingsRead = auth.ScopeSettingsRead agentCapabilityScopeSettingsWrite = auth.ScopeSettingsWrite agentCapabilityScopeAIExecute = auth.ScopeAIExecute + agentCapabilityScopeActionsPlan = auth.ScopeActionsPlan + agentCapabilityScopeActionsApprove = auth.ScopeActionsApprove + agentCapabilityScopeActionsExecute = auth.ScopeActionsExecute ) var ( @@ -604,6 +607,7 @@ var ( } agentCapabilityPlanActionErrorCodes = []string{ AgentErrCodeInvalidActionRequest, + AgentErrCodeActionActorUnavailable, AgentErrCodeResourceNotFound, AgentErrCodeCapabilityNotFound, AgentErrCodeActionExecutionUnavailable, @@ -615,6 +619,12 @@ var ( AgentErrCodeActionNotFound, AgentErrCodeActionNotPending, AgentErrCodeActionPlanExpired, + AgentErrCodeActionActorUnavailable, + AgentErrCodeActionApprovalForbidden, + AgentErrCodeActionStepUpUnavailable, + AgentErrCodeActionDecisionConflict, + AgentErrCodeActionSeparationRequired, + AgentErrCodeActionReplanRequired, } agentCapabilityExecuteActionErrorCodes = []string{ AgentErrCodeMissingID, @@ -629,6 +639,10 @@ var ( AgentErrCodeActionPlanDrift, AgentErrCodeResourceRemediationLocked, AgentErrCodeActionExecutorUnavailable, + AgentErrCodeActionActorUnavailable, + AgentErrCodeActionExecutionForbidden, + AgentErrCodeActionNotExecuting, + AgentErrCodeActionReplanRequired, } ) @@ -1022,7 +1036,7 @@ var canonicalManifest = Manifest{ Category: "action", Method: http.MethodPost, Path: PlanActionCapabilityPath, - Scope: agentCapabilityScopeAIExecute, + Scope: agentCapabilityScopeActionsPlan, ActionMode: agentCapabilityActionModeWrite, ApprovalPolicy: agentCapabilityApprovalPolicyActionPlan, RequestBodyShape: "ActionRequest", @@ -1034,11 +1048,11 @@ var canonicalManifest = Manifest{ { Name: DecideActionCapabilityName, Title: "Decide action", - Description: "Record an approval decision (approved or rejected) on a previously planned action. The actor is taken from the authenticated identity; an explicit reason can be passed in the body. Idempotent on the persisted decision: re-deciding a non-pending action surfaces the action_not_pending stable code so agents can branch on the conflict rather than retrying blindly.", + Description: "Record an approval decision (approved or rejected) on a previously planned action. The actor is taken from the authenticated identity; an explicit reason can be passed in the body. An exact retry returns the authoritative persisted decision without adding an approval or lifecycle event; a conflicting retry fails closed.", Category: "action", Method: http.MethodPost, Path: ActionDecisionCapabilityPath, - Scope: agentCapabilityScopeAIExecute, + Scope: agentCapabilityScopeActionsApprove, ActionMode: agentCapabilityActionModeWrite, ApprovalPolicy: agentCapabilityApprovalPolicyActionPlan, RequestBodyShape: "{ outcome: \"approved\"|\"rejected\", reason?: string }", @@ -1054,7 +1068,7 @@ var canonicalManifest = Manifest{ Category: "action", Method: http.MethodPost, Path: ActionExecutionCapabilityPath, - Scope: agentCapabilityScopeAIExecute, + Scope: agentCapabilityScopeActionsExecute, ActionMode: agentCapabilityActionModeWrite, ApprovalPolicy: agentCapabilityApprovalPolicyActionPlan, RequestBodyShape: "{ reason?: string }", diff --git a/internal/agentcapabilities/manifest_test.go b/internal/agentcapabilities/manifest_test.go index fd1211bd3..782b9408d 100644 --- a/internal/agentcapabilities/manifest_test.go +++ b/internal/agentcapabilities/manifest_test.go @@ -456,11 +456,12 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test category: "action", method: http.MethodPost, path: PlanActionCapabilityPath, - scope: auth.ScopeAIExecute, + scope: auth.ScopeActionsPlan, mode: ActionModeWrite, approval: ApprovalPolicyActionPlan, errorCodes: []string{ AgentErrCodeInvalidActionRequest, + AgentErrCodeActionActorUnavailable, AgentErrCodeResourceNotFound, AgentErrCodeCapabilityNotFound, AgentErrCodeActionExecutionUnavailable, @@ -471,7 +472,7 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test category: "action", method: http.MethodPost, path: ActionDecisionCapabilityPath, - scope: auth.ScopeAIExecute, + scope: auth.ScopeActionsApprove, mode: ActionModeWrite, approval: ApprovalPolicyActionPlan, errorCodes: []string{ @@ -481,6 +482,12 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test AgentErrCodeActionNotFound, AgentErrCodeActionNotPending, AgentErrCodeActionPlanExpired, + AgentErrCodeActionActorUnavailable, + AgentErrCodeActionApprovalForbidden, + AgentErrCodeActionStepUpUnavailable, + AgentErrCodeActionDecisionConflict, + AgentErrCodeActionSeparationRequired, + AgentErrCodeActionReplanRequired, }, }, { @@ -488,7 +495,7 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test category: "action", method: http.MethodPost, path: ActionExecutionCapabilityPath, - scope: auth.ScopeAIExecute, + scope: auth.ScopeActionsExecute, mode: ActionModeWrite, approval: ApprovalPolicyActionPlan, errorCodes: []string{ @@ -504,6 +511,10 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test AgentErrCodeActionPlanDrift, AgentErrCodeResourceRemediationLocked, AgentErrCodeActionExecutorUnavailable, + AgentErrCodeActionActorUnavailable, + AgentErrCodeActionExecutionForbidden, + AgentErrCodeActionNotExecuting, + AgentErrCodeActionReplanRequired, }, }, } diff --git a/internal/agentcapabilities/scopes_test.go b/internal/agentcapabilities/scopes_test.go index f6a9a54f9..42f2761bd 100644 --- a/internal/agentcapabilities/scopes_test.go +++ b/internal/agentcapabilities/scopes_test.go @@ -58,7 +58,7 @@ func TestNormalizeRequiredScopesUseAuthOrderAndDeduplicate(t *testing.T) { func TestRequiredCapabilityScopeListReflectsCanonicalManifest(t *testing.T) { got := RequiredCapabilityScopeList(CanonicalManifest().Capabilities) - want := "monitoring:read, monitoring:write, settings:read, settings:write, ai:execute" + want := "monitoring:read, monitoring:write, settings:read, settings:write, ai:execute, actions:plan, actions:approve, actions:execute" if got != want { t.Fatalf("RequiredCapabilityScopeList(CanonicalManifest) = %q, want %q", got, want) } diff --git a/internal/api/action_authority.go b/internal/api/action_authority.go new file mode 100644 index 000000000..535cc56d7 --- /dev/null +++ b/internal/api/action_authority.go @@ -0,0 +1,199 @@ +package api + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" +) + +type trustedActionActorContextKey struct{} + +func withTrustedActionActor(ctx context.Context, actor unified.ActionActor) context.Context { + return context.WithValue(ctx, trustedActionActorContextKey{}, unified.NormalizeActionActor(actor)) +} + +func trustedActionActorFromContext(ctx context.Context) (unified.ActionActor, bool) { + actor, ok := ctx.Value(trustedActionActorContextKey{}).(unified.ActionActor) + if !ok { + return unified.ActionActor{}, false + } + actor = unified.NormalizeActionActor(actor) + return actor, unified.ValidateActionActor(actor) == nil +} + +func actionCredentialID(prefix, value string) string { + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("%s:%x", prefix, sum[:16]) +} + +func actionActorForRequest(cfg *config.Config, r *http.Request, orgID string) (unified.ActionActor, error) { + orgID = strings.TrimSpace(orgID) + if actor, ok := trustedActionActorFromContext(r.Context()); ok { + if actor.OrgID != orgID { + return unified.ActionActor{}, actionlifecycle.ErrActionAuthorizationDenied + } + return actor, nil + } + if record := getAPITokenRecordFromRequest(r); record != nil { + subject := apiTokenAuthenticatedUser(record) + actor := unified.ActionActor{ + SubjectID: subject, + Kind: unified.ActionActorAPIToken, + CredentialID: "api-token:" + strings.TrimSpace(record.ID), + OrgID: orgID, + } + if err := unified.ValidateActionActor(actor); err != nil { + return unified.ActionActor{}, err + } + return actor, nil + } + user := strings.TrimSpace(auth.GetUser(r.Context())) + if user == "" && cfg != nil { + user = strings.TrimSpace(getAuthUsername(cfg, r)) + } + if user == "" { + return unified.ActionActor{}, actionlifecycle.ErrActionAuthorizationDenied + } + credentialID := "" + if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) { + credentialID = actionCredentialID("session", cookie.Value) + } else if cfg != nil && cfg.ProxyAuthSecret != "" { + if valid, proxyUser, _ := CheckProxyAuth(cfg, r); valid && strings.EqualFold(strings.TrimSpace(proxyUser), user) { + credentialID = actionCredentialID("proxy", user) + } + } else if authorization := strings.TrimSpace(r.Header.Get("Authorization")); authorization != "" { + credentialID = actionCredentialID("http-auth", authorization) + } else if adminBypassEnabled() { + credentialID = "development-bypass" + } else if strings.TrimSpace(auth.GetUser(r.Context())) != "" { + // Auth context is populated only by trusted server middleware. This + // covers integrations whose credential was verified upstream without + // persisting or re-reading that credential here. + credentialID = actionCredentialID("server-context", user) + } + if credentialID == "" { + return unified.ActionActor{}, actionlifecycle.ErrActionAuthorizationDenied + } + return unified.ActionActor{SubjectID: user, Kind: unified.ActionActorUser, CredentialID: credentialID, OrgID: orgID}, nil +} + +func approvalEvidenceForRequest(actor unified.ActionActor, record unified.ActionAuditRecord, outcome unified.ApprovalOutcome, now time.Time) unified.ApprovalEvidence { + method := unified.MethodSession + if actor.Kind == unified.ActionActorAPIToken { + method = unified.MethodAPIToken + } + return unified.ApprovalEvidence{ + Version: 1, + Method: method, + Actor: actor, + OrgID: actor.OrgID, + ActionID: record.ID, + PlanHash: record.Plan.PlanHash, + Outcome: outcome, + IssuedAt: now.UTC(), + } +} + +type actionAuthority struct { + authorizer auth.Authorizer + orgChecker *DefaultAuthorizationChecker +} + +func (a actionAuthority) authorizeActor(ctx context.Context, orgID string, actor unified.ActionActor, capability string) error { + actor = unified.NormalizeActionActor(actor) + var token *config.APITokenRecord + if actor.Kind == unified.ActionActorAPIToken { + current, ok := auth.GetAPIToken(ctx).(*config.APITokenRecord) + if !ok || current == nil || actor.CredentialID != "api-token:"+strings.TrimSpace(current.ID) { + return actionlifecycle.ErrActionAuthorizationDenied + } + owner := apiTokenBoundUser(current) + if owner == "" || !strings.EqualFold(owner, actor.SubjectID) || strings.HasPrefix(actor.SubjectID, "token:") { + return actionlifecycle.ErrApprovalActorNotHuman + } + if !tokenHasActionCapabilityScope(current, capability) { + return actionlifecycle.ErrActionAuthorizationDenied + } + token = current + } else if actor.Kind != unified.ActionActorUser || !strings.EqualFold(strings.TrimSpace(auth.GetUser(ctx)), actor.SubjectID) { + return actionlifecycle.ErrApprovalActorNotHuman + } + if a.orgChecker == nil || !a.orgChecker.CheckAccess(token, actor.SubjectID, orgID).Allowed { + return actionlifecycle.ErrActionAuthorizationDenied + } + return authorizeActionCapability(ctx, a.authorizer, capability) +} + +func tokenHasActionCapabilityScope(token *config.APITokenRecord, capability string) bool { + if token == nil { + return false + } + var scopes []string + switch capability { + case auth.ActionPlan: + scopes = []string{config.ScopeActionsPlan, config.ScopeAIExecute} + case auth.ActionApprove: + scopes = []string{config.ScopeActionsApprove, config.ScopeAIExecute, config.ScopeRelayMobileAccess} + case auth.ActionExecute: + scopes = []string{config.ScopeActionsExecute, config.ScopeAIExecute, config.ScopeRelayMobileAccess} + default: + return false + } + for _, scope := range scopes { + if token.HasScope(scope) { + return true + } + } + return false +} + +func (a actionAuthority) AuthorizeDecision(ctx context.Context, orgID string, _ unified.ActionAuditRecord, decision unified.ActionDecision) error { + return a.authorizeActor(ctx, orgID, decision.Actor, auth.ActionApprove) +} + +func (a actionAuthority) AuthorizeExecution(ctx context.Context, orgID string, _ unified.ActionAuditRecord, actor unified.ActionActor) error { + return a.authorizeActor(ctx, orgID, actor, auth.ActionExecute) +} + +func authorizeActionCapability(ctx context.Context, authorizer auth.Authorizer, capability string) error { + if authorizer == nil { + return actionlifecycle.ErrActionAuthorizationDenied + } + allowed, err := authorizer.Authorize(ctx, capability, auth.ResourceActions) + if err != nil { + return err + } + if allowed { + return nil + } + legacyAction := auth.ActionAdmin + if capability == auth.ActionPlan { + legacyAction = auth.ActionWrite + } + allowed, err = authorizer.Authorize(ctx, legacyAction, auth.ResourceAI) + if err != nil { + return err + } + if !allowed { + return actionlifecycle.ErrActionAuthorizationDenied + } + return nil +} + +func requireActionCapability(authorizer auth.Authorizer, capability string, handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := authorizeActionCapability(r.Context(), authorizer, capability); err != nil { + writeJSONError(w, http.StatusForbidden, "action_capability_denied", "You do not have permission to perform this action") + return + } + handler(w, r) + } +} diff --git a/internal/api/action_authority_test.go b/internal/api/action_authority_test.go new file mode 100644 index 000000000..07ee47cf2 --- /dev/null +++ b/internal/api/action_authority_test.go @@ -0,0 +1,163 @@ +package api + +import ( + "context" + "errors" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" +) + +type allowActionAuthorityAuthorizer struct{} + +func (allowActionAuthorityAuthorizer) Authorize(context.Context, string, string) (bool, error) { + return true, nil +} + +type fixedActionAuthorityAuthorizer bool + +func (a fixedActionAuthorityAuthorizer) Authorize(context.Context, string, string) (bool, error) { + return bool(a), nil +} + +func ownerBoundActionToken(id, owner string, scopes ...string) *config.APITokenRecord { + record := &config.APITokenRecord{ID: id, OrgID: "default", Scopes: scopes} + setAPITokenOwnerUserID(record, owner) + return record +} + +func tokenActionActor(record *config.APITokenRecord) unified.ActionActor { + return unified.ActionActor{ + SubjectID: apiTokenAuthenticatedUser(record), Kind: unified.ActionActorAPIToken, + CredentialID: "api-token:" + record.ID, OrgID: "default", + } +} + +func actionAuthorityContext(record *config.APITokenRecord) context.Context { + ctx := auth.WithUser(context.Background(), apiTokenAuthenticatedUser(record)) + return auth.WithAPIToken(ctx, record) +} + +func testActionAuthority() actionAuthority { + return actionAuthority{authorizer: allowActionAuthorityAuthorizer{}, orgChecker: NewAuthorizationChecker(nil)} +} + +func TestActionAuthorityAllowsOwnerBoundTokenWithCanonicalApproveAndExecuteScopes(t *testing.T) { + authority := testActionAuthority() + record := ownerBoundActionToken("owned", "alice", config.ScopeActionsApprove, config.ScopeActionsExecute) + ctx := actionAuthorityContext(record) + actor := tokenActionActor(record) + if err := authority.authorizeActor(ctx, "default", actor, auth.ActionApprove); err != nil { + t.Fatalf("approve: %v", err) + } + if err := authority.authorizeActor(ctx, "default", actor, auth.ActionExecute); err != nil { + t.Fatalf("execute: %v", err) + } +} + +func TestActionAuthorityRejectsOwnerBoundTokenWithoutApplicableScope(t *testing.T) { + record := ownerBoundActionToken("wrong-scope", "alice", config.ScopeMonitoringRead) + err := testActionAuthority().authorizeActor(actionAuthorityContext(record), "default", tokenActionActor(record), auth.ActionApprove) + if !errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied) { + t.Fatalf("error=%v", err) + } +} + +func TestActionAuthorityRejectsDetachedTokenEvenWithApprovalScope(t *testing.T) { + record := &config.APITokenRecord{ID: "detached", OrgID: "default", Scopes: []string{config.ScopeActionsApprove}} + err := testActionAuthority().authorizeActor(actionAuthorityContext(record), "default", tokenActionActor(record), auth.ActionApprove) + if !errors.Is(err, actionlifecycle.ErrApprovalActorNotHuman) { + t.Fatalf("error=%v", err) + } +} + +func TestActionAuthorityLegacyCompatibilityScopesAreExactlyEnumerated(t *testing.T) { + for _, tc := range []struct { + name string + capability string + scope string + allowed bool + }{ + {name: "plan ai execute", capability: auth.ActionPlan, scope: config.ScopeAIExecute, allowed: true}, + {name: "approve ai execute", capability: auth.ActionApprove, scope: config.ScopeAIExecute, allowed: true}, + {name: "approve relay mobile", capability: auth.ActionApprove, scope: config.ScopeRelayMobileAccess, allowed: true}, + {name: "execute ai execute", capability: auth.ActionExecute, scope: config.ScopeAIExecute, allowed: true}, + {name: "execute relay mobile", capability: auth.ActionExecute, scope: config.ScopeRelayMobileAccess, allowed: true}, + {name: "plan relay mobile denied", capability: auth.ActionPlan, scope: config.ScopeRelayMobileAccess, allowed: false}, + {name: "unrelated denied", capability: auth.ActionApprove, scope: config.ScopeMonitoringRead, allowed: false}, + } { + t.Run(tc.name, func(t *testing.T) { + record := ownerBoundActionToken("compat", "alice", tc.scope) + err := testActionAuthority().authorizeActor(actionAuthorityContext(record), "default", tokenActionActor(record), tc.capability) + if tc.allowed && err != nil { + t.Fatalf("error=%v", err) + } + if !tc.allowed && !errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied) { + t.Fatalf("error=%v", err) + } + }) + } +} + +func TestActionAuthorityScopeRevocationDeniesBeforeDecisionOrExecution(t *testing.T) { + authority := testActionAuthority() + record := ownerBoundActionToken("revoked", "alice", config.ScopeActionsApprove, config.ScopeActionsExecute) + ctx := actionAuthorityContext(record) + actor := tokenActionActor(record) + if err := authority.authorizeActor(ctx, "default", actor, auth.ActionApprove); err != nil { + t.Fatal(err) + } + record.Scopes = []string{config.ScopeMonitoringRead} + if err := authority.authorizeActor(ctx, "default", actor, auth.ActionApprove); !errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied) { + t.Fatalf("approve after revocation=%v", err) + } + if err := authority.authorizeActor(ctx, "default", actor, auth.ActionExecute); !errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied) { + t.Fatalf("execute after revocation=%v", err) + } +} + +func TestHandleDecideActionRejectsViewerSessionDespiteSessionScopeBypass(t *testing.T) { + authority := actionAuthority{authorizer: fixedActionAuthorityAuthorizer(false), orgChecker: NewAuthorizationChecker(nil)} + actor := unified.ActionActor{SubjectID: "viewer", Kind: unified.ActionActorUser, CredentialID: "session:viewer", OrgID: "default"} + ctx := auth.WithUser(context.Background(), "viewer") + if err := authority.authorizeActor(ctx, "default", actor, auth.ActionApprove); !errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied) { + t.Fatalf("error=%v", err) + } +} + +func TestHandleDecideActionRejectsDetachedTokenAsHumanApprover(t *testing.T) { + record := &config.APITokenRecord{ID: "detached-handler", OrgID: "default", Scopes: []string{config.ScopeActionsApprove}} + err := testActionAuthority().authorizeActor(actionAuthorityContext(record), "default", tokenActionActor(record), auth.ActionApprove) + if !errors.Is(err, actionlifecycle.ErrApprovalActorNotHuman) { + t.Fatalf("error=%v", err) + } +} + +func TestHandleDecideActionRejectsOwnerBoundTokenAfterRoleRevocation(t *testing.T) { + record := ownerBoundActionToken("role-revoked", "alice", config.ScopeActionsApprove) + authority := actionAuthority{authorizer: fixedActionAuthorityAuthorizer(false), orgChecker: NewAuthorizationChecker(nil)} + err := authority.authorizeActor(actionAuthorityContext(record), "default", tokenActionActor(record), auth.ActionApprove) + if !errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied) { + t.Fatalf("error=%v", err) + } +} + +func TestHandleExecuteActionRejectsCrossOrgAuditLookup(t *testing.T) { + record := ownerBoundActionToken("cross-org", "alice", config.ScopeActionsExecute) + actor := tokenActionActor(record) + if err := testActionAuthority().authorizeActor(actionAuthorityContext(record), "other-org", actor, auth.ActionExecute); !errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied) { + t.Fatalf("error=%v", err) + } +} + +func TestHandleExecuteActionRejectsExecutorWithoutCurrentCapability(t *testing.T) { + record := ownerBoundActionToken("execute-role-revoked", "alice", config.ScopeActionsExecute) + authority := actionAuthority{authorizer: fixedActionAuthorityAuthorizer(false), orgChecker: NewAuthorizationChecker(nil)} + err := authority.authorizeActor(actionAuthorityContext(record), "default", tokenActionActor(record), auth.ActionExecute) + if !errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied) { + t.Fatalf("error=%v", err) + } +} diff --git a/internal/api/actions.go b/internal/api/actions.go index 7984ebda3..3aef0c368 100644 --- a/internal/api/actions.go +++ b/internal/api/actions.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle" "github.com/rcourtman/pulse-go-rewrite/internal/actionplanner" @@ -33,6 +34,16 @@ type actionDecisionRequest struct { Reason string `json:"reason,omitempty"` } +type publicActionPlanRequest struct { + RequestID string `json:"requestId"` + ResourceID string `json:"resourceId"` + CapabilityName string `json:"capabilityName"` + Params map[string]any `json:"params,omitempty"` + Reason string `json:"reason"` + // RequestedBy is accepted only for boundary compatibility and ignored. + RequestedBy string `json:"requestedBy,omitempty"` +} + type actionDecisionResponse struct { ActionID string `json:"actionId"` State unified.ActionState `json:"state"` @@ -73,11 +84,13 @@ func (h *ResourceHandlers) ActionLifecycle() *actionlifecycle.Service { Store: func(orgID string) (actionlifecycle.Store, error) { return h.getStore(orgID) }, - Executor: h.actionExecutor, - OnActionCompleted: h.actionCompleted, - OnActionTransition: h.actionTransition, - PolicyAdmission: h.policyAdmission, - EmergencyStop: h.actionEmergencyStop, + Executor: h.actionExecutor, + OnActionCompleted: h.actionCompleted, + OnActionTransition: h.actionTransition, + PolicyAdmission: h.policyAdmission, + EmergencyStop: h.actionEmergencyStop, + DecisionAuthorizer: h.actionDecisionAuthorizer, + ExecutionAuthorizer: h.actionExecutionAuthorizer, } } @@ -87,10 +100,10 @@ func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Reque return } - var req unified.ActionRequest + var publicReq publicActionPlanRequest decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionPlanRequestBytes)) decoder.DisallowUnknownFields() - if err := decoder.Decode(&req); err != nil { + if err := decoder.Decode(&publicReq); err != nil { writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action planning request", map[string]string{ "body": "request body must be a valid ActionRequest JSON object", }) @@ -103,7 +116,20 @@ func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Reque return } - plan, err := h.ActionLifecycle().Plan(r.Context(), GetOrgID(r.Context()), req) + orgID := GetOrgID(r.Context()) + actor, err := actionActorForRequest(h.cfg, r, orgID) + if err != nil { + writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionActorUnavailable, "Authenticated action actor is unavailable") + return + } + req := unified.ActionRequest{ + RequestID: publicReq.RequestID, + ResourceID: publicReq.ResourceID, + CapabilityName: publicReq.CapabilityName, + Params: publicReq.Params, + Reason: publicReq.Reason, + } + plan, err := h.ActionLifecycle().Plan(r.Context(), orgID, req, actor) if err != nil { writeActionPlanError(w, err) return @@ -278,13 +304,31 @@ func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Req return } - approval := unified.ActionApprovalRecord{ - Actor: actionDecisionActor(h, r), - Method: unified.MethodAPI, - Outcome: decision.Outcome, - Reason: decision.Reason, + orgID := GetOrgID(r.Context()) + actor, err := actionActorForRequest(h.cfg, r, orgID) + if err != nil { + writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionActorUnavailable, "Authenticated action actor is unavailable") + return } - updated, err := h.ActionLifecycle().Decide(r.Context(), GetOrgID(r.Context()), actionID, approval) + lifecycle := h.ActionLifecycle() + record, found, err := lifecycle.Get(orgID, actionID) + if err != nil { + writeActionLifecycleReadError(w, err, func() { + writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit") + }) + return + } + if !found { + writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{"actionId": actionID}) + return + } + canonicalDecision := unified.ActionDecision{ + Actor: actor, + Outcome: decision.Outcome, + Reason: decision.Reason, + Evidence: approvalEvidenceForRequest(actor, record, decision.Outcome, time.Now().UTC()), + } + updated, err := lifecycle.Decide(r.Context(), orgID, actionID, canonicalDecision) if err != nil { writeActionLifecycleReadError(w, err, func() { var persist *actionlifecycle.PersistError @@ -297,7 +341,7 @@ func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Req return } - responseApproval := approval + responseApproval := unified.ActionApprovalRecord{} if len(updated.Approvals) > 0 { responseApproval = updated.Approvals[len(updated.Approvals)-1] } @@ -346,7 +390,13 @@ func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Re } execution.Reason = strings.TrimSpace(execution.Reason) - completed, err := h.ActionLifecycle().Execute(r.Context(), GetOrgID(r.Context()), actionID, actionDecisionActor(h, r), execution.Reason) + orgID := GetOrgID(r.Context()) + actor, err := actionActorForRequest(h.cfg, r, orgID) + if err != nil { + writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionActorUnavailable, "Authenticated action actor is unavailable") + return + } + completed, err := h.ActionLifecycle().Execute(r.Context(), orgID, actionID, actor, execution.Reason) if err != nil { writeActionLifecycleReadError(w, err, func() { writeActionExecuteError(w, err) @@ -403,18 +453,6 @@ func writeActionExecuteError(w http.ResponseWriter, err error) { } } -func actionDecisionActor(h *ResourceHandlers, r *http.Request) string { - if h != nil { - if actor := strings.TrimSpace(getAuthUsername(h.cfg, r)); actor != "" { - return actor - } - } - if actor := strings.TrimSpace(getUserID(r)); actor != "" { - return actor - } - return "api:authenticated" -} - func writeActionDecisionApplyError(w http.ResponseWriter, err error) { switch { case errors.Is(err, unified.ErrInvalidApprovalOutcome): @@ -425,6 +463,16 @@ func writeActionDecisionApplyError(w http.ResponseWriter, err error) { writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionNotPending, "Action is not pending approval") case errors.Is(err, unified.ErrActionPlanExpired): writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanExpired, "Action plan has expired") + case errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied), errors.Is(err, actionlifecycle.ErrApprovalActorNotHuman): + writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionApprovalForbidden, "Current actor is not authorized to decide this action") + case errors.Is(err, actionlifecycle.ErrApprovalStepUpUnavailable): + writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionStepUpUnavailable, "This action requires server-verified cryptographic step-up approval") + case errors.Is(err, actionlifecycle.ErrApprovalEvidenceInvalid), errors.Is(err, actionlifecycle.ErrDecisionReplayConflict), errors.Is(err, unified.ErrDuplicateApprovalActor): + writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionDecisionConflict, "Action decision conflicts with the authoritative approval record") + case errors.Is(err, actionlifecycle.ErrApprovalSeparationRequired): + writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionSeparationRequired, "Requester cannot approve this action") + case errors.Is(err, unified.ErrActionReplanRequired): + writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionReplanRequired, "Action authority is outdated; re-plan before deciding") default: writeJSONError(w, http.StatusInternalServerError, "action_decision_failed", sanitizeErrorForClient(err, "Action decision failed")) } @@ -439,7 +487,7 @@ func writeActionExecutionApplyError(w http.ResponseWriter, err error) { case errors.Is(err, unified.ErrActionExecutionFinal): writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionFinal, "Action execution is already final") case errors.Is(err, unified.ErrActionNotExecuting): - writeJSONError(w, http.StatusConflict, "action_not_executing", "Action is not executing") + writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionNotExecuting, "Action is not executing") case errors.Is(err, unified.ErrActionPlanExpired): writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanExpired, "Action plan has expired") case errors.Is(err, unified.ErrActionDryRunOnly): @@ -448,6 +496,10 @@ func writeActionExecutionApplyError(w http.ResponseWriter, err error) { writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanDrift, "Action plan no longer matches the current resource contract; re-plan before executing") case errors.Is(err, unified.ErrResourceRemediationLocked): writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeResourceRemediationLocked, "Resource is operator-locked against automated remediation") + case errors.Is(err, actionlifecycle.ErrActionAuthorizationDenied), errors.Is(err, actionlifecycle.ErrApprovalActorNotHuman): + writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionExecutionForbidden, "Current actor is not authorized to execute this action") + case errors.Is(err, unified.ErrActionReplanRequired): + writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionReplanRequired, "Action authority is outdated; re-plan before executing") default: writeJSONError(w, http.StatusInternalServerError, "action_execution_failed", sanitizeErrorForClient(err, "Action execution failed")) } diff --git a/internal/api/actions_test.go b/internal/api/actions_test.go index 604141358..cac6208d5 100644 --- a/internal/api/actions_test.go +++ b/internal/api/actions_test.go @@ -18,6 +18,48 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) +func configureActionHandlerTestAuthority(h *ResourceHandlers) { + h.SetActionAuthorizers( + actionlifecycle.DecisionAuthorizerFunc(func(context.Context, string, unified.ActionAuditRecord, unified.ActionDecision) error { return nil }), + actionlifecycle.ExecutionAuthorizerFunc(func(context.Context, string, unified.ActionAuditRecord, unified.ActionActor) error { return nil }), + ) +} + +func newActionTestResourceHandlers(t *testing.T, cfg *config.Config) *ResourceHandlers { + t.Helper() + h := NewResourceHandlers(cfg) + configureActionHandlerTestAuthority(h) + return h +} + +func actionHandlerTestRequest(req *http.Request, subject string) *http.Request { + if strings.TrimSpace(subject) == "" { + subject = strings.TrimSpace(auth.GetUser(req.Context())) + } + if subject == "" { + subject = "operator@example.com" + } + actor := unified.ActionActor{SubjectID: subject, Kind: unified.ActionActorUser, CredentialID: "session:test", OrgID: "default"} + ctx := auth.WithUser(req.Context(), subject) + ctx = withTrustedActionActor(ctx, actor) + return req.WithContext(ctx) +} + +func boundActionTestRequest(requestID, resourceID, capability, reason, subject string) unified.ActionRequest { + actor := unified.ActionActor{SubjectID: subject, Kind: unified.ActionActorService, CredentialID: "service:test-requester", OrgID: "default"} + return unified.ActionRequest{RequestID: requestID, ResourceID: resourceID, CapabilityName: capability, Reason: reason, RequestedBy: subject, Actor: actor} +} + +func boundActionTestApproval(actionID, planHash, subject string, at time.Time) unified.ActionApprovalRecord { + return boundActionTestDecisionApproval(actionID, planHash, subject, unified.OutcomeApproved, at) +} + +func boundActionTestDecisionApproval(actionID, planHash, subject string, outcome unified.ApprovalOutcome, at time.Time) unified.ActionApprovalRecord { + actor := unified.ActionActor{SubjectID: subject, Kind: unified.ActionActorUser, CredentialID: "session:test", OrgID: "default"} + evidence := unified.ApprovalEvidence{Version: 1, Method: unified.MethodSession, Actor: actor, OrgID: "default", ActionID: actionID, PlanHash: planHash, Outcome: outcome, IssuedAt: at} + return unified.ActionApprovalRecord{Actor: subject, ActorBinding: actor, Method: unified.MethodSession, Timestamp: at, Outcome: outcome, Evidence: &evidence} +} + type stubActionExecutor struct { result *unified.ExecutionResult err error @@ -31,9 +73,9 @@ func (s *stubActionExecutor) ExecuteAction(_ context.Context, record unified.Act return s.result, s.err } -func TestHandlePlanActionReturnsCanonicalPlan(t *testing.T) { +func TestHandlePlanActionBindsActorAndPlanHashToAuthenticatedOrg(t *testing.T) { now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -79,7 +121,7 @@ func TestHandlePlanActionReturnsCanonicalPlan(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/actions/plan", body) - h.HandlePlanAction(rec, req) + h.HandlePlanAction(rec, actionHandlerTestRequest(req, "")) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) @@ -104,6 +146,9 @@ func TestHandlePlanActionReturnsCanonicalPlan(t *testing.T) { if plan.ActionID == "" || !strings.HasPrefix(plan.PlanHash, "sha256:") { t.Fatalf("missing action identity/hash: actionID=%q planHash=%q", plan.ActionID, plan.PlanHash) } + if plan.ApprovalRequirement.Version != unified.ActionApprovalRequirementVersion { + t.Fatalf("ApprovalRequirement = %#v, want canonical version", plan.ApprovalRequirement) + } if plan.Preflight == nil || plan.Preflight.Target != "vm:42" { t.Fatalf("Preflight = %#v, want target vm:42", plan.Preflight) } @@ -112,9 +157,9 @@ func TestHandlePlanActionReturnsCanonicalPlan(t *testing.T) { } } -func TestHandlePlanActionPersistsAuditAndLifecycle(t *testing.T) { +func TestHandlePlanActionRejectsOrIgnoresPublicRequestedByAndStampsAuthenticatedActor(t *testing.T) { now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -154,7 +199,7 @@ func TestHandlePlanActionPersistsAuditAndLifecycle(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/actions/plan", body()) - h.HandlePlanAction(rec, req) + h.HandlePlanAction(rec, actionHandlerTestRequest(req, "")) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } @@ -178,7 +223,7 @@ func TestHandlePlanActionPersistsAuditAndLifecycle(t *testing.T) { if audit.ID != plan.ActionID || audit.State != unified.ActionStatePending { t.Fatalf("audit identity/state = %q/%q, want %q/%q", audit.ID, audit.State, plan.ActionID, unified.ActionStatePending) } - if audit.Request.RequestID != "agent-run-123" || audit.Request.RequestedBy != "agent:oncall-helper" { + if audit.Request.RequestID != "agent-run-123" || audit.Request.RequestedBy != "operator@example.com" || audit.Request.Actor.SubjectID != "operator@example.com" || audit.Request.Actor.OrgID != "default" { t.Fatalf("audit request was not preserved: %#v", audit.Request) } if audit.Plan.PlanHash != plan.PlanHash || audit.Plan.Preflight == nil { @@ -192,7 +237,7 @@ func TestHandlePlanActionPersistsAuditAndLifecycle(t *testing.T) { seenStates := map[unified.ActionState]bool{} for _, event := range events { seenStates[event.State] = true - if event.Actor != "agent:oncall-helper" { + if event.Actor != "operator@example.com" { t.Fatalf("event actor = %q, want requester", event.Actor) } } @@ -202,7 +247,7 @@ func TestHandlePlanActionPersistsAuditAndLifecycle(t *testing.T) { retryRec := httptest.NewRecorder() retryReq := httptest.NewRequest(http.MethodPost, "/api/actions/plan", body()) - h.HandlePlanAction(retryRec, retryReq) + h.HandlePlanAction(retryRec, actionHandlerTestRequest(retryReq, "")) if retryRec.Code != http.StatusOK { t.Fatalf("retry status = %d, body=%s", retryRec.Code, retryRec.Body.String()) } @@ -216,7 +261,7 @@ func TestHandlePlanActionPersistsAuditAndLifecycle(t *testing.T) { } func TestHandleListPendingActionsReturnsOnlyCanonicalDecisionQueue(t *testing.T) { - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) store, err := h.getStore("default") if err != nil { t.Fatalf("get store: %v", err) @@ -259,7 +304,7 @@ func TestHandleListPendingActionsReturnsOnlyCanonicalDecisionQueue(t *testing.T) } func TestHandleGetActionAndInboxAreTenantScoped(t *testing.T) { - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) now := time.Now().UTC() store, err := h.getStore("org-a") if err != nil { @@ -312,7 +357,7 @@ func TestHandleGetActionAndInboxAreTenantScoped(t *testing.T) { } func TestHandleListActionsRejectsUnknownViewAndUnsafeLimit(t *testing.T) { - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) for _, target := range []string{"/api/actions?view=unknown", "/api/actions?limit=501", "/api/actions?limit=-1"} { rec := httptest.NewRecorder() h.HandleListActions(rec, httptest.NewRequest(http.MethodGet, target, nil)) @@ -324,7 +369,7 @@ func TestHandleListActionsRejectsUnknownViewAndUnsafeLimit(t *testing.T) { func TestHandleDecideActionApprovesPendingPlanWithoutExecution(t *testing.T) { now := time.Date(2026, 5, 4, 14, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -361,7 +406,7 @@ func TestHandleDecideActionApprovesPendingPlanWithoutExecution(t *testing.T) { "reason":"Recover after confirmed outage", "requestedBy":"agent:oncall-helper" }`)) - h.HandlePlanAction(planRec, planReq) + h.HandlePlanAction(planRec, actionHandlerTestRequest(planReq, "")) if planRec.Code != http.StatusOK { t.Fatalf("plan status = %d, body=%s", planRec.Code, planRec.Body.String()) } @@ -377,7 +422,7 @@ func TestHandleDecideActionApprovesPendingPlanWithoutExecution(t *testing.T) { }`)) decisionReq.SetPathValue("id", plan.ActionID) decisionReq = decisionReq.WithContext(auth.WithUser(decisionReq.Context(), "operator@example.com")) - h.HandleDecideAction(decisionRec, decisionReq) + h.HandleDecideAction(decisionRec, actionHandlerTestRequest(decisionReq, "")) if decisionRec.Code != http.StatusOK { t.Fatalf("decision status = %d, body=%s", decisionRec.Code, decisionRec.Body.String()) } @@ -389,7 +434,7 @@ func TestHandleDecideActionApprovesPendingPlanWithoutExecution(t *testing.T) { if decision.ActionID != plan.ActionID || decision.State != unified.ActionStateApproved { t.Fatalf("decision identity/state = %q/%q, want %q/%q", decision.ActionID, decision.State, plan.ActionID, unified.ActionStateApproved) } - if decision.Approval.Actor != "operator@example.com" || decision.Approval.Method != unified.MethodAPI || decision.Approval.Outcome != unified.OutcomeApproved { + if decision.Approval.Actor != "operator@example.com" || decision.Approval.Method != unified.MethodSession || decision.Approval.Outcome != unified.OutcomeApproved { t.Fatalf("decision approval = %#v", decision.Approval) } if decision.Audit.Result != nil { @@ -418,10 +463,27 @@ func TestHandleDecideActionApprovesPendingPlanWithoutExecution(t *testing.T) { t.Fatalf("approval must not create execution event: %#v", event) } } - if len(events) != 3 || !seen[unified.ActionStatePlanned] || !seen[unified.ActionStatePending] || !seen[unified.ActionStateApproved] { + if len(events) != 4 || !seen[unified.ActionStatePlanned] || !seen[unified.ActionStatePending] || !seen[unified.ActionStateApproved] { t.Fatalf("events = %#v, want planned, pending_approval, approved", events) } + exactRetryRec := httptest.NewRecorder() + exactRetryReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+plan.ActionID+"/decision", bytes.NewBufferString(`{ + "outcome":"approved", + "reason":"inside maintenance window" + }`)) + exactRetryReq.SetPathValue("id", plan.ActionID) + exactRetryReq = exactRetryReq.WithContext(auth.WithUser(exactRetryReq.Context(), "operator@example.com")) + h.HandleDecideAction(exactRetryRec, actionHandlerTestRequest(exactRetryReq, "")) + if exactRetryRec.Code != http.StatusOK { + t.Fatalf("exact retry status = %d, body=%s", exactRetryRec.Code, exactRetryRec.Body.String()) + } + audit, _, _ = store.GetActionAudit(plan.ActionID) + events, _ = store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 10) + if len(audit.Approvals) != 1 || len(events) != 4 { + t.Fatalf("exact retry duplicated state: approvals=%d events=%d", len(audit.Approvals), len(events)) + } + retryRec := httptest.NewRecorder() retryReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+plan.ActionID+"/decision", bytes.NewBufferString(`{ "outcome":"rejected", @@ -429,7 +491,7 @@ func TestHandleDecideActionApprovesPendingPlanWithoutExecution(t *testing.T) { }`)) retryReq.SetPathValue("id", plan.ActionID) retryReq = retryReq.WithContext(auth.WithUser(retryReq.Context(), "second-operator@example.com")) - h.HandleDecideAction(retryRec, retryReq) + h.HandleDecideAction(retryRec, actionHandlerTestRequest(retryReq, "")) if retryRec.Code != http.StatusConflict { t.Fatalf("retry decision status = %d, body=%s", retryRec.Code, retryRec.Body.String()) } @@ -440,7 +502,7 @@ func TestHandleDecideActionApprovesPendingPlanWithoutExecution(t *testing.T) { func TestHandleExecuteActionRunsApprovedPlanThroughExecutor(t *testing.T) { now := time.Date(2026, 5, 4, 14, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -483,7 +545,7 @@ func TestHandleExecuteActionRunsApprovedPlanThroughExecutor(t *testing.T) { "reason":"Recover after confirmed outage", "requestedBy":"agent:oncall-helper" }`)) - h.HandlePlanAction(planRec, planReq) + h.HandlePlanAction(planRec, actionHandlerTestRequest(planReq, "")) if planRec.Code != http.StatusOK { t.Fatalf("plan status = %d, body=%s", planRec.Code, planRec.Body.String()) } @@ -499,7 +561,7 @@ func TestHandleExecuteActionRunsApprovedPlanThroughExecutor(t *testing.T) { }`)) decisionReq.SetPathValue("id", plan.ActionID) decisionReq = decisionReq.WithContext(auth.WithUser(decisionReq.Context(), "operator@example.com")) - h.HandleDecideAction(decisionRec, decisionReq) + h.HandleDecideAction(decisionRec, actionHandlerTestRequest(decisionReq, "")) if decisionRec.Code != http.StatusOK { t.Fatalf("decision status = %d, body=%s", decisionRec.Code, decisionRec.Body.String()) } @@ -510,7 +572,7 @@ func TestHandleExecuteActionRunsApprovedPlanThroughExecutor(t *testing.T) { }`)) executeReq.SetPathValue("id", plan.ActionID) executeReq = executeReq.WithContext(auth.WithUser(executeReq.Context(), "operator@example.com")) - h.HandleExecuteAction(executeRec, executeReq) + h.HandleExecuteAction(executeRec, actionHandlerTestRequest(executeReq, "")) if executeRec.Code != http.StatusOK { t.Fatalf("execute status = %d, body=%s", executeRec.Code, executeRec.Body.String()) } @@ -556,7 +618,7 @@ func TestHandleExecuteActionRunsApprovedPlanThroughExecutor(t *testing.T) { for _, event := range events { seen[event.State] = true } - if len(events) != 5 || + if len(events) != 6 || !seen[unified.ActionStatePlanned] || !seen[unified.ActionStatePending] || !seen[unified.ActionStateApproved] || @@ -594,7 +656,7 @@ func TestHandleExecuteActionRejectsStalePlanBeforeExecutor(t *testing.T) { resources: []unified.Resource{resource}, freshness: now, } - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(provider) executor := &stubActionExecutor{result: &unified.ExecutionResult{Success: true, Output: "should not run"}} h.SetActionExecutor(executor) @@ -612,7 +674,7 @@ func TestHandleExecuteActionRejectsStalePlanBeforeExecutor(t *testing.T) { "reason":"Recover after confirmed outage", "requestedBy":"agent:oncall-helper" }`)) - h.HandlePlanAction(planRec, planReq) + h.HandlePlanAction(planRec, actionHandlerTestRequest(planReq, "")) if planRec.Code != http.StatusOK { t.Fatalf("plan status = %d, body=%s", planRec.Code, planRec.Body.String()) } @@ -628,7 +690,7 @@ func TestHandleExecuteActionRejectsStalePlanBeforeExecutor(t *testing.T) { }`)) decisionReq.SetPathValue("id", plan.ActionID) decisionReq = decisionReq.WithContext(auth.WithUser(decisionReq.Context(), "operator@example.com")) - h.HandleDecideAction(decisionRec, decisionReq) + h.HandleDecideAction(decisionRec, actionHandlerTestRequest(decisionReq, "")) if decisionRec.Code != http.StatusOK { t.Fatalf("decision status = %d, body=%s", decisionRec.Code, decisionRec.Body.String()) } @@ -663,7 +725,7 @@ func TestHandleExecuteActionRejectsStalePlanBeforeExecutor(t *testing.T) { executeReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+plan.ActionID+"/execute", bytes.NewBufferString(`{}`)) executeReq.SetPathValue("id", plan.ActionID) executeReq = executeReq.WithContext(auth.WithUser(executeReq.Context(), "operator@example.com")) - h.HandleExecuteAction(executeRec, executeReq) + h.HandleExecuteAction(executeRec, actionHandlerTestRequest(executeReq, "")) if executeRec.Code != http.StatusConflict { t.Fatalf("execute status = %d, body=%s", executeRec.Code, executeRec.Body.String()) } @@ -707,7 +769,7 @@ func TestHandleExecuteActionRejectsStalePlanBeforeExecutor(t *testing.T) { func TestHandleExecuteActionWithoutExecutorLeavesApprovedAuditUnchanged(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) store, err := h.getStore("default") if err != nil { t.Fatalf("get store: %v", err) @@ -717,34 +779,21 @@ func TestHandleExecuteActionWithoutExecutorLeavesApprovedAuditUnchanged(t *testi CreatedAt: now.Add(-time.Minute), UpdatedAt: now, State: unified.ActionStateApproved, - Request: unified.ActionRequest{ - RequestID: "req-no-executor", - ResourceID: "vm:42", - CapabilityName: "restart", - Reason: "Recover after confirmed outage", - RequestedBy: "agent:oncall-helper", - }, + Request: boundActionTestRequest("req-no-executor", "vm:42", "restart", "Recover after confirmed outage", "agent:oncall-helper"), Plan: unified.ActionPlan{ - ActionID: "act_no_executor", - RequestID: "req-no-executor", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unified.ApprovalAdmin, - PlannedAt: now.Add(-time.Minute), - ExpiresAt: now.Add(5 * time.Minute), - ResourceVersion: "resource:sha256:test", - PolicyVersion: "policy:sha256:test", - PlanHash: "sha256:test", - }, - Approvals: []unified.ActionApprovalRecord{ - { - Actor: "operator@example.com", - Method: unified.MethodAPI, - Timestamp: now, - Outcome: unified.OutcomeApproved, - Reason: "approved for proof", - }, + ActionID: "act_no_executor", + RequestID: "req-no-executor", + Allowed: true, + RequiresApproval: true, + ApprovalPolicy: unified.ApprovalAdmin, + ApprovalRequirement: unified.ApprovalRequirementForFloor(unified.ApprovalAdmin), + PlannedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(5 * time.Minute), + ResourceVersion: "resource:sha256:test", + PolicyVersion: "policy:sha256:test", + PlanHash: "sha256:test", }, + Approvals: []unified.ActionApprovalRecord{boundActionTestApproval("act_no_executor", "sha256:test", "operator@example.com", now)}, } if err := store.RecordActionAudit(record); err != nil { t.Fatalf("RecordActionAudit: %v", err) @@ -754,7 +803,7 @@ func TestHandleExecuteActionWithoutExecutorLeavesApprovedAuditUnchanged(t *testi executeReq := httptest.NewRequest(http.MethodPost, "/api/actions/act_no_executor/execute", bytes.NewBufferString(`{}`)) executeReq.SetPathValue("id", "act_no_executor") executeReq = executeReq.WithContext(auth.WithUser(executeReq.Context(), "operator@example.com")) - h.HandleExecuteAction(executeRec, executeReq) + h.HandleExecuteAction(executeRec, actionHandlerTestRequest(executeReq, "")) if executeRec.Code != http.StatusNotImplemented { t.Fatalf("execute status = %d, body=%s", executeRec.Code, executeRec.Body.String()) } @@ -780,7 +829,7 @@ func TestHandleExecuteActionWithoutExecutorLeavesApprovedAuditUnchanged(t *testi func TestHandleExecuteActionRejectsDryRunOnlyPlan(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) executor := &stubActionExecutor{result: &unified.ExecutionResult{Success: true, Output: "should not run"}} h.SetActionExecutor(executor) published := make(chan unified.ActionAuditRecord, 1) @@ -797,23 +846,18 @@ func TestHandleExecuteActionRejectsDryRunOnlyPlan(t *testing.T) { CreatedAt: now.Add(-time.Minute), UpdatedAt: now, State: unified.ActionStatePlanned, - Request: unified.ActionRequest{ - RequestID: "req-dry-run-only", - ResourceID: "vm:42", - CapabilityName: "restart", - Reason: "Validate restart path without execution", - RequestedBy: "agent:oncall-helper", - }, + Request: boundActionTestRequest("req-dry-run-only", "vm:42", "restart", "Validate restart path without execution", "agent:oncall-helper"), Plan: unified.ActionPlan{ - ActionID: "act_dry_run_only", - RequestID: "req-dry-run-only", - Allowed: true, - ApprovalPolicy: unified.ApprovalDryRun, - PlannedAt: now.Add(-time.Minute), - ExpiresAt: now.Add(5 * time.Minute), - ResourceVersion: "resource:sha256:test", - PolicyVersion: "policy:sha256:test", - PlanHash: "sha256:test", + ActionID: "act_dry_run_only", + RequestID: "req-dry-run-only", + Allowed: true, + ApprovalPolicy: unified.ApprovalDryRun, + ApprovalRequirement: unified.ApprovalRequirementForFloor(unified.ApprovalDryRun), + PlannedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(5 * time.Minute), + ResourceVersion: "resource:sha256:test", + PolicyVersion: "policy:sha256:test", + PlanHash: "sha256:test", }, } if err := store.RecordActionAudit(record); err != nil { @@ -824,7 +868,7 @@ func TestHandleExecuteActionRejectsDryRunOnlyPlan(t *testing.T) { executeReq := httptest.NewRequest(http.MethodPost, "/api/actions/act_dry_run_only/execute", bytes.NewBufferString(`{}`)) executeReq.SetPathValue("id", "act_dry_run_only") executeReq = executeReq.WithContext(auth.WithUser(executeReq.Context(), "operator@example.com")) - h.HandleExecuteAction(executeRec, executeReq) + h.HandleExecuteAction(executeRec, actionHandlerTestRequest(executeReq, "")) if executeRec.Code != http.StatusConflict { t.Fatalf("execute status = %d, body=%s", executeRec.Code, executeRec.Body.String()) } @@ -861,7 +905,7 @@ func TestHandleExecuteActionRejectsDryRunOnlyPlan(t *testing.T) { func TestHandleExecuteActionMaterializesExplicitExpiredState(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) executor := &stubActionExecutor{result: &unified.ExecutionResult{Success: true, Output: "should not run"}} h.SetActionExecutor(executor) published := make(chan unified.ActionAuditRecord, 1) @@ -878,34 +922,21 @@ func TestHandleExecuteActionMaterializesExplicitExpiredState(t *testing.T) { CreatedAt: now.Add(-10 * time.Minute), UpdatedAt: now.Add(-6 * time.Minute), State: unified.ActionStateApproved, - Request: unified.ActionRequest{ - RequestID: "req-expired", - ResourceID: "vm:42", - CapabilityName: "restart", - Reason: "Recover after confirmed outage", - RequestedBy: "agent:oncall-helper", - }, + Request: boundActionTestRequest("req-expired", "vm:42", "restart", "Recover after confirmed outage", "agent:oncall-helper"), Plan: unified.ActionPlan{ - ActionID: "act_expired", - RequestID: "req-expired", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unified.ApprovalAdmin, - PlannedAt: now.Add(-10 * time.Minute), - ExpiresAt: now.Add(-5 * time.Minute), - ResourceVersion: "resource:sha256:test", - PolicyVersion: "policy:sha256:test", - PlanHash: "sha256:test", - }, - Approvals: []unified.ActionApprovalRecord{ - { - Actor: "operator@example.com", - Method: unified.MethodAPI, - Timestamp: now.Add(-6 * time.Minute), - Outcome: unified.OutcomeApproved, - Reason: "approved before expiry", - }, + ActionID: "act_expired", + RequestID: "req-expired", + Allowed: true, + RequiresApproval: true, + ApprovalPolicy: unified.ApprovalAdmin, + ApprovalRequirement: unified.ApprovalRequirementForFloor(unified.ApprovalAdmin), + PlannedAt: now.Add(-10 * time.Minute), + ExpiresAt: now.Add(-5 * time.Minute), + ResourceVersion: "resource:sha256:test", + PolicyVersion: "policy:sha256:test", + PlanHash: "sha256:test", }, + Approvals: []unified.ActionApprovalRecord{boundActionTestApproval("act_expired", "sha256:test", "operator@example.com", now.Add(-6*time.Minute))}, } if err := store.RecordActionAudit(record); err != nil { t.Fatalf("RecordActionAudit: %v", err) @@ -915,7 +946,7 @@ func TestHandleExecuteActionMaterializesExplicitExpiredState(t *testing.T) { executeReq := httptest.NewRequest(http.MethodPost, "/api/actions/act_expired/execute", bytes.NewBufferString(`{}`)) executeReq.SetPathValue("id", "act_expired") executeReq = executeReq.WithContext(auth.WithUser(executeReq.Context(), "operator@example.com")) - h.HandleExecuteAction(executeRec, executeReq) + h.HandleExecuteAction(executeRec, actionHandlerTestRequest(executeReq, "")) if executeRec.Code != http.StatusConflict { t.Fatalf("execute status = %d, body=%s", executeRec.Code, executeRec.Body.String()) } @@ -989,7 +1020,7 @@ func TestPersistActionPlanAuditRejectsOrphanLifecycleState(t *testing.T) { func TestHandlePlanActionRejectsMissingCapability(t *testing.T) { now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -1006,7 +1037,7 @@ func TestHandlePlanActionRejectsMissingCapability(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/actions/plan", body) - h.HandlePlanAction(rec, req) + h.HandlePlanAction(rec, actionHandlerTestRequest(req, "")) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) diff --git a/internal/api/agent_activity_telemetry_test.go b/internal/api/agent_activity_telemetry_test.go index b6c756a5d..e4a83422d 100644 --- a/internal/api/agent_activity_telemetry_test.go +++ b/internal/api/agent_activity_telemetry_test.go @@ -42,7 +42,7 @@ func TestRecordExternalAgentCapabilityActivityUsesCapabilityScope(t *testing.T) } actionReq := httptest.NewRequest(http.MethodPost, "/api/actions/plan", nil) - attachAPITokenRecord(actionReq, &config.APITokenRecord{Scopes: []string{config.ScopeAIExecute}}) + attachAPITokenRecord(actionReq, &config.APITokenRecord{Scopes: []string{config.ScopeActionsPlan}}) router.recordExternalAgentCapabilityActivity(actionReq, agentcapabilities.PlanActionCapabilityName) history, err = persistence.LoadExternalAgentActivityHistory() diff --git a/internal/api/agent_capabilities_test.go b/internal/api/agent_capabilities_test.go index b120f8047..3511c4e0e 100644 --- a/internal/api/agent_capabilities_test.go +++ b/internal/api/agent_capabilities_test.go @@ -223,9 +223,9 @@ func TestAgentCapabilitiesManifest_ScopesMatchAPIAuthConstants(t *testing.T) { agentcapabilities.SnoozeFindingCapabilityName: config.ScopeAIExecute, agentcapabilities.DismissFindingCapabilityName: config.ScopeAIExecute, agentcapabilities.ResolveFindingCapabilityName: config.ScopeAIExecute, - agentcapabilities.PlanActionCapabilityName: config.ScopeAIExecute, - agentcapabilities.DecideActionCapabilityName: config.ScopeAIExecute, - agentcapabilities.ExecuteActionCapabilityName: config.ScopeAIExecute, + agentcapabilities.PlanActionCapabilityName: config.ScopeActionsPlan, + agentcapabilities.DecideActionCapabilityName: config.ScopeActionsApprove, + agentcapabilities.ExecuteActionCapabilityName: config.ScopeActionsExecute, } manifest := agentcapabilities.CanonicalManifest() diff --git a/internal/api/agent_resource_context_test.go b/internal/api/agent_resource_context_test.go index 02d08b1b3..0c25fbcd5 100644 --- a/internal/api/agent_resource_context_test.go +++ b/internal/api/agent_resource_context_test.go @@ -923,6 +923,7 @@ func TestHandleAgentResourceContext_RecentActionsCarryRefusalTokens(t *testing.T CapabilityName: "pulse_control", Reason: "restart workload", RequestedBy: "pulse_patrol", + Actor: unified.ActionActor{SubjectID: "pulse_patrol", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"}, Params: map[string]any{"command": "systemctl restart workload"}, }, Plan: plan, @@ -2488,23 +2489,23 @@ func TestHandleAgentOperationsLoopStatus_UsesDecisionLifecycleEvidenceForOlderPl CapabilityName: "restart_service", Params: map[string]any{"command": "systemctl restart nginx"}, RequestedBy: "pulse_patrol", + Actor: unified.ActionActor{SubjectID: "pulse_patrol", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"}, }, Plan: unified.ActionPlan{ - ActionID: "act-rejected", - RequestID: "req-rejected", - PlannedAt: old, - ExpiresAt: now.Add(time.Hour), - RequiresApproval: true, + ActionID: "act-rejected", + RequestID: "req-rejected", + PlannedAt: old, + ExpiresAt: now.Add(time.Hour), + RequiresApproval: true, + ApprovalPolicy: unified.ApprovalAdmin, + ApprovalRequirement: unified.ApprovalRequirementForFloor(unified.ApprovalAdmin), + PlanHash: "sha256:act-rejected", }, } if err := store.RecordActionAudit(record); err != nil { t.Fatalf("RecordActionAudit: %v", err) } - rejected, event, err := unified.ApplyActionDecision(record, unified.ActionApprovalRecord{ - Actor: "operator@example.com", - Method: unified.MethodUI, - Outcome: unified.OutcomeRejected, - }, now) + rejected, event, err := unified.ApplyActionDecision(record, boundActionTestDecisionApproval(record.ID, record.Plan.PlanHash, "operator@example.com", unified.OutcomeRejected, now), now) if err != nil { t.Fatalf("ApplyActionDecision: %v", err) } @@ -2592,23 +2593,23 @@ func TestHandleAgentOperationsLoopStatus_ApprovedDecisionStillNeedsVerifiedOutco CapabilityName: "restart_service", Params: map[string]any{"command": "systemctl restart nginx"}, RequestedBy: "pulse_patrol", + Actor: unified.ActionActor{SubjectID: "pulse_patrol", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"}, }, Plan: unified.ActionPlan{ - ActionID: "act-approved", - RequestID: "req-approved", - PlannedAt: now.Add(-10 * time.Minute), - ExpiresAt: now.Add(time.Hour), - RequiresApproval: true, + ActionID: "act-approved", + RequestID: "req-approved", + PlannedAt: now.Add(-10 * time.Minute), + ExpiresAt: now.Add(time.Hour), + RequiresApproval: true, + ApprovalPolicy: unified.ApprovalAdmin, + ApprovalRequirement: unified.ApprovalRequirementForFloor(unified.ApprovalAdmin), + PlanHash: "sha256:act-approved", }, } if err := store.RecordActionAudit(record); err != nil { t.Fatalf("RecordActionAudit: %v", err) } - approved, event, err := unified.ApplyActionDecision(record, unified.ActionApprovalRecord{ - Actor: "operator@example.com", - Method: unified.MethodUI, - Outcome: unified.OutcomeApproved, - }, now) + approved, event, err := unified.ApplyActionDecision(record, boundActionTestDecisionApproval(record.ID, record.Plan.PlanHash, "operator@example.com", unified.OutcomeApproved, now), now) if err != nil { t.Fatalf("ApplyActionDecision: %v", err) } diff --git a/internal/api/agent_substrate_e2e_test.go b/internal/api/agent_substrate_e2e_test.go index 18ef1f307..56e9a78da 100644 --- a/internal/api/agent_substrate_e2e_test.go +++ b/internal/api/agent_substrate_e2e_test.go @@ -626,10 +626,10 @@ func TestAgentSubstrate_ActionEndpointsEmitAgentStableEnvelope(t *testing.T) { for _, c := range manifest.Capabilities { byName[c.Name] = c } - for _, want := range []string{ - agentcapabilities.PlanActionCapabilityName, - agentcapabilities.DecideActionCapabilityName, - agentcapabilities.ExecuteActionCapabilityName, + for want, wantScope := range map[string]string{ + agentcapabilities.PlanActionCapabilityName: config.ScopeActionsPlan, + agentcapabilities.DecideActionCapabilityName: config.ScopeActionsApprove, + agentcapabilities.ExecuteActionCapabilityName: config.ScopeActionsExecute, } { cap, ok := byName[want] if !ok { @@ -638,8 +638,8 @@ func TestAgentSubstrate_ActionEndpointsEmitAgentStableEnvelope(t *testing.T) { if cap.Category != "action" { t.Errorf("%s: category = %q, want \"action\"", want, cap.Category) } - if cap.Scope != config.ScopeAIExecute { - t.Errorf("%s: scope = %q, want %q", want, cap.Scope, config.ScopeAIExecute) + if cap.Scope != wantScope { + t.Errorf("%s: scope = %q, want %q", want, cap.Scope, wantScope) } if len(cap.ErrorCodes) == 0 { t.Errorf("%s: must declare at least one stable errorCode", want) diff --git a/internal/api/assistant_typed_action_planner.go b/internal/api/assistant_typed_action_planner.go index f7bdee936..8bc13bb32 100644 --- a/internal/api/assistant_typed_action_planner.go +++ b/internal/api/assistant_typed_action_planner.go @@ -11,7 +11,8 @@ type assistantTypedActionPlanner struct { } func (p assistantTypedActionPlanner) PlanTypedAction(ctx context.Context, orgID string, req unified.ActionRequest) (*unified.ActionPlan, error) { - plan, err := p.resources.ActionLifecycle().Plan(ctx, orgID, req) + actor := unified.ActionActor{SubjectID: "pulse_assistant", Kind: unified.ActionActorService, CredentialID: "service:assistant", OrgID: orgID} + plan, err := p.resources.ActionLifecycle().Plan(ctx, orgID, req, actor) if err != nil { return nil, err } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 7b2d36481..57d4aecff 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -12379,7 +12379,7 @@ func TestContractResourceFiltersAcceptNativeDockerAndKubernetesInventory(t *test func TestContract_TenantResourcesDoNotFallbackToRawSnapshotSeeding(t *testing.T) { now := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{ Hosts: []models.Host{{ID: "host-default", Hostname: "default", Status: "online", LastSeen: now}}, }}) @@ -12408,7 +12408,7 @@ func TestContract_TenantResourcesDoNotFallbackToRawSnapshotSeeding(t *testing.T) func TestContract_ResourceListPolicyMetadata(t *testing.T) { now := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unifiedresources.Resource{ @@ -12492,7 +12492,7 @@ func TestContract_ResourceListPolicyMetadata(t *testing.T) { func TestContract_ProxmoxWorkloadDiscoveryTargetUsesLinkedNodeAgent(t *testing.T) { now := time.Date(2026, 6, 4, 20, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unifiedresources.Resource{ @@ -12553,7 +12553,7 @@ func TestContract_ProxmoxWorkloadDiscoveryTargetUsesLinkedNodeAgent(t *testing.T func TestContract_ResourceListUsesDeterministicNameTieBreakers(t *testing.T) { now := time.Date(2026, 4, 11, 0, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unifiedresources.Resource{ @@ -12596,7 +12596,7 @@ func TestContract_ResourceListUsesDeterministicNameTieBreakers(t *testing.T) { func TestContract_ResourceListAcceptsBrowserEncodedTypeCSV(t *testing.T) { now := time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unifiedresources.Resource{ @@ -12671,7 +12671,7 @@ func TestContract_StateAndResourceListShareCanonicalMockResourceContract(t *test monitor: monitor, persistence: config.NewConfigPersistence(dataPath), } - handlers := NewResourceHandlers(cfg) + handlers := newActionTestResourceHandlers(t, cfg) handlers.SetStateProvider(monitor) stateReq := httptest.NewRequest(http.MethodGet, "/api/state", nil) @@ -13719,6 +13719,7 @@ func TestContract_ActionPlanJSONSnapshot(t *testing.T) { Params: map[string]any{"mode": "graceful"}, Reason: "Recover after confirmed outage", RequestedBy: "agent:oncall-helper", + Actor: unifiedresources.ActionActor{SubjectID: "agent:oncall-helper", Kind: unifiedresources.ActionActorService, CredentialID: "service:contract", OrgID: "default"}, } plan, err := (actionplanner.Planner{Now: func() time.Time { return now }}).Plan(req, resource) @@ -13731,11 +13732,12 @@ func TestContract_ActionPlanJSONSnapshot(t *testing.T) { } const want = `{ - "actionId":"act_8f428171be2762cec97fd4546a291d5f", + "actionId":"act_4ccd0282616d29d9c1751617eaee44c7", "requestId":"agent-run-123", "allowed":true, "requiresApproval":true, "approvalPolicy":"admin", + "approvalRequirement":{"version":1,"floor":"admin","quorum":1,"disallowRequester":false}, "predictedBlastRadius":["vm:42","node-1"], "rollbackAvailable":false, "message":"Plan created for restart on web-42. Execution requires admin approval and is not performed by this endpoint.", @@ -13743,7 +13745,7 @@ func TestContract_ActionPlanJSONSnapshot(t *testing.T) { "expiresAt":"2026-05-03T10:05:00Z", "resourceVersion":"resource:sha256:54fb6f0264f42e0f2724e513", "policyVersion":"policy:sha256:0bce3cd2df181ace685598eb", - "planHash":"sha256:69631faa9da67496a8b5953de2bc2dceb7b2aab5b582be3d1536e5d67683791b", + "planHash":"sha256:216bf02a80fa947125afcb54241e4972d79144da45ff57f5360fbca9a1623ca8", "preflight":{ "target":"vm:42", "currentState":"web-42 is warning", @@ -13758,7 +13760,7 @@ func TestContract_ActionPlanJSONSnapshot(t *testing.T) { ], "verificationSteps":[ "Refresh the resource and confirm the expected state after execution.", - "Review /api/audit/actions/act_8f428171be2762cec97fd4546a291d5f/events for lifecycle evidence." + "Review /api/audit/actions/act_4ccd0282616d29d9c1751617eaee44c7/events for lifecycle evidence." ], "generatedAt":"2026-05-03T10:00:00Z" } @@ -13797,6 +13799,7 @@ func TestContract_ActionPlanAuditLifecycleSnapshot(t *testing.T) { Params: map[string]any{"mode": "graceful"}, Reason: "Recover after confirmed outage", RequestedBy: "agent:oncall-helper", + Actor: unifiedresources.ActionActor{SubjectID: "agent:oncall-helper", Kind: unifiedresources.ActionActorService, CredentialID: "service:contract", OrgID: "default"}, } plan, err := (actionplanner.Planner{Now: func() time.Time { return now }}).Plan(req, resource) if err != nil { @@ -13863,13 +13866,13 @@ func TestContract_ActionPlanAuditLifecycleSnapshot(t *testing.T) { } const want = `{ "audit":{ - "id":"act_7eed41cdc58507f340151d2497707eca", + "id":"act_a0f4a22a14c32c635a2ecc3cddea25a1", "state":"pending_approval", "resourceId":"vm:42", "requestId":"agent-run-123", "requestedBy":"agent:oncall-helper", "approvalPolicy":"admin", - "planHash":"sha256:f60417e39f967eb0803af0a3f2e2abd70f20d8a77a09c2e36976bdda34b6dbaf", + "planHash":"sha256:1473ff97f7a51c6956d40eca1d8e710db1c0abd008b2ab429d3071b81f387fde", "preflightSummary":"No provider-supported dry run is advertised for this capability." }, "events":[ @@ -13891,6 +13894,8 @@ func TestContract_ActionPlanAuditLifecycleSnapshot(t *testing.T) { func TestContract_ActionDecisionJSONSnapshot(t *testing.T) { now := time.Date(2026, 5, 4, 15, 0, 0, 0, time.UTC) plannedAt := now.Add(-time.Minute) + requestActor := unifiedresources.ActionActor{SubjectID: "agent:oncall-helper", Kind: unifiedresources.ActionActorService, CredentialID: "service:contract", OrgID: "default"} + decisionActor := unifiedresources.ActionActor{SubjectID: "operator@example.com", Kind: unifiedresources.ActionActorUser, CredentialID: "session:contract", OrgID: "default"} record := unifiedresources.ActionAuditRecord{ ID: "act_decision_contract", CreatedAt: plannedAt, @@ -13902,19 +13907,21 @@ func TestContract_ActionDecisionJSONSnapshot(t *testing.T) { CapabilityName: "restart", Reason: "Recover after confirmed outage", RequestedBy: "agent:oncall-helper", + Actor: requestActor, }, Plan: unifiedresources.ActionPlan{ - ActionID: "act_decision_contract", - RequestID: "agent-run-approve", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - RollbackAvailable: false, - PlannedAt: plannedAt, - ExpiresAt: now.Add(4 * time.Minute), - ResourceVersion: "resource:sha256:contract", - PolicyVersion: "policy:sha256:contract", - PlanHash: "sha256:contract", + ActionID: "act_decision_contract", + RequestID: "agent-run-approve", + Allowed: true, + RequiresApproval: true, + ApprovalPolicy: unifiedresources.ApprovalAdmin, + ApprovalRequirement: unifiedresources.ApprovalRequirementForFloor(unifiedresources.ApprovalAdmin), + RollbackAvailable: false, + PlannedAt: plannedAt, + ExpiresAt: now.Add(4 * time.Minute), + ResourceVersion: "resource:sha256:contract", + PolicyVersion: "policy:sha256:contract", + PlanHash: "sha256:contract", Preflight: &unifiedresources.ActionPreflight{ Target: "vm:42", CurrentState: "web-42 is warning", @@ -13933,10 +13940,10 @@ func TestContract_ActionDecisionJSONSnapshot(t *testing.T) { }, } + evidence := unifiedresources.ApprovalEvidence{Version: 1, Method: unifiedresources.MethodSession, Actor: decisionActor, OrgID: "default", ActionID: record.ID, PlanHash: record.Plan.PlanHash, Outcome: unifiedresources.OutcomeApproved, IssuedAt: now} updated, event, err := unifiedresources.ApplyActionDecision(record, unifiedresources.ActionApprovalRecord{ - Actor: "operator@example.com", - Outcome: unifiedresources.OutcomeApproved, - Reason: "inside maintenance window", + Actor: "operator@example.com", ActorBinding: decisionActor, Method: unifiedresources.MethodSession, + Outcome: unifiedresources.OutcomeApproved, Reason: "inside maintenance window", Evidence: &evidence, }, now) if err != nil { t.Fatalf("apply action decision: %v", err) @@ -13980,22 +13987,26 @@ func TestContract_ActionDecisionJSONSnapshot(t *testing.T) { "state":"approved", "approval":{ "actor":"operator@example.com", - "method":"api", + "method":"session", "timestamp":"2026-05-04T15:00:00Z", "outcome":"approved", - "reason":"inside maintenance window" + "reason":"inside maintenance window", + "actorBinding":{"subjectId":"operator@example.com","kind":"user","credentialId":"session:contract","orgId":"default"}, + "evidence":{"version":1,"method":"session","actor":{"subjectId":"operator@example.com","kind":"user","credentialId":"session:contract","orgId":"default"},"orgId":"default","actionId":"act_decision_contract","planHash":"sha256:contract","outcome":"approved","issuedAt":"2026-05-04T15:00:00Z","expiresAt":"0001-01-01T00:00:00Z"} }, "audit":{ "id":"act_decision_contract", "createdAt":"2026-05-04T14:59:00Z", "updatedAt":"2026-05-04T15:00:00Z", "state":"approved", + "decisionRevision":1, "request":{ "requestId":"agent-run-approve", "resourceId":"vm:42", "capabilityName":"restart", "reason":"Recover after confirmed outage", - "requestedBy":"agent:oncall-helper" + "requestedBy":"agent:oncall-helper", + "actor":{"subjectId":"agent:oncall-helper","kind":"service","credentialId":"service:contract","orgId":"default"} }, "plan":{ "actionId":"act_decision_contract", @@ -14003,6 +14014,7 @@ func TestContract_ActionDecisionJSONSnapshot(t *testing.T) { "allowed":true, "requiresApproval":true, "approvalPolicy":"admin", + "approvalRequirement":{"version":1,"floor":"admin","quorum":1,"disallowRequester":false}, "rollbackAvailable":false, "plannedAt":"2026-05-04T14:59:00Z", "expiresAt":"2026-05-04T15:04:00Z", @@ -14028,10 +14040,12 @@ func TestContract_ActionDecisionJSONSnapshot(t *testing.T) { "approvals":[ { "actor":"operator@example.com", - "method":"api", + "method":"session", "timestamp":"2026-05-04T15:00:00Z", "outcome":"approved", - "reason":"inside maintenance window" + "reason":"inside maintenance window", + "actorBinding":{"subjectId":"operator@example.com","kind":"user","credentialId":"session:contract","orgId":"default"}, + "evidence":{"version":1,"method":"session","actor":{"subjectId":"operator@example.com","kind":"user","credentialId":"session:contract","orgId":"default"},"orgId":"default","actionId":"act_decision_contract","planHash":"sha256:contract","outcome":"approved","issuedAt":"2026-05-04T15:00:00Z","expiresAt":"0001-01-01T00:00:00Z"} } ], "verificationOutcome":{ @@ -14044,7 +14058,7 @@ func TestContract_ActionDecisionJSONSnapshot(t *testing.T) { "state":"approved", "actor":"operator@example.com", "message":"Action approved. Execution remains pending a separate execution contract.", - "method":"api", + "method":"session", "outcome":"approved", "policy":"admin" } @@ -14057,30 +14071,36 @@ func TestContract_ActionExecutionJSONSnapshot(t *testing.T) { completedAt := startedAt.Add(30 * time.Second) plannedAt := startedAt.Add(-2 * time.Minute) approvedAt := startedAt.Add(-time.Minute) + requestActor := unifiedresources.ActionActor{SubjectID: "agent:oncall-helper", Kind: unifiedresources.ActionActorService, CredentialID: "service:contract", OrgID: "default"} + approvalActor := unifiedresources.ActionActor{SubjectID: "operator@example.com", Kind: unifiedresources.ActionActorUser, CredentialID: "session:contract", OrgID: "default"} + approvalEvidence := unifiedresources.ApprovalEvidence{Version: 1, Method: unifiedresources.MethodSession, Actor: approvalActor, OrgID: "default", ActionID: "act_execution_contract", PlanHash: "sha256:contract", Outcome: unifiedresources.OutcomeApproved, IssuedAt: approvedAt} record := unifiedresources.ActionAuditRecord{ - ID: "act_execution_contract", - CreatedAt: plannedAt, - UpdatedAt: approvedAt, - State: unifiedresources.ActionStateApproved, + ID: "act_execution_contract", + CreatedAt: plannedAt, + UpdatedAt: approvedAt, + State: unifiedresources.ActionStateApproved, + DecisionRevision: 1, Request: unifiedresources.ActionRequest{ RequestID: "agent-run-execute", ResourceID: "vm:42", CapabilityName: "restart", Reason: "Recover after confirmed outage", RequestedBy: "agent:oncall-helper", + Actor: requestActor, }, Plan: unifiedresources.ActionPlan{ - ActionID: "act_execution_contract", - RequestID: "agent-run-execute", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - RollbackAvailable: false, - PlannedAt: plannedAt, - ExpiresAt: startedAt.Add(3 * time.Minute), - ResourceVersion: "resource:sha256:contract", - PolicyVersion: "policy:sha256:contract", - PlanHash: "sha256:contract", + ActionID: "act_execution_contract", + RequestID: "agent-run-execute", + Allowed: true, + RequiresApproval: true, + ApprovalPolicy: unifiedresources.ApprovalAdmin, + ApprovalRequirement: unifiedresources.ApprovalRequirementForFloor(unifiedresources.ApprovalAdmin), + RollbackAvailable: false, + PlannedAt: plannedAt, + ExpiresAt: startedAt.Add(3 * time.Minute), + ResourceVersion: "resource:sha256:contract", + PolicyVersion: "policy:sha256:contract", + PlanHash: "sha256:contract", Preflight: &unifiedresources.ActionPreflight{ Target: "vm:42", CurrentState: "web-42 is warning", @@ -14099,11 +14119,13 @@ func TestContract_ActionExecutionJSONSnapshot(t *testing.T) { }, Approvals: []unifiedresources.ActionApprovalRecord{ { - Actor: "operator@example.com", - Method: unifiedresources.MethodAPI, - Timestamp: approvedAt, - Outcome: unifiedresources.OutcomeApproved, - Reason: "inside maintenance window", + Actor: "operator@example.com", + ActorBinding: approvalActor, + Method: unifiedresources.MethodSession, + Timestamp: approvedAt, + Outcome: unifiedresources.OutcomeApproved, + Reason: "inside maintenance window", + Evidence: &approvalEvidence, }, }, } @@ -14166,12 +14188,14 @@ func TestContract_ActionExecutionJSONSnapshot(t *testing.T) { "createdAt":"2026-05-04T15:28:00Z", "updatedAt":"2026-05-04T15:30:30Z", "state":"completed", + "decisionRevision":1, "request":{ "requestId":"agent-run-execute", "resourceId":"vm:42", "capabilityName":"restart", "reason":"Recover after confirmed outage", - "requestedBy":"agent:oncall-helper" + "requestedBy":"agent:oncall-helper", + "actor":{"subjectId":"agent:oncall-helper","kind":"service","credentialId":"service:contract","orgId":"default"} }, "plan":{ "actionId":"act_execution_contract", @@ -14179,6 +14203,7 @@ func TestContract_ActionExecutionJSONSnapshot(t *testing.T) { "allowed":true, "requiresApproval":true, "approvalPolicy":"admin", + "approvalRequirement":{"version":1,"floor":"admin","quorum":1,"disallowRequester":false}, "rollbackAvailable":false, "plannedAt":"2026-05-04T15:28:00Z", "expiresAt":"2026-05-04T15:33:00Z", @@ -14204,10 +14229,12 @@ func TestContract_ActionExecutionJSONSnapshot(t *testing.T) { "approvals":[ { "actor":"operator@example.com", - "method":"api", + "method":"session", "timestamp":"2026-05-04T15:29:00Z", "outcome":"approved", - "reason":"inside maintenance window" + "reason":"inside maintenance window", + "actorBinding":{"subjectId":"operator@example.com","kind":"user","credentialId":"session:contract","orgId":"default"}, + "evidence":{"version":1,"method":"session","actor":{"subjectId":"operator@example.com","kind":"user","credentialId":"session:contract","orgId":"default"},"orgId":"default","actionId":"act_execution_contract","planHash":"sha256:contract","outcome":"approved","issuedAt":"2026-05-04T15:29:00Z","expiresAt":"0001-01-01T00:00:00Z"} } ], "result":{ @@ -14239,7 +14266,7 @@ func TestContract_ActionExecutionJSONSnapshot(t *testing.T) { func TestContract_ActionDryRunOnlyExecutionErrorJSONSnapshot(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) store, err := h.getStore("default") if err != nil { t.Fatalf("get store: %v", err) @@ -14256,19 +14283,21 @@ func TestContract_ActionDryRunOnlyExecutionErrorJSONSnapshot(t *testing.T) { CapabilityName: "restart", Reason: "Inspect possible restart remediation", RequestedBy: "agent:oncall-helper", + Actor: unifiedresources.ActionActor{SubjectID: "agent:oncall-helper", Kind: unifiedresources.ActionActorUser, CredentialID: "session:contract", OrgID: "default"}, }, Plan: unifiedresources.ActionPlan{ - ActionID: "act_dry_run_contract", - RequestID: "agent-run-dry-run", - Allowed: true, - RequiresApproval: false, - ApprovalPolicy: unifiedresources.ApprovalDryRun, - RollbackAvailable: false, - PlannedAt: now.Add(-time.Minute), - ExpiresAt: now.Add(4 * time.Minute), - ResourceVersion: "resource:sha256:dry-run-contract", - PolicyVersion: "policy:sha256:dry-run-contract", - PlanHash: "sha256:dry-run-contract", + ActionID: "act_dry_run_contract", + RequestID: "agent-run-dry-run", + Allowed: true, + RequiresApproval: false, + ApprovalPolicy: unifiedresources.ApprovalDryRun, + ApprovalRequirement: unifiedresources.ApprovalRequirementForFloor(unifiedresources.ApprovalDryRun), + RollbackAvailable: false, + PlannedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(4 * time.Minute), + ResourceVersion: "resource:sha256:dry-run-contract", + PolicyVersion: "policy:sha256:dry-run-contract", + PlanHash: "sha256:dry-run-contract", Preflight: &unifiedresources.ActionPreflight{ Target: "vm:42", CurrentState: "web-42 is warning", @@ -14292,7 +14321,7 @@ func TestContract_ActionDryRunOnlyExecutionErrorJSONSnapshot(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/actions/act_dry_run_contract/execute", bytes.NewBufferString(`{}`)) req.SetPathValue("id", "act_dry_run_contract") rec := httptest.NewRecorder() - h.HandleExecuteAction(rec, req) + h.HandleExecuteAction(rec, actionHandlerTestRequest(req, "")) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusConflict, rec.Body.String()) @@ -14357,7 +14386,7 @@ func TestContract_APIActionExecutionRevalidatesPlanFreshness(t *testing.T) { "if unified.IsPermanentActionExecutionRefusal(err)", "if err := s.ValidatePlanFresh(orgID, record); err != nil", "errors.Is(err, unified.ErrActionPlanDrift)", - "RecordRefusedExecution(store, record, actor, now, err)", + "RecordRefusedExecution(store, record, actorID, now, err)", "unified.RefuseActionExecution(record, reason, actor, now)", } { if !strings.Contains(src, snippet) { @@ -14365,7 +14394,7 @@ func TestContract_APIActionExecutionRevalidatesPlanFreshness(t *testing.T) { } } if strings.Index(src, "if err := s.ValidatePlanFresh(orgID, record); err != nil") > - strings.Index(src, "started, startEvent, err := unified.BeginActionExecution(record, actor, now)") { + strings.Index(src, "started, startEvent, err := unified.BeginActionExecution(record, actorID, now)") { t.Fatal("Execute must validate plan freshness before entering executing state or calling the executor") } @@ -14406,7 +14435,7 @@ func TestContract_ExecuteActionCapabilityDeclaresPlanExpired(t *testing.T) { func TestContract_ResourceTimelineEndpointsIncludeRelatedChanges(t *testing.T) { now := time.Date(2026, 4, 25, 22, 15, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unifiedresources.Resource{ @@ -14526,29 +14555,32 @@ func TestContract_UnifiedActionAuditsJSONSnapshot(t *testing.T) { }{ Audits: []unifiedresources.ActionAuditRecord{ { - ID: "action-1", - CreatedAt: now, - UpdatedAt: now, - State: unifiedresources.ActionStateCompleted, + ID: "action-1", + CreatedAt: now, + UpdatedAt: now, + State: unifiedresources.ActionStateCompleted, + DecisionRevision: 1, Request: unifiedresources.ActionRequest{ RequestID: "req-1", ResourceID: "vm:42", CapabilityName: "restart", Reason: "maintenance", RequestedBy: "agent:ops", + Actor: unifiedresources.ActionActor{SubjectID: "agent:ops", Kind: unifiedresources.ActionActorService, CredentialID: "service:contract", OrgID: "default"}, }, Plan: unifiedresources.ActionPlan{ - ActionID: "action-1", - RequestID: "req-1", - Allowed: true, - RequiresApproval: false, - ApprovalPolicy: unifiedresources.ApprovalNone, - RollbackAvailable: false, - PlannedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - ResourceVersion: "rv-1", - PolicyVersion: "pv-1", - PlanHash: "hash-1", + ActionID: "action-1", + RequestID: "req-1", + Allowed: true, + RequiresApproval: false, + ApprovalPolicy: unifiedresources.ApprovalNone, + ApprovalRequirement: unifiedresources.ApprovalRequirementForFloor(unifiedresources.ApprovalNone), + RollbackAvailable: false, + PlannedAt: now, + ExpiresAt: now.Add(5 * time.Minute), + ResourceVersion: "rv-1", + PolicyVersion: "pv-1", + PlanHash: "hash-1", Preflight: &unifiedresources.ActionPreflight{ Target: "vm:42", CurrentState: "online", @@ -14562,11 +14594,12 @@ func TestContract_UnifiedActionAuditsJSONSnapshot(t *testing.T) { }, Approvals: []unifiedresources.ActionApprovalRecord{ { - Actor: "admin@example.com", - Method: unifiedresources.MethodUI, - Timestamp: now.Add(time.Minute), - Outcome: unifiedresources.OutcomeApproved, - Reason: "approved", + Actor: "admin@example.com", + ActorBinding: unifiedresources.ActionActor{SubjectID: "admin@example.com", Kind: unifiedresources.ActionActorUser, CredentialID: "session:contract", OrgID: "default"}, + Method: unifiedresources.MethodUI, + Timestamp: now.Add(time.Minute), + Outcome: unifiedresources.OutcomeApproved, + Reason: "approved", }, }, Result: &unifiedresources.ExecutionResult{ @@ -14594,12 +14627,14 @@ func TestContract_UnifiedActionAuditsJSONSnapshot(t *testing.T) { "createdAt":"2026-03-18T16:00:00Z", "updatedAt":"2026-03-18T16:00:00Z", "state":"completed", + "decisionRevision":1, "request":{ "requestId":"req-1", "resourceId":"vm:42", "capabilityName":"restart", "reason":"maintenance", - "requestedBy":"agent:ops" + "requestedBy":"agent:ops", + "actor":{"subjectId":"agent:ops","kind":"service","credentialId":"service:contract","orgId":"default"} }, "plan":{ "actionId":"action-1", @@ -14607,6 +14642,7 @@ func TestContract_UnifiedActionAuditsJSONSnapshot(t *testing.T) { "allowed":true, "requiresApproval":false, "approvalPolicy":"none", + "approvalRequirement":{"version":1,"floor":"none","quorum":1,"disallowRequester":false}, "rollbackAvailable":false, "plannedAt":"2026-03-18T16:00:00Z", "expiresAt":"2026-03-18T16:05:00Z", @@ -14630,7 +14666,8 @@ func TestContract_UnifiedActionAuditsJSONSnapshot(t *testing.T) { "method":"ui", "timestamp":"2026-03-18T16:01:00Z", "outcome":"approved", - "reason":"approved" + "reason":"approved", + "actorBinding":{"subjectId":"admin@example.com","kind":"user","credentialId":"session:contract","orgId":"default"} } ], "result":{ @@ -14662,6 +14699,7 @@ func TestContract_UnifiedActionLifecycleEventsJSONSnapshot(t *testing.T) { ActionID: "action-1", Timestamp: now, State: unifiedresources.ActionStatePlanned, + Kind: unifiedresources.ActionLifecycleEventTransition, Actor: "system", Message: "planned", }, @@ -14681,6 +14719,7 @@ func TestContract_UnifiedActionLifecycleEventsJSONSnapshot(t *testing.T) { "actionId":"action-1", "timestamp":"2026-03-18T16:00:00Z", "state":"planned", + "kind":"transition", "actor":"system", "message":"planned" } @@ -16555,6 +16594,9 @@ func TestContract_AgentCapabilitiesRequiredScopeSummaryUsesManifestScopes(t *tes authpkg.ScopeSettingsRead, authpkg.ScopeSettingsWrite, authpkg.ScopeAIExecute, + authpkg.ScopeActionsPlan, + authpkg.ScopeActionsApprove, + authpkg.ScopeActionsExecute, } if !reflect.DeepEqual(got, want) { t.Fatalf("canonical agent capability scopes = %v, want %v", got, want) @@ -19719,16 +19761,24 @@ func TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations(t *testing.T) "AgentErrCodeInvalidActionRequest": agentcapabilities.AgentErrCodeInvalidActionRequest, "AgentErrCodeCapabilityNotFound": agentcapabilities.AgentErrCodeCapabilityNotFound, "AgentErrCodeActionExecutionUnavailable": agentcapabilities.AgentErrCodeActionExecutionUnavailable, + "AgentErrCodeActionActorUnavailable": agentcapabilities.AgentErrCodeActionActorUnavailable, "AgentErrCodeMissingID": agentcapabilities.AgentErrCodeMissingID, "AgentErrCodeInvalidID": agentcapabilities.AgentErrCodeInvalidID, "AgentErrCodeInvalidActionDecision": agentcapabilities.AgentErrCodeInvalidActionDecision, "AgentErrCodeActionNotFound": agentcapabilities.AgentErrCodeActionNotFound, "AgentErrCodeActionNotPending": agentcapabilities.AgentErrCodeActionNotPending, "AgentErrCodeActionPlanExpired": agentcapabilities.AgentErrCodeActionPlanExpired, + "AgentErrCodeActionApprovalForbidden": agentcapabilities.AgentErrCodeActionApprovalForbidden, + "AgentErrCodeActionStepUpUnavailable": agentcapabilities.AgentErrCodeActionStepUpUnavailable, + "AgentErrCodeActionDecisionConflict": agentcapabilities.AgentErrCodeActionDecisionConflict, + "AgentErrCodeActionSeparationRequired": agentcapabilities.AgentErrCodeActionSeparationRequired, + "AgentErrCodeActionReplanRequired": agentcapabilities.AgentErrCodeActionReplanRequired, "AgentErrCodeInvalidActionExecution": agentcapabilities.AgentErrCodeInvalidActionExecution, "AgentErrCodeActionNotApproved": agentcapabilities.AgentErrCodeActionNotApproved, "AgentErrCodeActionAlreadyExecuting": agentcapabilities.AgentErrCodeActionAlreadyExecuting, "AgentErrCodeActionExecutionFinal": agentcapabilities.AgentErrCodeActionExecutionFinal, + "AgentErrCodeActionExecutionForbidden": agentcapabilities.AgentErrCodeActionExecutionForbidden, + "AgentErrCodeActionNotExecuting": agentcapabilities.AgentErrCodeActionNotExecuting, "AgentErrCodeActionDryRunOnly": agentcapabilities.AgentErrCodeActionDryRunOnly, "AgentErrCodeActionPlanDrift": agentcapabilities.AgentErrCodeActionPlanDrift, "AgentErrCodeResourceRemediationLocked": agentcapabilities.AgentErrCodeResourceRemediationLocked, @@ -20172,7 +20222,7 @@ func TestContract_FindingsResourceOperatorStateProviderIsWired(t *testing.T) { // through body manipulation, defeating per-resource scoping. func TestContract_ResourceOperatorStateUrlCanonicalIDWinsOverBody(t *testing.T) { cfg := &config.Config{DataPath: t.TempDir()} - h := NewResourceHandlers(cfg) + h := newActionTestResourceHandlers(t, cfg) body := []byte(`{"canonicalId":"vm:999","intentionallyOffline":true}`) rec := httptest.NewRecorder() diff --git a/internal/api/docker_container_action_executor_test.go b/internal/api/docker_container_action_executor_test.go index e4f8af797..da6102a81 100644 --- a/internal/api/docker_container_action_executor_test.go +++ b/internal/api/docker_container_action_executor_test.go @@ -70,7 +70,7 @@ func dockerActionReadinessByName(readinesses []unified.ResourceActionReadiness, func TestDockerContainerActionExecutorDispatchesPodmanRestartAndVerification(t *testing.T) { now := time.Now().UTC() - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -115,7 +115,7 @@ func TestDockerContainerActionExecutorResolvesCommandAgentByDockerHostname(t *te resource := dockerContainerActionResource("app-container:api", "docker", "running", now) resource.Docker.AgentID = "docker-source-1" resource.Docker.Hostname = "tower" - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{resource}, @@ -165,7 +165,7 @@ func TestDockerContainerActionExecutorFailsWhenCapabilityNoLongerAdvertised(t *t now := time.Now().UTC() resource := dockerContainerActionResource("app-container:api", "docker", "running", now) resource.Capabilities = nil - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{resource}, @@ -185,7 +185,7 @@ func TestDockerContainerActionExecutorFailsWhenCapabilityNoLongerAdvertised(t *t func TestDockerContainerActionExecutorAvailabilityRequiresConnectedAgent(t *testing.T) { now := time.Now().UTC() resource := dockerContainerActionResource("app-container:api", "docker", "running", now) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{resource}, @@ -210,7 +210,7 @@ func TestDockerContainerActionExecutorAvailabilityRequiresConnectedAgent(t *test func TestHandlePlanActionRejectsDisconnectedDockerContainerAgent(t *testing.T) { now := time.Now().UTC() - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -229,7 +229,7 @@ func TestHandlePlanActionRejectsDisconnectedDockerContainerAgent(t *testing.T) { "reason":"operator requested restart", "requestedBy":"operator" }`)) - h.HandlePlanAction(rec, req) + h.HandlePlanAction(rec, actionHandlerTestRequest(req, "")) if rec.Code != http.StatusConflict { t.Fatalf("plan status = %d, want %d, body=%s", rec.Code, http.StatusConflict, rec.Body.String()) @@ -254,7 +254,7 @@ func TestHandlePlanActionRejectsDisconnectedDockerContainerAgent(t *testing.T) { func TestResourceResponsesFilterDisconnectedDockerLifecycleCapabilities(t *testing.T) { now := time.Now().UTC() - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -307,7 +307,7 @@ func TestResourceResponsesFilterDisconnectedDockerLifecycleCapabilities(t *testi func TestHandleExecuteActionRejectsNeverAutoRemediateBeforeExecutor(t *testing.T) { now := time.Date(2026, 5, 4, 14, 0, 0, 0, time.UTC) - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -342,7 +342,7 @@ func TestHandleExecuteActionRejectsNeverAutoRemediateBeforeExecutor(t *testing.T "reason":"Recover after confirmed outage", "requestedBy":"agent:oncall-helper" }`)) - h.HandlePlanAction(planRec, planReq) + h.HandlePlanAction(planRec, actionHandlerTestRequest(planReq, "")) if planRec.Code != http.StatusOK { t.Fatalf("plan status = %d, body=%s", planRec.Code, planRec.Body.String()) } @@ -355,7 +355,7 @@ func TestHandleExecuteActionRejectsNeverAutoRemediateBeforeExecutor(t *testing.T decisionReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+plan.ActionID+"/decision", bytes.NewBufferString(`{"outcome":"approved"}`)) decisionReq.SetPathValue("id", plan.ActionID) decisionReq = decisionReq.WithContext(auth.WithUser(decisionReq.Context(), "operator@example.com")) - h.HandleDecideAction(decisionRec, decisionReq) + h.HandleDecideAction(decisionRec, actionHandlerTestRequest(decisionReq, "")) if decisionRec.Code != http.StatusOK { t.Fatalf("decision status = %d, body=%s", decisionRec.Code, decisionRec.Body.String()) } @@ -377,7 +377,7 @@ func TestHandleExecuteActionRejectsNeverAutoRemediateBeforeExecutor(t *testing.T executeReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+plan.ActionID+"/execute", bytes.NewBufferString(`{}`)) executeReq.SetPathValue("id", plan.ActionID) executeReq = executeReq.WithContext(auth.WithUser(executeReq.Context(), "operator@example.com")) - h.HandleExecuteAction(executeRec, executeReq) + h.HandleExecuteAction(executeRec, actionHandlerTestRequest(executeReq, "")) if executeRec.Code != http.StatusConflict { t.Fatalf("execute status = %d, body=%s", executeRec.Code, executeRec.Body.String()) } diff --git a/internal/api/patrol_action_broker.go b/internal/api/patrol_action_broker.go index 38b1aa63f..f837cfede 100644 --- a/internal/api/patrol_action_broker.go +++ b/internal/api/patrol_action_broker.go @@ -137,6 +137,12 @@ func (b *patrolActionBroker) Submit(ctx context.Context, proposal aicontracts.Ac Reason: proposal.Reason, RequestedBy: patrolActionBrokerActor, }, actionlifecycle.PlanOptions{ + Actor: unified.ActionActor{ + SubjectID: patrolActionBrokerActor, + Kind: unified.ActionActorService, + CredentialID: "service:patrol-action-broker", + OrgID: b.orgID, + }, Origin: &unified.ActionOrigin{ Surface: patrolActionOriginSurface, FindingID: proposal.FindingID, diff --git a/internal/api/patrol_action_broker_test.go b/internal/api/patrol_action_broker_test.go index 2426cea9d..23f63f3a5 100644 --- a/internal/api/patrol_action_broker_test.go +++ b/internal/api/patrol_action_broker_test.go @@ -127,7 +127,7 @@ func newPatrolBrokerTestHandlers(t *testing.T, minimumApproval unified.ActionApp func newPatrolBrokerTestHandlersWithEligibility(t *testing.T, minimumApproval unified.ActionApprovalLevel, eligibility unified.ActionAutoAuthorizationClass) (*ResourceHandlers, *stubActionExecutor) { t.Helper() now := time.Now().UTC() - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -565,7 +565,7 @@ func TestPatrolTypedActionJourneyDetectPlanApproveExecuteVerifyAndReconcile(t *t decisionReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+disposition.ActionID+"/decision", bytes.NewBufferString(`{"outcome":"approved","reason":"maintenance window"}`)) decisionReq.SetPathValue("id", disposition.ActionID) decisionReq = decisionReq.WithContext(auth.WithUser(decisionReq.Context(), "operator@example.com")) - resources.HandleDecideAction(decisionRec, decisionReq) + resources.HandleDecideAction(decisionRec, actionHandlerTestRequest(decisionReq, "")) if decisionRec.Code != http.StatusOK { t.Fatalf("decision status = %d body=%s", decisionRec.Code, decisionRec.Body.String()) } @@ -574,7 +574,7 @@ func TestPatrolTypedActionJourneyDetectPlanApproveExecuteVerifyAndReconcile(t *t executionReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+disposition.ActionID+"/execute", bytes.NewBufferString(`{"reason":"approved maintenance window"}`)) executionReq.SetPathValue("id", disposition.ActionID) executionReq = executionReq.WithContext(auth.WithUser(executionReq.Context(), "operator@example.com")) - resources.HandleExecuteAction(executionRec, executionReq) + resources.HandleExecuteAction(executionRec, actionHandlerTestRequest(executionReq, "")) if executionRec.Code != http.StatusOK { t.Fatalf("execution status = %d body=%s", executionRec.Code, executionRec.Body.String()) } diff --git a/internal/api/proxmox_guest_action_executor_test.go b/internal/api/proxmox_guest_action_executor_test.go index f7665d329..15a668695 100644 --- a/internal/api/proxmox_guest_action_executor_test.go +++ b/internal/api/proxmox_guest_action_executor_test.go @@ -18,7 +18,7 @@ import ( func TestProxmoxGuestActionExecutorDispatchesVMShutdownAndVerification(t *testing.T) { now := time.Now().UTC() - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -62,7 +62,7 @@ func TestProxmoxGuestActionExecutorDispatchesVMShutdownAndVerification(t *testin func TestProxmoxGuestActionExecutorDispatchesLXCStartAndVerification(t *testing.T) { now := time.Now().UTC() - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -94,7 +94,7 @@ func TestProxmoxGuestActionExecutorResolvesCommandAgentByNodeHostname(t *testing now := time.Now().UTC() resource := proxmoxGuestActionResource("vm:160", unified.ResourceTypeVM, "running", now) resource.Proxmox.LinkedAgentID = "stale-agent" - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{resource}, @@ -139,7 +139,7 @@ func TestProxmoxGuestActionExecutorResolvesCommandAgentByNodeHostname(t *testing func TestProxmoxGuestActionExecutorVerificationFailureFailsAction(t *testing.T) { now := time.Now().UTC() - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -170,7 +170,7 @@ func TestProxmoxGuestActionExecutorVerificationFailureFailsAction(t *testing.T) func TestHandlePlanActionRejectsDisconnectedProxmoxNodeCommandAgent(t *testing.T) { now := time.Now().UTC() - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ @@ -192,7 +192,7 @@ func TestHandlePlanActionRejectsDisconnectedProxmoxNodeCommandAgent(t *testing.T "reason":"operator requested reboot", "requestedBy":"operator" }`)) - h.HandlePlanAction(rec, req) + h.HandlePlanAction(rec, actionHandlerTestRequest(req, "")) if rec.Code != http.StatusConflict { t.Fatalf("plan status = %d, want %d, body=%s", rec.Code, http.StatusConflict, rec.Body.String()) @@ -217,7 +217,7 @@ func TestHandlePlanActionRejectsDisconnectedProxmoxNodeCommandAgent(t *testing.T func TestResourceResponsesFilterDisconnectedProxmoxLifecycleCapabilities(t *testing.T) { now := time.Now().UTC() - h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{ diff --git a/internal/api/relay_mobile_capability.go b/internal/api/relay_mobile_capability.go index ee85158ff..10f5cfbc8 100644 --- a/internal/api/relay_mobile_capability.go +++ b/internal/api/relay_mobile_capability.go @@ -14,6 +14,7 @@ type relayMobileRuntimeRouteSpec struct { method string path string requiredScope string + legacyScope string } const ( @@ -147,31 +148,36 @@ var relayMobileRuntimeRouteSpecs = map[relayMobileRuntimeRouteID]relayMobileRunt id: relayMobileRoutePendingActions, method: http.MethodGet, path: "/api/actions/pending", - requiredScope: config.ScopeAIExecute, + requiredScope: config.ScopeActionsApprove, + legacyScope: config.ScopeAIExecute, }, relayMobileRouteActionsList: { id: relayMobileRouteActionsList, method: http.MethodGet, path: "/api/actions", - requiredScope: config.ScopeAIExecute, + requiredScope: config.ScopeActionsApprove, + legacyScope: config.ScopeAIExecute, }, relayMobileRouteActionDetail: { id: relayMobileRouteActionDetail, method: http.MethodGet, path: "/api/actions/{action_id}", - requiredScope: config.ScopeAIExecute, + requiredScope: config.ScopeActionsApprove, + legacyScope: config.ScopeAIExecute, }, relayMobileRouteActionDecision: { id: relayMobileRouteActionDecision, method: http.MethodPost, path: "/api/actions/{action_id}/decision", - requiredScope: config.ScopeAIExecute, + requiredScope: config.ScopeActionsApprove, + legacyScope: config.ScopeAIExecute, }, relayMobileRouteActionExecute: { id: relayMobileRouteActionExecute, method: http.MethodPost, path: "/api/actions/{action_id}/execute", - requiredScope: config.ScopeAIExecute, + requiredScope: config.ScopeActionsExecute, + legacyScope: config.ScopeAIExecute, }, relayMobileRouteChatSend: { id: relayMobileRouteChatSend, @@ -234,7 +240,11 @@ func relayMobileRuntimeRouteSpecFor(routeID relayMobileRuntimeRouteID) relayMobi } func (spec relayMobileRuntimeRouteSpec) compatibleScopes() []string { - return []string{config.ScopeRelayMobileAccess, spec.requiredScope} + scopes := []string{config.ScopeRelayMobileAccess, spec.requiredScope} + if spec.legacyScope != "" && spec.legacyScope != spec.requiredScope { + scopes = append(scopes, spec.legacyScope) + } + return scopes } func requireRelayMobileRuntimeRoute(routeID relayMobileRuntimeRouteID, handler http.HandlerFunc) http.HandlerFunc { diff --git a/internal/api/relay_mobile_capability_test.go b/internal/api/relay_mobile_capability_test.go index 1d8e63dfc..39b52d6fc 100644 --- a/internal/api/relay_mobile_capability_test.go +++ b/internal/api/relay_mobile_capability_test.go @@ -20,14 +20,21 @@ func TestRelayMobileRuntimeRouteInventory(t *testing.T) { seenIDs[spec.id] = struct{}{} scopes := spec.compatibleScopes() - if len(scopes) != 2 { - t.Fatalf("compatible scopes for %q = %d, want 2", spec.id, len(scopes)) + wantLen := 2 + if spec.legacyScope != "" && spec.legacyScope != spec.requiredScope { + wantLen++ + } + if len(scopes) != wantLen { + t.Fatalf("compatible scopes for %q = %d, want %d", spec.id, len(scopes), wantLen) } if scopes[0] != config.ScopeRelayMobileAccess { t.Fatalf("compatible scopes for %q start with %q, want %q", spec.id, scopes[0], config.ScopeRelayMobileAccess) } if scopes[1] != spec.requiredScope { - t.Fatalf("compatible scopes for %q end with %q, want %q", spec.id, scopes[1], spec.requiredScope) + t.Fatalf("compatible scopes for %q primary scope = %q, want %q", spec.id, scopes[1], spec.requiredScope) + } + if wantLen == 3 && scopes[2] != spec.legacyScope { + t.Fatalf("compatible scopes for %q legacy scope = %q, want %q", spec.id, scopes[2], spec.legacyScope) } route := fmt.Sprintf("%s %s => %s", spec.method, spec.path, spec.requiredScope) @@ -51,11 +58,11 @@ func TestRelayMobileRuntimeRouteInventory(t *testing.T) { "GET /api/ai/approvals => ai:execute", "POST /api/ai/approvals/{approval_id}/approve => ai:execute", "POST /api/ai/approvals/{approval_id}/deny => ai:execute", - "GET /api/actions/pending => ai:execute", - "GET /api/actions => ai:execute", - "GET /api/actions/{action_id} => ai:execute", - "POST /api/actions/{action_id}/decision => ai:execute", - "POST /api/actions/{action_id}/execute => ai:execute", + "GET /api/actions/pending => actions:approve", + "GET /api/actions => actions:approve", + "GET /api/actions/{action_id} => actions:approve", + "POST /api/actions/{action_id}/decision => actions:approve", + "POST /api/actions/{action_id}/execute => actions:execute", "POST /api/ai/chat => ai:chat", "GET /api/ai/sessions => ai:chat", "POST /api/ai/sessions => ai:chat", diff --git a/internal/api/resources.go b/internal/api/resources.go index 96f4fdf36..344e84bb5 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -23,21 +23,23 @@ import ( // ResourceHandlers provides HTTP handlers for the unified resource API. type ResourceHandlers struct { - cfg *config.Config - storeMu sync.Mutex - stores map[string]unified.ResourceStore - cacheMu sync.Mutex - registryCache map[string]registryCacheEntry - supplementalMu sync.RWMutex - supplementalRecords map[unified.DataSource]SupplementalRecordsProvider - stateProvider SnapshotProvider - tenantStateProvider TenantStateProvider - actionExecutor ActionExecutor - actionCompleted func(unified.ActionAuditRecord) - actionTransition func(orgID string, record unified.ActionAuditRecord) - policyAdmission *actionlifecycle.PolicyAdmissionCoordinator - actionEmergencyStop func(orgID string) (bool, error) - discoveryReadiness ResourceDiscoveryReadinessProvider + cfg *config.Config + storeMu sync.Mutex + stores map[string]unified.ResourceStore + cacheMu sync.Mutex + registryCache map[string]registryCacheEntry + supplementalMu sync.RWMutex + supplementalRecords map[unified.DataSource]SupplementalRecordsProvider + stateProvider SnapshotProvider + tenantStateProvider TenantStateProvider + actionExecutor ActionExecutor + actionCompleted func(unified.ActionAuditRecord) + actionTransition func(orgID string, record unified.ActionAuditRecord) + policyAdmission *actionlifecycle.PolicyAdmissionCoordinator + actionEmergencyStop func(orgID string) (bool, error) + actionDecisionAuthorizer actionlifecycle.DecisionAuthorizer + actionExecutionAuthorizer actionlifecycle.ExecutionAuthorizer + discoveryReadiness ResourceDiscoveryReadinessProvider } // ResourceDiscoveryReadinessProvider projects service-discovery state onto a @@ -111,6 +113,11 @@ func (h *ResourceHandlers) SetActionEmergencyStopChecker(checker func(orgID stri h.actionEmergencyStop = checker } +func (h *ResourceHandlers) SetActionAuthorizers(decision actionlifecycle.DecisionAuthorizer, execution actionlifecycle.ExecutionAuthorizer) { + h.actionDecisionAuthorizer = decision + h.actionExecutionAuthorizer = execution +} + // SetActionCompletedPublisher configures the terminal action notification hook // used by the agent SSE bridge. It is intentionally outside the execution // driver so refused-before-dispatch failures and future executor diff --git a/internal/api/router.go b/internal/api/router.go index bfaa3bc97..75f23a018 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -488,6 +488,9 @@ func (r *Router) setupRoutes() { r.unifiedAgentHandlers = NewUnifiedAgentHandlers(r.mtMonitor, r.monitor, r.wsHub) r.kubernetesAgentHandlers.SetRecoveryIngestor(r.recoveryHandlers) r.resourceHandlers = NewResourceHandlers(r.config) + actionOrgChecker := NewAuthorizationChecker(NewMultiTenantOrganizationLoader(r.multiTenant)) + actionAuth := actionAuthority{authorizer: r.authorizer, orgChecker: actionOrgChecker} + r.resourceHandlers.SetActionAuthorizers(actionAuth, actionAuth) r.maintenanceSentinel = r.buildMaintenanceVerificationSentinel() r.maintenanceVerificationHandlers = NewMaintenanceVerificationHandlers(r.resourceHandlers, r.maintenanceSentinel) if r.maintenanceSentinel != nil { diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index dd4ce614e..c5bdff68b 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -6,6 +6,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) func (r *Router) registerMonitoringResourceRoutes( @@ -136,26 +137,26 @@ func (r *Router) registerMonitoringResourceRoutes( r.agentEventBroadcaster.HandleAgentEvents, )))) } - r.mux.HandleFunc("POST /api/actions/plan", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.withExternalAgentCapabilityActivity( + r.mux.HandleFunc("POST /api/actions/plan", RequireAuth(r.config, RequireAnyScope([]string{config.ScopeActionsPlan, config.ScopeAIExecute}, requireActionCapability(r.authorizer, auth.ActionPlan, r.withExternalAgentCapabilityActivity( agentcapabilities.PlanActionCapabilityName, r.resourceHandlers.HandlePlanAction, - )))) + ))))) r.mux.HandleFunc("GET /api/actions/pending", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRoutePendingActions, - r.resourceHandlers.HandleListPendingActions, + requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleListPendingActions), ))) r.mux.HandleFunc("GET /api/actions", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionsList, - r.resourceHandlers.HandleListActions, + requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleListActions), ))) r.mux.HandleFunc("GET /api/actions/{id}", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionDetail, - r.resourceHandlers.HandleGetAction, + requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleGetAction), ))) r.mux.HandleFunc("POST /api/actions/{id}/decision", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionDecision, r.withExternalAgentCapabilityActivity( agentcapabilities.DecideActionCapabilityName, - r.resourceHandlers.HandleDecideAction, + requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleDecideAction), )))) r.mux.HandleFunc("POST /api/actions/{id}/execute", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionExecute, r.withExternalAgentCapabilityActivity( agentcapabilities.ExecuteActionCapabilityName, - r.resourceHandlers.HandleExecuteAction, + requireActionCapability(r.authorizer, auth.ActionExecute, r.resourceHandlers.HandleExecuteAction), )))) // Guest metadata routes r.mux.HandleFunc("/api/guests/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, guestMetadataHandler.HandleGetMetadata))) diff --git a/internal/api/telemetry_pulse_intelligence_test.go b/internal/api/telemetry_pulse_intelligence_test.go index 7f95eec41..f0970b7d1 100644 --- a/internal/api/telemetry_pulse_intelligence_test.go +++ b/internal/api/telemetry_pulse_intelligence_test.go @@ -165,11 +165,8 @@ func TestGetPulseIntelligenceActionTelemetry_CountsApprovedLifecycleAttemptsInsi if err := store.RecordActionAudit(oldRejected); err != nil { t.Fatalf("RecordActionAudit(oldRejected): %v", err) } - rejected, rejectedEvent, err := unifiedresources.ApplyActionDecision(oldRejected, unifiedresources.ActionApprovalRecord{ - Outcome: unifiedresources.OutcomeRejected, - Method: unifiedresources.MethodAPI, - Actor: "operator", - }, now.Add(-10*time.Minute)) + rejectedAt := now.Add(-10 * time.Minute) + rejected, rejectedEvent, err := unifiedresources.ApplyActionDecision(oldRejected, boundActionTestDecisionApproval(oldRejected.ID, oldRejected.Plan.PlanHash, "operator", unifiedresources.OutcomeRejected, rejectedAt), rejectedAt) if err != nil { t.Fatalf("ApplyActionDecision(oldRejected): %v", err) } @@ -252,6 +249,10 @@ func pulseTelemetryActionRecord( requiresApproval bool, approvals []unifiedresources.ActionApprovalRecord, ) unifiedresources.ActionAuditRecord { + approvalPolicy := unifiedresources.ApprovalNone + if requiresApproval { + approvalPolicy = unifiedresources.ApprovalAdmin + } return unifiedresources.ActionAuditRecord{ ID: id, CreatedAt: createdAt, @@ -263,14 +264,18 @@ func pulseTelemetryActionRecord( CapabilityName: "pulse_exec", Reason: "test", RequestedBy: "pulse_assistant", + Actor: unifiedresources.ActionActor{SubjectID: "pulse_assistant", Kind: unifiedresources.ActionActorService, CredentialID: "service:test", OrgID: "default"}, }, Plan: unifiedresources.ActionPlan{ - ActionID: id, - RequestID: "req-" + id, - Allowed: true, - RequiresApproval: requiresApproval, - PlannedAt: createdAt, - ExpiresAt: createdAt.Add(time.Hour), + ActionID: id, + RequestID: "req-" + id, + Allowed: true, + RequiresApproval: requiresApproval, + ApprovalPolicy: approvalPolicy, + ApprovalRequirement: unifiedresources.ApprovalRequirementForFloor(approvalPolicy), + PlannedAt: createdAt, + ExpiresAt: createdAt.Add(time.Hour), + PlanHash: "sha256:" + id, }, Approvals: approvals, } diff --git a/internal/config/api_tokens.go b/internal/config/api_tokens.go index bea32eb31..4b84f06ef 100644 --- a/internal/config/api_tokens.go +++ b/internal/config/api_tokens.go @@ -30,6 +30,9 @@ const ( ScopeRelayMobileAccess = auth.ScopeRelayMobileAccess ScopeAgentExec = auth.ScopeAgentExec ScopeAgentEnroll = auth.ScopeAgentEnroll + ScopeActionsPlan = auth.ScopeActionsPlan + ScopeActionsApprove = auth.ScopeActionsApprove + ScopeActionsExecute = auth.ScopeActionsExecute ) // AllKnownScopes enumerates scopes recognized by the backend (excluding the wildcard sentinel). diff --git a/internal/unifiedresources/action_dispatch_store.go b/internal/unifiedresources/action_dispatch_store.go index 0839bc83d..e82e113d7 100644 --- a/internal/unifiedresources/action_dispatch_store.go +++ b/internal/unifiedresources/action_dispatch_store.go @@ -169,7 +169,7 @@ func (s *SQLiteResourceStore) RecordActionExpiry(record ActionAuditRecord, event if record.State != ActionStateExpired || event.State != ActionStateExpired || event.ActionID != record.ID { return errors.New("action expiry must persist matching expired state") } - return s.recordActionTransition(record, event, []ActionState{ActionStatePlanned, ActionStatePending, ActionStateApproved}, ErrActionExecutionFinal) + return s.recordActionTransition(record, event, nil, []ActionState{ActionStatePlanned, ActionStatePending, ActionStateApproved}, ErrActionExecutionFinal, nil) } func (s *SQLiteResourceStore) GetActionDispatchAttempt(actionID string) (ActionDispatchAttempt, bool, error) { @@ -491,7 +491,7 @@ func (s *SQLiteResourceStore) ExpireActionAudits(now time.Time, limit int) ([]Ac if limit <= 0 || limit > 500 { limit = 100 } - rows, err := s.db.Query(`SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json FROM action_audits WHERE state IN (?, ?, ?) ORDER BY updated_at ASC LIMIT ?`, ActionStatePlanned, ActionStatePending, ActionStateApproved, limit) + rows, err := s.db.Query(`SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json FROM action_audits WHERE state IN (?, ?, ?) ORDER BY updated_at ASC LIMIT ?`, ActionStatePlanned, ActionStatePending, ActionStateApproved, limit) if err != nil { return nil, err } @@ -541,7 +541,7 @@ func (s *SQLiteResourceStore) GetActionAuditsByStates(states []ActionState, limi args = append(args, string(state)) } args = append(args, limit) - rows, err := s.db.Query(`SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json FROM action_audits WHERE state IN (`+strings.Join(marks, ",")+`) ORDER BY updated_at DESC, created_at DESC LIMIT ?`, args...) + rows, err := s.db.Query(`SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json FROM action_audits WHERE state IN (`+strings.Join(marks, ",")+`) ORDER BY updated_at DESC, created_at DESC LIMIT ?`, args...) if err != nil { return nil, err } diff --git a/internal/unifiedresources/actions.go b/internal/unifiedresources/actions.go index bbfadd800..d37e51186 100644 --- a/internal/unifiedresources/actions.go +++ b/internal/unifiedresources/actions.go @@ -31,7 +31,118 @@ type ActionRequest struct { CapabilityName string `json:"capabilityName"` Params map[string]any `json:"params,omitempty"` Reason string `json:"reason"` - RequestedBy string `json:"requestedBy"` // e.g., "agent:oncall-helper" + // RequestedBy remains on the wire for compatibility and presentation, but + // canonical planning derives it from Actor.SubjectID. Public callers never + // own this value. + RequestedBy string `json:"requestedBy"` + Actor ActionActor `json:"actor"` +} + +// ActionActor is the immutable server-owned identity bound to a governed +// action. SubjectID is the durable principal; CredentialID identifies the +// authenticated credential without persisting a secret. +type ActionActor struct { + SubjectID string `json:"subjectId"` + Kind ActionActorKind `json:"kind"` + CredentialID string `json:"credentialId"` + OrgID string `json:"orgId"` +} + +type ActionActorKind string + +const ( + ActionActorUser ActionActorKind = "user" + ActionActorAPIToken ActionActorKind = "api_token" + ActionActorService ActionActorKind = "service" + ActionActorPolicy ActionActorKind = "policy" +) + +func NormalizeActionActor(actor ActionActor) ActionActor { + actor.SubjectID = strings.TrimSpace(actor.SubjectID) + actor.CredentialID = strings.TrimSpace(actor.CredentialID) + actor.OrgID = strings.TrimSpace(actor.OrgID) + return actor +} + +func ValidateActionActor(actor ActionActor) error { + actor = NormalizeActionActor(actor) + if actor.SubjectID == "" || actor.CredentialID == "" || actor.OrgID == "" { + return errors.New("action actor subject, credential, and organization are required") + } + switch actor.Kind { + case ActionActorUser, ActionActorAPIToken, ActionActorService, ActionActorPolicy: + return nil + default: + return fmt.Errorf("unsupported action actor kind %q", actor.Kind) + } +} + +func ActionActorsEqual(left, right ActionActor) bool { + left = NormalizeActionActor(left) + right = NormalizeActionActor(right) + return left == right +} + +const ActionApprovalRequirementVersion = 1 + +// ApprovalRequirement is the capability-owned approval floor captured at +// planning time. Tenant policy may strengthen this structure in a future +// resolver, but it must never lower Floor. +type ApprovalRequirement struct { + Version int `json:"version"` + Floor ActionApprovalLevel `json:"floor"` + Quorum int `json:"quorum"` + DisallowRequester bool `json:"disallowRequester"` +} + +func ApprovalRequirementForFloor(floor ActionApprovalLevel) ApprovalRequirement { + return ApprovalRequirement{ + Version: ActionApprovalRequirementVersion, + Floor: floor, + Quorum: 1, + } +} + +func NormalizeApprovalRequirement(requirement ApprovalRequirement, legacyFloor ActionApprovalLevel) ApprovalRequirement { + if requirement.Floor == "" { + requirement.Floor = legacyFloor + } + if requirement.Quorum < 1 { + requirement.Quorum = 1 + } + return requirement +} + +func ValidateApprovalRequirement(requirement ApprovalRequirement, capabilityFloor ActionApprovalLevel) error { + requirement = NormalizeApprovalRequirement(requirement, capabilityFloor) + if requirement.Version != ActionApprovalRequirementVersion || requirement.Quorum < 1 { + return errors.New("approval requirement version and quorum are invalid") + } + if capabilityFloor == ApprovalDryRun { + if requirement.Floor != ApprovalDryRun { + return errors.New("dry-run-only capability floor cannot be lowered") + } + return nil + } + if requirement.Floor == ApprovalDryRun { + return nil + } + rank := func(level ActionApprovalLevel) int { + switch level { + case ApprovalNone: + return 0 + case ApprovalAdmin: + return 1 + case ApprovalMultiFactor: + return 2 + default: + return -1 + } + } + if rank(requirement.Floor) < rank(capabilityFloor) || rank(requirement.Floor) < 0 { + return errors.New("approval requirement cannot lower the capability floor") + } + return nil } // ApprovalOutcome represents the decision on a requested capability. @@ -50,15 +161,47 @@ const ( MethodAPI ApprovalMethod = "api" MethodMFAChallenge ApprovalMethod = "mfa_challenge" MethodPolicy ApprovalMethod = "policy" + MethodSession ApprovalMethod = "session" + MethodAPIToken ApprovalMethod = "api_token" + MethodWebAuthnUV ApprovalMethod = "webauthn_uv" + MethodDeviceKeyUV ApprovalMethod = "device_key_uv" ) +// ApprovalEvidence is the server-checked decision binding. Cryptographic +// methods are never trusted from this structure alone: actionlifecycle calls +// its installed verifier to validate and atomically consume the challenge. +type ApprovalEvidence struct { + Version int `json:"version"` + Method ApprovalMethod `json:"method"` + Actor ActionActor `json:"actor"` + OrgID string `json:"orgId"` + ActionID string `json:"actionId"` + PlanHash string `json:"planHash"` + Outcome ApprovalOutcome `json:"outcome"` + ChallengeID string `json:"challengeId,omitempty"` + IssuedAt time.Time `json:"issuedAt"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` +} + +// ActionDecision is the transport-independent input to the canonical human +// decision boundary. Actor and Evidence are supplied by a trusted adapter, +// never decoded as public identity authority. +type ActionDecision struct { + Actor ActionActor `json:"actor"` + Outcome ApprovalOutcome `json:"outcome"` + Reason string `json:"reason,omitempty"` + Evidence ApprovalEvidence `json:"evidence"` +} + // ActionApprovalRecord captures a specific approval or rejection event. type ActionApprovalRecord struct { - Actor string `json:"actor"` // Who approved/rejected it - Method ApprovalMethod `json:"method"` // e.g. "ui", "api", "mfa_challenge" - Timestamp time.Time `json:"timestamp"` // When the decision was made - Outcome ApprovalOutcome `json:"outcome"` // "approved" or "rejected" - Reason string `json:"reason,omitempty"` + Actor string `json:"actor"` // Who approved/rejected it + Method ApprovalMethod `json:"method"` // e.g. "ui", "api", "mfa_challenge" + Timestamp time.Time `json:"timestamp"` // When the decision was made + Outcome ApprovalOutcome `json:"outcome"` // "approved" or "rejected" + Reason string `json:"reason,omitempty"` + ActorBinding ActionActor `json:"actorBinding,omitempty"` + Evidence *ApprovalEvidence `json:"evidence,omitempty"` // PolicyLease is present only for a server-owned policy authorization. // Human decisions never carry this field. Policy approval and execution // admission are persisted atomically, so the lease cannot become reusable. @@ -144,6 +287,7 @@ type ActionPlan struct { Allowed bool `json:"allowed"` RequiresApproval bool `json:"requiresApproval"` ApprovalPolicy ActionApprovalLevel `json:"approvalPolicy"` + ApprovalRequirement ApprovalRequirement `json:"approvalRequirement"` PredictedBlastRadius []string `json:"predictedBlastRadius,omitempty"` // Correlated related resources RollbackAvailable bool `json:"rollbackAvailable"` Message string `json:"message,omitempty"` @@ -307,6 +451,7 @@ type ActionAuditRecord struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` State ActionState `json:"state"` + DecisionRevision uint64 `json:"decisionRevision"` Request ActionRequest `json:"request"` Plan ActionPlan `json:"plan"` Origin *ActionOrigin `json:"origin,omitempty"` @@ -346,13 +491,26 @@ func NormalizeActionOrigin(origin *ActionOrigin) *ActionOrigin { return &normalized } -// ActionLifecycleEvent represents an append-only state transition in an action's life. +type ActionLifecycleEventKind string + +const ( + ActionLifecycleEventTransition ActionLifecycleEventKind = "transition" + ActionLifecycleEventDecision ActionLifecycleEventKind = "decision" + ActionLifecycleEventLegacy ActionLifecycleEventKind = "legacy" +) + +// ActionLifecycleEvent is an append-only action audit fact. State transitions +// remain unique by action/state; human decisions are independently unique by +// action/decision revision and carry their complete server-bound approval. type ActionLifecycleEvent struct { - ActionID string `json:"actionId"` - Timestamp time.Time `json:"timestamp"` - State ActionState `json:"state"` - Actor string `json:"actor,omitempty"` - Message string `json:"message,omitempty"` + ActionID string `json:"actionId"` + Timestamp time.Time `json:"timestamp"` + State ActionState `json:"state"` + Kind ActionLifecycleEventKind `json:"kind"` + DecisionRevision uint64 `json:"decisionRevision,omitempty"` + Decision *ActionApprovalRecord `json:"decision,omitempty"` + Actor string `json:"actor,omitempty"` + Message string `json:"message,omitempty"` } // ActionEngine defines the enforced runtime loop for capabilities. @@ -389,10 +547,13 @@ var ( ErrInvalidApprovalOutcome = errors.New("invalid approval outcome") ErrActionAuditAlreadyExists = errors.New("action audit already exists") ErrActionIdentityConflict = errors.New("action audit identity conflicts with the persisted record") + ErrActionDecisionRevisionConflict = errors.New("action decision revision conflicts with the persisted record") ErrActionPolicyAuthorizationInvalid = errors.New("policy_authorization_invalid") ErrActionPolicyAuthorizationExpired = errors.New("policy_authorization_expired") ErrActionPolicyAuthorizationRevoked = errors.New("policy_authorization_revoked") ErrActionEmergencyStop = errors.New("action_emergency_stop") + ErrActionReplanRequired = errors.New("action_replan_required") + ErrDuplicateApprovalActor = errors.New("duplicate approval actor") ) // BeginPolicyActionExecution atomically composes the policy approval and @@ -461,6 +622,10 @@ func ApplyActionDecision(record ActionAuditRecord, approval ActionApprovalRecord } else { now = now.UTC() } + approval.ActorBinding = NormalizeActionActor(approval.ActorBinding) + if approval.ActorBinding.SubjectID != "" { + approval.Actor = approval.ActorBinding.SubjectID + } approval.Actor = strings.TrimSpace(approval.Actor) approval.Reason = strings.TrimSpace(approval.Reason) if approval.Method == "" { @@ -472,12 +637,31 @@ func ApplyActionDecision(record ActionAuditRecord, approval ActionApprovalRecord approval.Timestamp = approval.Timestamp.UTC() } + for _, existing := range record.Approvals { + if approval.Actor != "" && strings.EqualFold(strings.TrimSpace(existing.Actor), approval.Actor) { + return ActionAuditRecord{}, ActionLifecycleEvent{}, ErrDuplicateApprovalActor + } + } + var nextState ActionState var message string switch approval.Outcome { case OutcomeApproved: - nextState = ActionStateApproved - message = "Action approved. Execution remains pending a separate execution contract." + requirement := NormalizeApprovalRequirement(record.Plan.ApprovalRequirement, record.Plan.ApprovalPolicy) + approvedActors := map[string]struct{}{} + for _, existing := range record.Approvals { + if existing.Outcome == OutcomeApproved && strings.TrimSpace(existing.Actor) != "" { + approvedActors[strings.ToLower(strings.TrimSpace(existing.Actor))] = struct{}{} + } + } + approvedActors[strings.ToLower(approval.Actor)] = struct{}{} + if len(approvedActors) < requirement.Quorum { + nextState = ActionStatePending + message = fmt.Sprintf("Approval recorded; %d of %d distinct approvals collected.", len(approvedActors), requirement.Quorum) + } else { + nextState = ActionStateApproved + message = "Action approved. Execution remains pending a separate execution contract." + } case OutcomeRejected: nextState = ActionStateRejected message = "Action rejected before execution." @@ -494,16 +678,20 @@ func ApplyActionDecision(record ActionAuditRecord, approval ActionApprovalRecord record.State = nextState record.UpdatedAt = approval.Timestamp record.Approvals = append(record.Approvals, approval) + record.DecisionRevision++ normalized, err := NormalizeActionAuditRecord(record) if err != nil { return ActionAuditRecord{}, ActionLifecycleEvent{}, err } event := ActionLifecycleEvent{ - ActionID: normalized.ID, - Timestamp: approval.Timestamp, - State: nextState, - Actor: approval.Actor, - Message: message, + ActionID: normalized.ID, + Timestamp: approval.Timestamp, + State: nextState, + Kind: ActionLifecycleEventDecision, + DecisionRevision: normalized.DecisionRevision, + Decision: &approval, + Actor: approval.Actor, + Message: message, } normalizedEvent, err := NormalizeActionLifecycleEvent(event) if err != nil { @@ -512,6 +700,154 @@ func ApplyActionDecision(record ActionAuditRecord, approval ActionApprovalRecord return normalized, normalizedEvent, nil } +// ActionDecisionAppendCommand is the canonical pure store command for one +// append-only human decision. Both persistence implementations validate and +// execute this same command before applying their atomic CAS mechanics. +type ActionDecisionAppendCommand struct { + Record ActionAuditRecord + DecisionEvent ActionLifecycleEvent + TransitionEvent *ActionLifecycleEvent +} + +// PrepareActionDecisionAppend validates an append against the authoritative +// current audit and derives the optional lifecycle transition. It validates +// captured evidence structure and policy-floor compatibility, but deliberately +// does not perform cryptographic verification or current membership/RBAC. +func PrepareActionDecisionAppend(current, proposed ActionAuditRecord, event ActionLifecycleEvent) (ActionDecisionAppendCommand, error) { + current, err := NormalizeActionAuditRecord(current) + if err != nil { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + proposed, err = NormalizeActionAuditRecord(proposed) + if err != nil { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + event, err = NormalizeActionLifecycleEvent(event) + if err != nil { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + if current.State != ActionStatePending || !ActionAuditIdentityMatches(current, proposed) || + current.DecisionRevision != uint64(len(current.Approvals)) || + proposed.DecisionRevision != current.DecisionRevision+1 || + len(proposed.Approvals) != len(current.Approvals)+1 || + proposed.DecisionRevision != uint64(len(proposed.Approvals)) || event.Decision == nil || + event.ActionID != proposed.ID || event.State != proposed.State || event.DecisionRevision != proposed.DecisionRevision { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + for index := range current.Approvals { + if !canonicalActionIdentityJSONEqual(current.Approvals[index], proposed.Approvals[index]) { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + } + if err := ValidateActionActor(proposed.Request.Actor); err != nil { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + requirement := NormalizeApprovalRequirement(proposed.Plan.ApprovalRequirement, proposed.Plan.ApprovalPolicy) + if err := ValidateApprovalRequirement(requirement, proposed.Plan.ApprovalPolicy); err != nil { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + approvedActors := map[string]struct{}{} + seenSubjects := map[string]struct{}{} + for index := range proposed.Approvals { + approval := proposed.Approvals[index] + approval.ActorBinding = NormalizeActionActor(approval.ActorBinding) + if err := ValidateActionActor(approval.ActorBinding); err != nil || + (approval.ActorBinding.Kind != ActionActorUser && approval.ActorBinding.Kind != ActionActorAPIToken) || + approval.Actor != approval.ActorBinding.SubjectID || approval.Evidence == nil || approval.Timestamp.IsZero() { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + subjectKey := strings.ToLower(approval.ActorBinding.SubjectID) + if _, duplicate := seenSubjects[subjectKey]; duplicate { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + seenSubjects[subjectKey] = struct{}{} + if requirement.DisallowRequester && strings.EqualFold(approval.ActorBinding.SubjectID, proposed.Request.Actor.SubjectID) { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + evidence := *approval.Evidence + evidence.Actor = NormalizeActionActor(evidence.Actor) + if evidence.Version != 1 || evidence.IssuedAt.IsZero() || + !ActionActorsEqual(evidence.Actor, approval.ActorBinding) || evidence.OrgID != proposed.Request.Actor.OrgID || + evidence.ActionID != proposed.ID || evidence.PlanHash != proposed.Plan.PlanHash || + evidence.Outcome != approval.Outcome || evidence.Method != approval.Method || + evidence.IssuedAt.After(approval.Timestamp) || + (!evidence.ExpiresAt.IsZero() && (evidence.ExpiresAt.Before(evidence.IssuedAt) || approval.Timestamp.After(evidence.ExpiresAt))) { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + switch approval.Method { + case MethodSession: + if approval.ActorBinding.Kind != ActionActorUser || strings.TrimSpace(evidence.ChallengeID) != "" { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + case MethodAPIToken: + if approval.ActorBinding.Kind != ActionActorAPIToken || strings.TrimSpace(evidence.ChallengeID) != "" { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + case MethodWebAuthnUV, MethodDeviceKeyUV: + if approval.ActorBinding.Kind != ActionActorUser || strings.TrimSpace(evidence.ChallengeID) == "" || evidence.ExpiresAt.IsZero() || !evidence.ExpiresAt.After(evidence.IssuedAt) { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + default: + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + if approval.Outcome == OutcomeApproved { + switch requirement.Floor { + case ApprovalDryRun: + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + case ApprovalMultiFactor: + if approval.Method != MethodWebAuthnUV && approval.Method != MethodDeviceKeyUV { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + case ApprovalAdmin, ApprovalNone: + if approval.Method != MethodSession && approval.Method != MethodAPIToken { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + default: + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + } + if index < len(proposed.Approvals)-1 && approval.Outcome != OutcomeApproved { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + if approval.Outcome == OutcomeApproved { + approvedActors[subjectKey] = struct{}{} + } + } + last := proposed.Approvals[len(proposed.Approvals)-1] + if !canonicalActionIdentityJSONEqual(last, *event.Decision) || event.Actor != last.Actor || !event.Timestamp.Equal(last.Timestamp) { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + derivedState := ActionStateRejected + expectedMessage := "Action rejected before execution." + if last.Outcome == OutcomeApproved { + if len(approvedActors) < requirement.Quorum { + derivedState = ActionStatePending + expectedMessage = fmt.Sprintf("Approval recorded; %d of %d distinct approvals collected.", len(approvedActors), requirement.Quorum) + } else { + derivedState = ActionStateApproved + expectedMessage = "Action approved. Execution remains pending a separate execution contract." + } + } else if last.Outcome != OutcomeRejected { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + if proposed.State != derivedState || event.State != derivedState || event.Message != expectedMessage { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + command := ActionDecisionAppendCommand{Record: proposed, DecisionEvent: event} + if derivedState != ActionStatePending { + transition := event + transition.Kind = ActionLifecycleEventTransition + transition.DecisionRevision = 0 + transition.Decision = nil + transition, err = NormalizeActionLifecycleEvent(transition) + if err != nil { + return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict + } + command.TransitionEvent = &transition + } + return command, nil +} + // ExpireAction materializes plan expiry as an explicit monotonic lifecycle // state. Expiry is admission truth, not an execution-result classification. func ExpireAction(record ActionAuditRecord, actor string, now time.Time) (ActionAuditRecord, ActionLifecycleEvent, error) { @@ -704,6 +1040,8 @@ func permanentActionExecutionRefusalMessage(reason error) (string, bool) { return "policy_authorization_revoked: automatic authority changed before dispatch", true case errors.Is(reason, ErrActionEmergencyStop): return "action_emergency_stop: action dispatch is stopped by the operator", true + case errors.Is(reason, ErrActionReplanRequired): + return "action_replan_required: legacy action authority is unbound; re-plan before deciding or executing", true default: return "", false } @@ -744,6 +1082,46 @@ func ValidateActionExecutionStart(record ActionAuditRecord, now time.Time) error } } +// ValidateHumanActionBinding rejects legacy nonterminal records that predate +// the immutable actor/requirement contract. Terminal history remains readable, +// while pending or approved work must be re-planned under current authority. +func ValidateHumanActionBinding(record ActionAuditRecord, orgID string) error { + record.Request.Actor = NormalizeActionActor(record.Request.Actor) + requirement := NormalizeApprovalRequirement(record.Plan.ApprovalRequirement, record.Plan.ApprovalPolicy) + if ValidateActionActor(record.Request.Actor) != nil || record.Request.Actor.OrgID != strings.TrimSpace(orgID) || + requirement.Version != ActionApprovalRequirementVersion || requirement.Floor != record.Plan.ApprovalPolicy { + return ErrActionReplanRequired + } + if record.Plan.RequiresApproval && (record.State == ActionStateApproved || record.State == ActionStateExecuting) { + actors := map[string]struct{}{} + for _, approval := range record.Approvals { + if approval.Outcome != OutcomeApproved || approval.Evidence == nil { + continue + } + actor := NormalizeActionActor(approval.ActorBinding) + evidence := *approval.Evidence + evidence.Actor = NormalizeActionActor(evidence.Actor) + if ValidateActionActor(actor) != nil || !ActionActorsEqual(actor, evidence.Actor) || + evidence.Version != 1 || evidence.OrgID != strings.TrimSpace(orgID) || evidence.ActionID != record.ID || + evidence.PlanHash != record.Plan.PlanHash || evidence.Outcome != OutcomeApproved || evidence.IssuedAt.IsZero() { + continue + } + if requirement.Floor == ApprovalMultiFactor { + if evidence.Method != MethodWebAuthnUV && evidence.Method != MethodDeviceKeyUV { + continue + } + } else if evidence.Method != MethodSession && evidence.Method != MethodAPIToken { + continue + } + actors[strings.ToLower(actor.SubjectID)] = struct{}{} + } + if len(actors) < requirement.Quorum { + return ErrActionReplanRequired + } + } + return nil +} + // NormalizeActionAuditRecord applies the canonical action-governance floor // before a record is persisted. It keeps older callers usable by filling safe // deterministic defaults, but rejects records that cannot identify the action, @@ -757,6 +1135,12 @@ func NormalizeActionAuditRecord(record ActionAuditRecord) (ActionAuditRecord, er } record.Approvals = append([]ActionApprovalRecord(nil), record.Approvals...) for i := range record.Approvals { + record.Approvals[i].ActorBinding = NormalizeActionActor(record.Approvals[i].ActorBinding) + if record.Approvals[i].Evidence != nil { + evidence := *record.Approvals[i].Evidence + evidence.Actor = NormalizeActionActor(evidence.Actor) + record.Approvals[i].Evidence = &evidence + } if record.Approvals[i].PolicyLease != nil { lease := *record.Approvals[i].PolicyLease lease.CapabilityNames = append([]string(nil), lease.CapabilityNames...) @@ -804,6 +1188,10 @@ func NormalizeActionAuditRecord(record ActionAuditRecord) (ActionAuditRecord, er record.Request.CapabilityName = strings.TrimSpace(record.Request.CapabilityName) record.Request.Reason = strings.TrimSpace(record.Request.Reason) record.Request.RequestedBy = strings.TrimSpace(record.Request.RequestedBy) + record.Request.Actor = NormalizeActionActor(record.Request.Actor) + if record.Request.Actor.SubjectID != "" { + record.Request.RequestedBy = record.Request.Actor.SubjectID + } record.Origin = NormalizeActionOrigin(record.Origin) if record.Request.ResourceID == "" { return ActionAuditRecord{}, fmt.Errorf("action request resource id required") @@ -843,6 +1231,10 @@ func NormalizeActionAuditRecord(record ActionAuditRecord) (ActionAuditRecord, er record.Plan.ApprovalPolicy = ApprovalNone } } + record.Plan.ApprovalRequirement = NormalizeApprovalRequirement(record.Plan.ApprovalRequirement, record.Plan.ApprovalPolicy) + if record.Plan.ApprovalRequirement.Version != 0 && record.Plan.ApprovalRequirement.Version != ActionApprovalRequirementVersion { + return ActionAuditRecord{}, fmt.Errorf("unsupported approval requirement version %d", record.Plan.ApprovalRequirement.Version) + } record.Plan.Preflight = NormalizeActionPreflight(record.Plan.Preflight, record.Request, record.Plan) if record.Result != nil { @@ -897,6 +1289,44 @@ func NormalizeActionLifecycleEvent(event ActionLifecycleEvent) (ActionLifecycleE } event.Actor = strings.TrimSpace(event.Actor) event.Message = strings.TrimSpace(event.Message) + if event.Kind == "" { + if event.DecisionRevision > 0 || event.Decision != nil { + event.Kind = ActionLifecycleEventDecision + } else { + event.Kind = ActionLifecycleEventTransition + } + } + switch event.Kind { + case ActionLifecycleEventTransition: + if event.DecisionRevision != 0 || event.Decision != nil { + return ActionLifecycleEvent{}, fmt.Errorf("state transition event cannot carry decision identity") + } + case ActionLifecycleEventDecision: + if event.DecisionRevision == 0 || event.Decision == nil { + return ActionLifecycleEvent{}, fmt.Errorf("decision event revision and approval are required") + } + decision := *event.Decision + decision.ActorBinding = NormalizeActionActor(decision.ActorBinding) + decision.Actor = strings.TrimSpace(decision.Actor) + decision.Reason = strings.TrimSpace(decision.Reason) + if decision.Actor == "" || decision.Actor != decision.ActorBinding.SubjectID || decision.Evidence == nil || + decision.Outcome == "" || decision.Method == "" || decision.Timestamp.IsZero() { + return ActionLifecycleEvent{}, fmt.Errorf("decision event approval binding is incomplete") + } + decision.Timestamp = decision.Timestamp.UTC() + evidence := *decision.Evidence + evidence.Actor = NormalizeActionActor(evidence.Actor) + if !ActionActorsEqual(evidence.Actor, decision.ActorBinding) || evidence.ActionID != event.ActionID || + evidence.Outcome != decision.Outcome || evidence.Method != decision.Method { + return ActionLifecycleEvent{}, fmt.Errorf("decision event evidence binding does not match approval") + } + decision.Evidence = &evidence + event.Decision = &decision + event.Actor = decision.Actor + event.Timestamp = decision.Timestamp + default: + return ActionLifecycleEvent{}, fmt.Errorf("unsupported action lifecycle event kind %q", event.Kind) + } return event, nil } diff --git a/internal/unifiedresources/actions_test.go b/internal/unifiedresources/actions_test.go index f5cbbd7e9..5c3892a7c 100644 --- a/internal/unifiedresources/actions_test.go +++ b/internal/unifiedresources/actions_test.go @@ -7,6 +7,18 @@ import ( "time" ) +func testBoundActionApproval(record ActionAuditRecord, subject string, method ApprovalMethod, outcome ApprovalOutcome, reason string, at time.Time) ActionApprovalRecord { + kind := ActionActorUser + credential := "session:" + subject + if method == MethodPolicy { + kind = ActionActorPolicy + credential = "policy:test" + } + actor := ActionActor{SubjectID: subject, Kind: kind, CredentialID: credential, OrgID: "default"} + evidence := ApprovalEvidence{Version: 1, Method: method, Actor: actor, OrgID: "default", ActionID: record.ID, PlanHash: record.Plan.PlanHash, Outcome: outcome, IssuedAt: at} + return ActionApprovalRecord{Actor: subject, ActorBinding: actor, Method: method, Timestamp: at, Outcome: outcome, Reason: reason, Evidence: &evidence} +} + func TestNormalizeActionAuditRecordPopulatesGovernedPlanPreflight(t *testing.T) { now := time.Date(2026, 4, 25, 22, 30, 0, 0, time.UTC) record, err := NormalizeActionAuditRecord(ActionAuditRecord{ @@ -144,33 +156,31 @@ func TestApplyActionDecisionApprovesPendingActionWithoutExecution(t *testing.T) CapabilityName: "restart", Reason: "recover service", RequestedBy: "agent:oncall-helper", + Actor: ActionActor{SubjectID: "agent:oncall-helper", Kind: ActionActorService, CredentialID: "service:test", OrgID: "default"}, }, Plan: ActionPlan{ - ActionID: "act_test", - RequestID: "req-1", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: ApprovalAdmin, - PlannedAt: now.Add(-time.Minute), - ExpiresAt: now.Add(time.Minute), - ResourceVersion: "resource:sha256:test", - PolicyVersion: "policy:sha256:test", - PlanHash: "sha256:test", + ActionID: "act_test", + RequestID: "req-1", + Allowed: true, + RequiresApproval: true, + ApprovalPolicy: ApprovalAdmin, + ApprovalRequirement: ApprovalRequirementForFloor(ApprovalAdmin), + PlannedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Minute), + ResourceVersion: "resource:sha256:test", + PolicyVersion: "policy:sha256:test", + PlanHash: "sha256:test", }, } - updated, event, err := ApplyActionDecision(record, ActionApprovalRecord{ - Actor: "operator@example.com", - Outcome: OutcomeApproved, - Reason: "inside maintenance window", - }, now) + updated, event, err := ApplyActionDecision(record, testBoundActionApproval(record, "operator@example.com", MethodSession, OutcomeApproved, "inside maintenance window", now), now) if err != nil { t.Fatalf("ApplyActionDecision: %v", err) } if updated.State != ActionStateApproved || updated.Result != nil { t.Fatalf("updated action = %#v, want approved without execution result", updated) } - if len(updated.Approvals) != 1 || updated.Approvals[0].Method != MethodAPI || updated.Approvals[0].Actor != "operator@example.com" { + if len(updated.Approvals) != 1 || updated.Approvals[0].Method != MethodSession || updated.Approvals[0].Actor != "operator@example.com" { t.Fatalf("approval record = %#v", updated.Approvals) } if event.ActionID != "act_test" || event.State != ActionStateApproved || !strings.Contains(event.Message, "Execution remains pending") { diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 3fefd1336..03a84ed5e 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -692,13 +692,13 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) { "agentcapabilities.AgentErrCodeActionExecutorUnavailable", }, filepath.Join("..", "api", "resources.go"): { - "actionExecutor ActionExecutor", - "actionCompleted func(unified.ActionAuditRecord)", - "actionTransition func(orgID string, record unified.ActionAuditRecord)", + "actionExecutor ActionExecutor", + "actionCompleted func(unified.ActionAuditRecord)", + "actionTransition func(orgID string, record unified.ActionAuditRecord)", "func (h *ResourceHandlers) SetActionExecutor(executor ActionExecutor)", "func (h *ResourceHandlers) SetActionCompletedPublisher(", "func (h *ResourceHandlers) SetActionTransitionPublisher(", - "policyAdmission *actionlifecycle.PolicyAdmissionCoordinator", + "policyAdmission *actionlifecycle.PolicyAdmissionCoordinator", "func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resources []unified.Resource)", "resources[i].ActionReadiness = readinesses", }, @@ -713,7 +713,7 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) { }, filepath.Join("..", "api", "router_routes_monitoring.go"): { `"POST /api/actions/{id}/execute"`, - "RequireScope(config.ScopeAIExecute, r.withExternalAgentCapabilityActivity(", + "requireActionCapability(r.authorizer, auth.ActionExecute", "r.resourceHandlers.HandleExecuteAction", }, } diff --git a/internal/unifiedresources/store.go b/internal/unifiedresources/store.go index 211c259f3..2f641daff 100644 --- a/internal/unifiedresources/store.go +++ b/internal/unifiedresources/store.go @@ -456,6 +456,7 @@ func (s *SQLiteResourceStore) initSchema() error { created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, state TEXT NOT NULL, + decision_revision INTEGER NOT NULL DEFAULT 0, request_json TEXT NOT NULL, plan_json TEXT NOT NULL, approvals_json TEXT, @@ -470,6 +471,9 @@ func (s *SQLiteResourceStore) initSchema() error { action_id TEXT NOT NULL, timestamp DATETIME NOT NULL, state TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'transition', + decision_revision INTEGER NOT NULL DEFAULT 0, + decision_json TEXT, actor TEXT, message TEXT ); @@ -626,8 +630,8 @@ func (s *SQLiteResourceStore) migrateResourceIdentitiesSchema() error { return nil } -// migrateActionAuditsSchema adds the verification_outcome_json and -// origin_json columns to older action_audits tables so the +// migrateActionAuditsSchema adds decision/verification/origin columns to +// older action_audits tables so the // VerificationOutcome and broker-owned Origin fields on ActionAuditRecord // persist across restarts. Records written before the migration read back // with the default unknown status / nil origin via the normalizer. @@ -646,6 +650,11 @@ func (s *SQLiteResourceStore) migrateActionAuditsSchema() error { return fmt.Errorf("add action_audits.origin_json column: %w", err) } } + if _, ok := columns["decision_revision"]; !ok { + if _, err := s.db.Exec("ALTER TABLE action_audits ADD COLUMN decision_revision INTEGER NOT NULL DEFAULT 0"); err != nil { + return fmt.Errorf("add action_audits.decision_revision column: %w", err) + } + } if _, err := s.db.Exec(` CREATE INDEX IF NOT EXISTS idx_action_audits_origin_investigation_updated_v2 ON action_audits(json_extract(origin_json, '$.surface'), json_extract(origin_json, '$.investigationId'), updated_at DESC) @@ -663,20 +672,51 @@ func (s *SQLiteResourceStore) migrateActionAuditsSchema() error { } func (s *SQLiteResourceStore) migrateActionLifecycleEventsSchema() error { + columns, err := s.tableColumns("action_lifecycle_events") + if err != nil { + return err + } + if _, ok := columns["kind"]; !ok { + if _, err := s.db.Exec("ALTER TABLE action_lifecycle_events ADD COLUMN kind TEXT NOT NULL DEFAULT 'transition'"); err != nil { + return fmt.Errorf("add action_lifecycle_events.kind column: %w", err) + } + } + if _, ok := columns["decision_revision"]; !ok { + if _, err := s.db.Exec("ALTER TABLE action_lifecycle_events ADD COLUMN decision_revision INTEGER NOT NULL DEFAULT 0"); err != nil { + return fmt.Errorf("add action_lifecycle_events.decision_revision column: %w", err) + } + } + if _, ok := columns["decision_json"]; !ok { + if _, err := s.db.Exec("ALTER TABLE action_lifecycle_events ADD COLUMN decision_json TEXT"); err != nil { + return fmt.Errorf("add action_lifecycle_events.decision_json column: %w", err) + } + } + if _, err := s.db.Exec(`DROP INDEX IF EXISTS idx_action_lifecycle_events_action_state_unique`); err != nil { + return fmt.Errorf("replace action lifecycle state index: %w", err) + } if _, err := s.db.Exec(` - DELETE FROM action_lifecycle_events - WHERE id NOT IN ( - SELECT MIN(id) FROM action_lifecycle_events GROUP BY action_id, state + UPDATE action_lifecycle_events SET kind = 'legacy' + WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY action_id, state ORDER BY id) AS occurrence + FROM action_lifecycle_events WHERE kind = 'transition' + ) WHERE occurrence > 1 ) `); err != nil { - return fmt.Errorf("deduplicate action lifecycle state events: %w", err) + return fmt.Errorf("preserve duplicate historical lifecycle events as legacy facts: %w", err) } if _, err := s.db.Exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_action_lifecycle_events_action_state_unique - ON action_lifecycle_events(action_id, state) + ON action_lifecycle_events(action_id, state) WHERE kind = 'transition' `); err != nil { return fmt.Errorf("create unique action lifecycle state index: %w", err) } + if _, err := s.db.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_action_lifecycle_events_action_decision_revision_unique + ON action_lifecycle_events(action_id, decision_revision) WHERE kind = 'decision' + `); err != nil { + return fmt.Errorf("create unique action decision revision index: %w", err) + } return nil } @@ -1796,7 +1836,7 @@ func actionAuditSQLArgs(record ActionAuditRecord) ([]any, error) { originJSON = string(encoded) } - return []any{record.ID, record.ID, CanonicalResourceID(record.Request.ResourceID), record.Request.RequestID, record.CreatedAt, record.UpdatedAt, string(record.State), string(requestJSON), string(planJSON), string(approvalsJSON), string(resultJSON), string(verificationOutcomeJSON), originJSON}, nil + return []any{record.ID, record.ID, CanonicalResourceID(record.Request.ResourceID), record.Request.RequestID, record.CreatedAt, record.UpdatedAt, string(record.State), record.DecisionRevision, string(requestJSON), string(planJSON), string(approvalsJSON), string(resultJSON), string(verificationOutcomeJSON), originJSON}, nil } func insertActionAuditSQL(exec sqlExecutor, record ActionAuditRecord) (bool, error) { @@ -1805,8 +1845,8 @@ func insertActionAuditSQL(exec sqlExecutor, record ActionAuditRecord) (bool, err return false, err } result, err := exec.Exec(` - INSERT INTO action_audits (id, action_id, canonical_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO action_audits (id, action_id, canonical_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING `, args...) if err != nil { @@ -1831,10 +1871,10 @@ func updateActionAuditSQL(exec sqlExecutor, record ActionAuditRecord, expectedSt placeholders[i] = "?" setArgs = append(setArgs, string(state)) } - setArgs = append(setArgs, args[7], args[8], firstNonNilString(args[12])) + setArgs = append(setArgs, args[8], args[9], firstNonNilString(args[13])) result, err := exec.Exec(` UPDATE action_audits SET - action_id=?, canonical_id=?, request_id=?, created_at=?, updated_at=?, state=?, + action_id=?, canonical_id=?, request_id=?, created_at=?, updated_at=?, state=?, decision_revision=?, request_json=?, plan_json=?, approvals_json=?, result_json=?, verification_outcome_json=?, origin_json=? WHERE id=? AND state IN (`+strings.Join(placeholders, ",")+`) AND request_json=? AND plan_json=? AND COALESCE(origin_json, '')=? @@ -1849,6 +1889,41 @@ func updateActionAuditSQL(exec sqlExecutor, record ActionAuditRecord, expectedSt return rows == 1, nil } +func updateActionDecisionSQL(exec sqlExecutor, record ActionAuditRecord, expectedRevision uint64) (bool, error) { + if len(record.Approvals) == 0 { + return false, ErrActionDecisionRevisionConflict + } + args, err := actionAuditSQLArgs(record) + if err != nil { + return false, err + } + var expectedApprovals []ActionApprovalRecord + if len(record.Approvals) > 1 { + expectedApprovals = record.Approvals[:len(record.Approvals)-1] + } + expectedApprovalsJSON, err := json.Marshal(expectedApprovals) + if err != nil { + return false, fmt.Errorf("marshal expected action approvals: %w", err) + } + setArgs := append([]any(nil), args[1:]...) + setArgs = append(setArgs, record.ID, string(ActionStatePending), expectedRevision, string(expectedApprovalsJSON), args[8], args[9], firstNonNilString(args[13])) + result, err := exec.Exec(` + UPDATE action_audits SET + action_id=?, canonical_id=?, request_id=?, created_at=?, updated_at=?, state=?, decision_revision=?, + request_json=?, plan_json=?, approvals_json=?, result_json=?, verification_outcome_json=?, origin_json=? + WHERE id=? AND state=? AND decision_revision=? AND approvals_json=? + AND request_json=? AND plan_json=? AND COALESCE(origin_json, '')=? + `, setArgs...) + if err != nil { + return false, fmt.Errorf("update action decision: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("read updated action decision rows: %w", err) + } + return rows == 1, nil +} + func firstNonNilString(value any) string { if value == nil { return "" @@ -1960,7 +2035,7 @@ func scanActionAuditRecord(scanner actionAuditScanner) (ActionAuditRecord, error var stateStr string var actionID, requestID string var requestJSON, planJSON, approvalsJSON, resultJSON, verificationOutcomeJSON, originJSON sql.NullString - if err := scanner.Scan(&record.ID, &actionID, &requestID, &record.CreatedAt, &record.UpdatedAt, &stateStr, &requestJSON, &planJSON, &approvalsJSON, &resultJSON, &verificationOutcomeJSON, &originJSON); err != nil { + if err := scanner.Scan(&record.ID, &actionID, &requestID, &record.CreatedAt, &record.UpdatedAt, &stateStr, &record.DecisionRevision, &requestJSON, &planJSON, &approvalsJSON, &resultJSON, &verificationOutcomeJSON, &originJSON); err != nil { return ActionAuditRecord{}, err } @@ -2049,7 +2124,7 @@ func (s *SQLiteResourceStore) GetLatestActionAuditByOrigin(surface, investigatio return ActionAuditRecord{}, false, nil } row := s.db.QueryRow(` - SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json + SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json FROM action_audits WHERE json_valid(origin_json) AND json_extract(origin_json, '$.surface') = ? @@ -2072,7 +2147,7 @@ func (s *SQLiteResourceStore) GetPendingActionAudits(limit int) ([]ActionAuditRe limit = 100 } rows, err := s.db.Query(` - SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json + SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json FROM action_audits WHERE state = ? ORDER BY updated_at ASC, created_at ASC @@ -2098,7 +2173,7 @@ func (s *SQLiteResourceStore) GetPendingActionAudits(limit int) ([]ActionAuditRe func (s *SQLiteResourceStore) GetActionAudits(canonicalID string, since time.Time, limit int) ([]ActionAuditRecord, error) { query := ` - SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json + SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json FROM action_audits` args := []any{} @@ -2140,10 +2215,18 @@ func (s *SQLiteResourceStore) GetActionAudits(canonicalID string, since time.Tim } func recordActionLifecycleEventSQL(exec sqlExecutor, event ActionLifecycleEvent) error { + var decisionJSON any + if event.Decision != nil { + encoded, err := json.Marshal(event.Decision) + if err != nil { + return fmt.Errorf("marshal action decision event: %w", err) + } + decisionJSON = string(encoded) + } _, err := exec.Exec(` - INSERT INTO action_lifecycle_events (action_id, timestamp, state, actor, message) - VALUES (?, ?, ?, ?, ?) - `, event.ActionID, event.Timestamp, string(event.State), event.Actor, event.Message) + INSERT INTO action_lifecycle_events (action_id, timestamp, state, kind, decision_revision, decision_json, actor, message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, event.ActionID, event.Timestamp, string(event.State), string(event.Kind), event.DecisionRevision, decisionJSON, event.Actor, event.Message) if err != nil { return fmt.Errorf("insert action lifecycle event: %w", err) } @@ -2169,7 +2252,7 @@ func actionTransitionConflict(current ActionAuditRecord, desired ActionAuditReco } } -func (s *SQLiteResourceStore) recordActionTransition(record ActionAuditRecord, event ActionLifecycleEvent, expectedStates []ActionState, fallback error) error { +func (s *SQLiteResourceStore) recordActionTransition(record ActionAuditRecord, event ActionLifecycleEvent, resultingTransition *ActionLifecycleEvent, expectedStates []ActionState, fallback error, expectedDecisionRevision *uint64) error { s.mu.Lock() defer s.mu.Unlock() tx, err := s.db.Begin() @@ -2182,7 +2265,12 @@ func (s *SQLiteResourceStore) recordActionTransition(record ActionAuditRecord, e _ = tx.Rollback() } }() - updated, err := updateActionAuditSQL(tx, record, expectedStates...) + var updated bool + if expectedDecisionRevision != nil { + updated, err = updateActionDecisionSQL(tx, record, *expectedDecisionRevision) + } else { + updated, err = updateActionAuditSQL(tx, record, expectedStates...) + } if err != nil { return err } @@ -2194,11 +2282,19 @@ func (s *SQLiteResourceStore) recordActionTransition(record ActionAuditRecord, e if !found { return fmt.Errorf("action audit %q not found", record.ID) } + if expectedDecisionRevision != nil && current.State == ActionStatePending && ActionAuditIdentityMatches(current, record) { + return ErrActionDecisionRevisionConflict + } return actionTransitionConflict(current, record, fallback) } if err := recordActionLifecycleEventSQL(tx, event); err != nil { return err } + if resultingTransition != nil { + if err := recordActionLifecycleEventSQL(tx, *resultingTransition); err != nil { + return err + } + } if err := tx.Commit(); err != nil { return fmt.Errorf("commit action transition transaction: %w", err) } @@ -2218,8 +2314,22 @@ func (s *SQLiteResourceStore) RecordActionDecision(record ActionAuditRecord, eve if normalizedEvent.ActionID != normalizedRecord.ID { return fmt.Errorf("action decision event id %q does not match action audit id %q", normalizedEvent.ActionID, normalizedRecord.ID) } - - return s.recordActionTransition(normalizedRecord, normalizedEvent, []ActionState{ActionStatePending}, ErrActionNotPending) + if normalizedEvent.Kind != ActionLifecycleEventDecision || normalizedEvent.DecisionRevision != normalizedRecord.DecisionRevision { + return fmt.Errorf("action decision event revision does not match action audit revision") + } + current, found, err := s.getActionAudit(normalizedRecord.ID) + if err != nil { + return err + } + if !found { + return fmt.Errorf("action audit %q not found", normalizedRecord.ID) + } + command, err := PrepareActionDecisionAppend(current, normalizedRecord, normalizedEvent) + if err != nil { + return err + } + expectedRevision := command.Record.DecisionRevision - 1 + return s.recordActionTransition(command.Record, command.DecisionEvent, command.TransitionEvent, []ActionState{ActionStatePending}, ErrActionNotPending, &expectedRevision) } func (s *SQLiteResourceStore) RecordActionExecutionStart(record ActionAuditRecord, event ActionLifecycleEvent) error { @@ -2251,7 +2361,7 @@ func (s *SQLiteResourceStore) RecordActionExecutionStart(record ActionAuditRecor if normalizedRecord.Plan.Allowed && !normalizedRecord.Plan.RequiresApproval { expected = append(expected, ActionStatePlanned) } - return s.recordActionTransition(normalizedRecord, normalizedEvent, expected, ErrActionNotApproved) + return s.recordActionTransition(normalizedRecord, normalizedEvent, nil, expected, ErrActionNotApproved, nil) } // RecordActionPolicyExecutionStart is the single automatic-admission CAS. It @@ -2338,7 +2448,7 @@ func (s *SQLiteResourceStore) RecordActionExecutionResult(record ActionAuditReco // output before persisting; see RecordActionAudit for the contract. normalizedRecord = RedactAuditRecord(normalizedRecord) - return s.recordActionTransition(normalizedRecord, normalizedEvent, []ActionState{ActionStateExecuting}, ErrActionNotExecuting) + return s.recordActionTransition(normalizedRecord, normalizedEvent, nil, []ActionState{ActionStateExecuting}, ErrActionNotExecuting, nil) } func (s *SQLiteResourceStore) RecordActionExecutionRefusal(record ActionAuditRecord, event ActionLifecycleEvent) error { @@ -2354,7 +2464,7 @@ func (s *SQLiteResourceStore) RecordActionExecutionRefusal(record ActionAuditRec return fmt.Errorf("action execution refusal must persist matching failed state") } normalizedRecord = RedactAuditRecord(normalizedRecord) - return s.recordActionTransition(normalizedRecord, normalizedEvent, []ActionState{ActionStatePlanned, ActionStatePending, ActionStateApproved}, ErrActionExecutionFinal) + return s.recordActionTransition(normalizedRecord, normalizedEvent, nil, []ActionState{ActionStatePlanned, ActionStatePending, ActionStateApproved}, ErrActionExecutionFinal, nil) } func (s *SQLiteResourceStore) getActionAudit(actionID string) (ActionAuditRecord, bool, error) { @@ -2371,7 +2481,7 @@ func getActionAuditFrom(queryer actionAuditQueryRower, actionID string) (ActionA return ActionAuditRecord{}, false, nil } row := queryer.QueryRow(` - SELECT id, action_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json + SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json FROM action_audits WHERE id = ? `, actionID) @@ -2400,7 +2510,7 @@ func (s *SQLiteResourceStore) RecordActionLifecycleEvent(event ActionLifecycleEv func (s *SQLiteResourceStore) GetActionLifecycleEvents(actionID string, since time.Time, limit int) ([]ActionLifecycleEvent, error) { query := ` - SELECT action_id, timestamp, state, actor, message + SELECT action_id, timestamp, state, kind, decision_revision, decision_json, actor, message FROM action_lifecycle_events` args := []any{} @@ -2415,7 +2525,7 @@ func (s *SQLiteResourceStore) GetActionLifecycleEvents(actionID string, since ti args = append(args, since) } query += ` - ORDER BY timestamp DESC` + ORDER BY timestamp DESC, decision_revision DESC, id DESC` if limit > 0 { query += ` LIMIT ?` args = append(args, limit) @@ -2431,10 +2541,20 @@ func (s *SQLiteResourceStore) GetActionLifecycleEvents(actionID string, since ti for rows.Next() { var event ActionLifecycleEvent var stateStr string - if err := rows.Scan(&event.ActionID, &event.Timestamp, &stateStr, &event.Actor, &event.Message); err != nil { + var kind string + var decisionJSON sql.NullString + if err := rows.Scan(&event.ActionID, &event.Timestamp, &stateStr, &kind, &event.DecisionRevision, &decisionJSON, &event.Actor, &event.Message); err != nil { return nil, fmt.Errorf("scan action lifecycle row: %w", err) } event.State = ActionState(stateStr) + event.Kind = ActionLifecycleEventKind(kind) + if decisionJSON.Valid && decisionJSON.String != "" && decisionJSON.String != "null" { + var decision ActionApprovalRecord + if err := json.Unmarshal([]byte(decisionJSON.String), &decision); err != nil { + return nil, fmt.Errorf("unmarshal action decision event: %w", err) + } + event.Decision = &decision + } events = append(events, event) } if err := rows.Err(); err != nil { @@ -3293,28 +3413,33 @@ func (m *MemoryStore) RecordActionDecision(record ActionAuditRecord, event Actio if normalizedEvent.ActionID != normalizedRecord.ID { return fmt.Errorf("action decision event id %q does not match action audit id %q", normalizedEvent.ActionID, normalizedRecord.ID) } - + if normalizedEvent.Kind != ActionLifecycleEventDecision || normalizedEvent.DecisionRevision != normalizedRecord.DecisionRevision { + return fmt.Errorf("action decision event revision does not match action audit revision") + } m.mu.Lock() defer m.mu.Unlock() - replaced := false for i := range m.actionAudits { if m.actionAudits[i].ID == normalizedRecord.ID { - if m.actionAudits[i].State != ActionStatePending { - return actionTransitionConflict(m.actionAudits[i], normalizedRecord, ErrActionNotPending) + command, err := PrepareActionDecisionAppend(m.actionAudits[i], normalizedRecord, normalizedEvent) + if err != nil { + return err } - if !ActionAuditIdentityMatches(m.actionAudits[i], normalizedRecord) { - return ErrActionIdentityConflict + if command.TransitionEvent != nil { + for _, existing := range m.actionLifecycleEvents { + if existing.ActionID == command.TransitionEvent.ActionID && existing.Kind == ActionLifecycleEventTransition && existing.State == command.TransitionEvent.State { + return fmt.Errorf("action lifecycle state %q already recorded for %q", command.TransitionEvent.State, command.TransitionEvent.ActionID) + } + } } - m.actionAudits[i] = normalizedRecord - replaced = true - break + m.actionAudits[i] = command.Record + m.actionLifecycleEvents = append(m.actionLifecycleEvents, command.DecisionEvent) + if command.TransitionEvent != nil { + m.actionLifecycleEvents = append(m.actionLifecycleEvents, *command.TransitionEvent) + } + return nil } } - if !replaced { - return fmt.Errorf("action audit %q not found", normalizedRecord.ID) - } - m.actionLifecycleEvents = append(m.actionLifecycleEvents, normalizedEvent) - return nil + return fmt.Errorf("action audit %q not found", normalizedRecord.ID) } func (m *MemoryStore) RecordActionExecutionStart(record ActionAuditRecord, event ActionLifecycleEvent) error { @@ -3510,9 +3635,15 @@ func (m *MemoryStore) RecordActionLifecycleEvent(event ActionLifecycleEvent) err m.mu.Lock() defer m.mu.Unlock() for _, existing := range m.actionLifecycleEvents { - if existing.ActionID == event.ActionID && existing.State == event.State { + if existing.ActionID != event.ActionID { + continue + } + if event.Kind == ActionLifecycleEventTransition && existing.Kind == ActionLifecycleEventTransition && existing.State == event.State { return fmt.Errorf("action lifecycle state %q already recorded for %q", event.State, event.ActionID) } + if event.Kind == ActionLifecycleEventDecision && existing.Kind == ActionLifecycleEventDecision && existing.DecisionRevision == event.DecisionRevision { + return fmt.Errorf("action decision revision %d already recorded for %q", event.DecisionRevision, event.ActionID) + } } m.actionLifecycleEvents = append(m.actionLifecycleEvents, event) return nil diff --git a/internal/unifiedresources/store_test.go b/internal/unifiedresources/store_test.go index dab7d08a2..c3f4b5144 100644 --- a/internal/unifiedresources/store_test.go +++ b/internal/unifiedresources/store_test.go @@ -15,10 +15,11 @@ import ( func atomicLifecycleTestRecord(id string, state ActionState) ActionAuditRecord { now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + actor := ActionActor{SubjectID: "agent:test", Kind: ActionActorService, CredentialID: "service:test", OrgID: "default"} return ActionAuditRecord{ ID: id, CreatedAt: now, UpdatedAt: now, State: state, - Request: ActionRequest{RequestID: "req-" + id, ResourceID: "vm:42", CapabilityName: "restart", Reason: "atomic lifecycle proof", RequestedBy: "agent:test"}, - Plan: ActionPlan{ActionID: id, RequestID: "req-" + id, Allowed: true, RequiresApproval: state == ActionStatePending, ApprovalPolicy: ApprovalAdmin, PlannedAt: now, ExpiresAt: now.Add(time.Hour), ResourceVersion: "resource:sha256:test", PolicyVersion: "policy:sha256:test", PlanHash: "sha256:" + id}, + Request: ActionRequest{RequestID: "req-" + id, ResourceID: "vm:42", CapabilityName: "restart", Reason: "atomic lifecycle proof", RequestedBy: "agent:test", Actor: actor}, + Plan: ActionPlan{ActionID: id, RequestID: "req-" + id, Allowed: true, RequiresApproval: state == ActionStatePending, ApprovalPolicy: ApprovalAdmin, ApprovalRequirement: ApprovalRequirementForFloor(ApprovalAdmin), PlannedAt: now, ExpiresAt: now.Add(time.Hour), ResourceVersion: "resource:sha256:test", PolicyVersion: "policy:sha256:test", PlanHash: "sha256:" + id}, Origin: &ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "proposal-1"}, } } @@ -77,7 +78,7 @@ func TestMemoryStoreActionTransitionsAreMonotonic(t *testing.T) { if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { t.Fatal(err) } - approved, event, err := ApplyActionDecision(record, ActionApprovalRecord{Actor: "operator", Method: MethodAPI, Outcome: OutcomeApproved}, record.CreatedAt.Add(time.Minute)) + approved, event, err := ApplyActionDecision(record, testBoundActionApproval(record, "operator", MethodSession, OutcomeApproved, "", record.CreatedAt.Add(time.Minute)), record.CreatedAt.Add(time.Minute)) if err != nil || store.RecordActionDecision(approved, event) != nil { t.Fatalf("approve: %v", err) } @@ -89,7 +90,7 @@ func TestMemoryStoreActionTransitionsAreMonotonic(t *testing.T) { if err != nil || store.RecordActionExecutionResult(completed, doneEvent) != nil { t.Fatalf("complete: %v", err) } - if err := store.RecordActionDecision(approved, event); !errors.Is(err, ErrActionExecutionFinal) { + if err := store.RecordActionDecision(approved, event); !errors.Is(err, ErrActionDecisionRevisionConflict) { t.Fatalf("terminal rewind error=%v", err) } } @@ -205,8 +206,8 @@ func TestSQLiteStoreActionTransitionCASAcrossTwoInstances(t *testing.T) { if _, _, err := first.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { t.Fatal(err) } - approved, approvedEvent, _ := ApplyActionDecision(record, ActionApprovalRecord{Actor: "one", Outcome: OutcomeApproved}, record.CreatedAt.Add(time.Minute)) - rejected, rejectedEvent, _ := ApplyActionDecision(record, ActionApprovalRecord{Actor: "two", Outcome: OutcomeRejected}, record.CreatedAt.Add(time.Minute)) + approved, approvedEvent, _ := ApplyActionDecision(record, testBoundActionApproval(record, "one", MethodSession, OutcomeApproved, "", record.CreatedAt.Add(time.Minute)), record.CreatedAt.Add(time.Minute)) + rejected, rejectedEvent, _ := ApplyActionDecision(record, testBoundActionApproval(record, "two", MethodSession, OutcomeRejected, "", record.CreatedAt.Add(time.Minute)), record.CreatedAt.Add(time.Minute)) start := make(chan struct{}) errs := make(chan error, 2) go func() { <-start; errs <- first.RecordActionDecision(approved, approvedEvent) }() @@ -273,23 +274,50 @@ func TestSQLiteStoreCreateActionAuditRollsBackWhenInitialEventInsertFails(t *tes } } -func TestSQLiteStoreActionTransitionRollsBackWhenEventInsertFails(t *testing.T) { +func TestSQLiteStoreActionDecisionRollsBackWhenDecisionEventInsertFails(t *testing.T) { store := newTestStore(t) record := atomicLifecycleTestRecord("act_transition_rollback", ActionStatePending) if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { t.Fatal(err) } - if _, err := store.db.Exec(`CREATE TRIGGER fail_transition_event BEFORE INSERT ON action_lifecycle_events BEGIN SELECT RAISE(ABORT, 'forced event failure'); END`); err != nil { + if _, err := store.db.Exec(`CREATE TRIGGER fail_decision_event BEFORE INSERT ON action_lifecycle_events WHEN NEW.kind = 'decision' BEGIN SELECT RAISE(ABORT, 'forced decision event failure'); END`); err != nil { t.Fatal(err) } - approved, event, _ := ApplyActionDecision(record, ActionApprovalRecord{Actor: "operator", Outcome: OutcomeApproved}, record.CreatedAt.Add(time.Minute)) + approved, event, _ := ApplyActionDecision(record, testBoundActionApproval(record, "operator", MethodSession, OutcomeApproved, "", record.CreatedAt.Add(time.Minute)), record.CreatedAt.Add(time.Minute)) if err := store.RecordActionDecision(approved, event); err == nil { t.Fatal("expected transition failure") } current, found, err := store.GetActionAudit(record.ID) - if err != nil || !found || current.State != ActionStatePending { + if err != nil || !found || current.State != ActionStatePending || current.DecisionRevision != 0 || len(current.Approvals) != 0 { t.Fatalf("current=%#v found=%v err=%v", current, found, err) } + events, _ := store.GetActionLifecycleEvents(record.ID, time.Time{}, 20) + if len(events) != 2 { + t.Fatalf("decision-event failure leaked events: %#v", events) + } +} + +func TestSQLiteStoreActionDecisionRollsBackWhenResultingTransitionInsertFails(t *testing.T) { + store := newTestStore(t) + record := atomicLifecycleTestRecord("act_transition_rollback", ActionStatePending) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`CREATE TRIGGER fail_resulting_transition BEFORE INSERT ON action_lifecycle_events WHEN NEW.kind = 'transition' AND NEW.state = 'approved' BEGIN SELECT RAISE(ABORT, 'forced transition event failure'); END`); err != nil { + t.Fatal(err) + } + approved, event, _ := ApplyActionDecision(record, testBoundActionApproval(record, "operator", MethodSession, OutcomeApproved, "", record.CreatedAt.Add(time.Minute)), record.CreatedAt.Add(time.Minute)) + if err := store.RecordActionDecision(approved, event); err == nil { + t.Fatal("expected resulting transition failure") + } + current, found, err := store.GetActionAudit(record.ID) + if err != nil || !found || current.State != ActionStatePending || current.DecisionRevision != 0 || len(current.Approvals) != 0 { + t.Fatalf("current=%#v found=%v err=%v", current, found, err) + } + events, _ := store.GetActionLifecycleEvents(record.ID, time.Time{}, 20) + if len(events) != 2 { + t.Fatalf("transition-event failure leaked events: %#v", events) + } } func TestSQLiteStoreLifecycleRestartPreservesMonotonicState(t *testing.T) { @@ -355,7 +383,7 @@ func TestSQLiteStoreRestartDoesNotReadmitExecutingAction(t *testing.T) { } } -func TestSQLiteActionLifecycleMigrationDeduplicatesStateEvents(t *testing.T) { +func TestSQLiteActionLifecycleMigrationRetainsHistoricalDuplicatesAndRestoresTransitionUniqueness(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "resources", "unified_resources.db") if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { @@ -382,11 +410,19 @@ func TestSQLiteActionLifecycleMigrationDeduplicatesStateEvents(t *testing.T) { } defer store.Close() events, err := store.GetActionLifecycleEvents("act_event_dedupe", time.Time{}, 10) - if err != nil || len(events) != 1 { + if err != nil || len(events) != 2 { t.Fatalf("events=%#v err=%v", events, err) } - if err := store.RecordActionLifecycleEvent(events[0]); err == nil { - t.Fatal("duplicate state event should be rejected after migration") + kinds := map[ActionLifecycleEventKind]int{} + for _, event := range events { + kinds[event.Kind]++ + } + if kinds[ActionLifecycleEventTransition] != 1 || kinds[ActionLifecycleEventLegacy] != 1 { + t.Fatalf("migrated historical event kinds=%v, want one transition and one retained legacy fact", kinds) + } + duplicateTransition := ActionLifecycleEvent{ActionID: "act_event_dedupe", Timestamp: time.Now().UTC(), State: ActionStatePlanned} + if err := store.RecordActionLifecycleEvent(duplicateTransition); err == nil { + t.Fatal("duplicate planned transition should remain rejected after migration") } } @@ -1882,6 +1918,7 @@ func TestActionAuditRecord_RoundTripLegacyResultVerificationRedactsSQLiteReads(t ResourceID: "vm:legacy-verification", CapabilityName: "restart", RequestedBy: "agent:test", + Actor: ActionActor{SubjectID: "agent:test", Kind: ActionActorService, CredentialID: "service:test", OrgID: "default"}, }, Plan: ActionPlan{ ActionID: "action-legacy-verification", @@ -2104,12 +2141,14 @@ func TestActionAudit_GetActionAuditByID(t *testing.T) { RequestedBy: "agent:test", }, Plan: ActionPlan{ - ActionID: "act_lookup", - RequestID: "req-lookup", - ExpiresAt: now.Add(5 * time.Minute), - ResourceVersion: "resource:sha256:test", - PolicyVersion: "policy:sha256:test", - PlanHash: "sha256:test", + ActionID: "act_lookup", + RequestID: "req-lookup", + ExpiresAt: now.Add(5 * time.Minute), + ResourceVersion: "resource:sha256:test", + PolicyVersion: "policy:sha256:test", + PlanHash: "sha256:test", + ApprovalPolicy: ApprovalAdmin, + ApprovalRequirement: ApprovalRequirementForFloor(ApprovalAdmin), }, } if err := store.RecordActionAudit(record); err != nil { @@ -2146,24 +2185,28 @@ func TestRecordActionDecision_UpdatesAuditAndAppendsLifecycle(t *testing.T) { CapabilityName: "restart", Reason: "decision proof", RequestedBy: "agent:test", + Actor: ActionActor{SubjectID: "agent:test", Kind: ActionActorService, CredentialID: "service:test", OrgID: "default"}, }, Plan: ActionPlan{ - ActionID: "act_decision", - RequestID: "req-decision", - ExpiresAt: now.Add(5 * time.Minute), - ResourceVersion: "resource:sha256:test", - PolicyVersion: "policy:sha256:test", - PlanHash: "sha256:test", + ActionID: "act_decision", + RequestID: "req-decision", + ExpiresAt: now.Add(5 * time.Minute), + ResourceVersion: "resource:sha256:test", + PolicyVersion: "policy:sha256:test", + PlanHash: "sha256:test", + ApprovalPolicy: ApprovalAdmin, + ApprovalRequirement: ApprovalRequirementForFloor(ApprovalAdmin), }, } if err := store.RecordActionAudit(record); err != nil { t.Fatalf("RecordActionAudit: %v", err) } - updated, event, err := ApplyActionDecision(record, ActionApprovalRecord{ - Actor: "operator@example.com", - Outcome: OutcomeApproved, - Reason: "approved for proof", - }, now) + approvalFor := func(subject string, outcome ApprovalOutcome, reason string, at time.Time) ActionApprovalRecord { + actor := ActionActor{SubjectID: subject, Kind: ActionActorUser, CredentialID: "session:" + subject, OrgID: "default"} + evidence := ApprovalEvidence{Version: 1, Method: MethodSession, Actor: actor, OrgID: "default", ActionID: record.ID, PlanHash: record.Plan.PlanHash, Outcome: outcome, IssuedAt: at} + return ActionApprovalRecord{Actor: subject, ActorBinding: actor, Method: MethodSession, Timestamp: at, Outcome: outcome, Reason: reason, Evidence: &evidence} + } + updated, event, err := ApplyActionDecision(record, approvalFor("operator@example.com", OutcomeApproved, "approved for proof", now), now) if err != nil { t.Fatalf("ApplyActionDecision: %v", err) } @@ -2182,20 +2225,17 @@ func TestRecordActionDecision_UpdatesAuditAndAppendsLifecycle(t *testing.T) { if err != nil { t.Fatalf("GetActionLifecycleEvents: %v", err) } - if len(events) != 1 || events[0].State != ActionStateApproved || events[0].Actor != "operator@example.com" { + if len(events) != 2 || events[0].State != ActionStateApproved || events[0].Actor != "operator@example.com" || + events[0].Kind != ActionLifecycleEventDecision || events[1].Kind != ActionLifecycleEventTransition { t.Fatalf("decision events = %#v", events) } - staleUpdate, staleEvent, err := ApplyActionDecision(record, ActionApprovalRecord{ - Actor: "second-operator@example.com", - Outcome: OutcomeRejected, - Reason: "late rejection", - }, now.Add(time.Second)) + staleUpdate, staleEvent, err := ApplyActionDecision(record, approvalFor("second-operator@example.com", OutcomeRejected, "late rejection", now.Add(time.Second)), now.Add(time.Second)) if err != nil { t.Fatalf("ApplyActionDecision stale: %v", err) } - if err := store.RecordActionDecision(staleUpdate, staleEvent); !errors.Is(err, ErrActionNotPending) { - t.Fatalf("stale RecordActionDecision error = %v, want %v", err, ErrActionNotPending) + if err := store.RecordActionDecision(staleUpdate, staleEvent); !errors.Is(err, ErrActionDecisionRevisionConflict) { + t.Fatalf("stale RecordActionDecision error = %v, want %v", err, ErrActionDecisionRevisionConflict) } } diff --git a/pkg/auth/permissions.go b/pkg/auth/permissions.go index 3020342d7..d5aa18e1b 100644 --- a/pkg/auth/permissions.go +++ b/pkg/auth/permissions.go @@ -2,10 +2,13 @@ package auth // Standard Actions const ( - ActionRead = "read" - ActionWrite = "write" - ActionDelete = "delete" - ActionAdmin = "admin" + ActionRead = "read" + ActionWrite = "write" + ActionDelete = "delete" + ActionAdmin = "admin" + ActionPlan = "plan" + ActionApprove = "approve" + ActionExecute = "execute" ) // Standard Resources @@ -17,4 +20,5 @@ const ( ResourceLicense = "license" ResourceAI = "ai" ResourceDiscovery = "discovery" + ResourceActions = "actions" ) diff --git a/pkg/auth/scopes.go b/pkg/auth/scopes.go index de0a8c906..3e42abbe6 100644 --- a/pkg/auth/scopes.go +++ b/pkg/auth/scopes.go @@ -20,6 +20,9 @@ const ( ScopeRelayMobileAccess = "relay:mobile:access" ScopeAgentExec = "agent:exec" ScopeAgentEnroll = "agent:enroll" + ScopeActionsPlan = "actions:plan" + ScopeActionsApprove = "actions:approve" + ScopeActionsExecute = "actions:execute" ) // AllKnownScopes enumerates canonical scopes, excluding the wildcard sentinel. @@ -41,4 +44,7 @@ var AllKnownScopes = []string{ ScopeRelayMobileAccess, ScopeAgentExec, ScopeAgentEnroll, + ScopeActionsPlan, + ScopeActionsApprove, + ScopeActionsExecute, } diff --git a/pkg/pulsecli/actions.go b/pkg/pulsecli/actions.go index f357b59d3..b8defa48f 100644 --- a/pkg/pulsecli/actions.go +++ b/pkg/pulsecli/actions.go @@ -146,7 +146,7 @@ func newActionsCmd(deps *ActionsDeps) *cobra.Command { planCmd.Flags().StringVar(&opts.ResourceID, "resource-id", "", "canonical unified resource id") planCmd.Flags().StringVar(&opts.CapabilityName, "capability", "", "resource capability name to plan") planCmd.Flags().StringVar(&opts.Reason, "reason", "", "audit reason for the requested action") - planCmd.Flags().StringVar(&opts.RequestedBy, "requested-by", "", "requester identity, for example agent:oncall-helper") + planCmd.Flags().StringVar(&opts.RequestedBy, "requested-by", "", "deprecated compatibility label; server derives requester identity from authentication") planCmd.Flags().StringVar(&opts.ParamsJSON, "params-json", "", "JSON object merged into request params") planCmd.Flags().StringArrayVar(&opts.Params, "param", nil, "request param as key=value; repeatable, values parse as JSON when possible") @@ -286,7 +286,17 @@ func runActionPlan(cmd *cobra.Command, deps *ActionsDeps, opts actionPlanOptions return err } - body, err := json.Marshal(actionReq) + body, err := json.Marshal(struct { + RequestID string `json:"requestId"` + ResourceID string `json:"resourceId"` + CapabilityName string `json:"capabilityName"` + Params map[string]any `json:"params,omitempty"` + Reason string `json:"reason"` + RequestedBy string `json:"requestedBy,omitempty"` + }{ + RequestID: actionReq.RequestID, ResourceID: actionReq.ResourceID, CapabilityName: actionReq.CapabilityName, + Params: actionReq.Params, Reason: actionReq.Reason, RequestedBy: actionReq.RequestedBy, + }) if err != nil { return fmt.Errorf("failed to encode action request: %w", err) } @@ -709,7 +719,6 @@ func validateCLIActionRequest(req unified.ActionRequest) error { {field: "resourceId", value: req.ResourceID, flag: "--resource-id"}, {field: "capabilityName", value: req.CapabilityName, flag: "--capability"}, {field: "reason", value: req.Reason, flag: "--reason"}, - {field: "requestedBy", value: req.RequestedBy, flag: "--requested-by"}, } for _, item := range required { if item.value == "" { diff --git a/pkg/pulsecli/actions_test.go b/pkg/pulsecli/actions_test.go index ca9dc1c38..ffb75455e 100644 --- a/pkg/pulsecli/actions_test.go +++ b/pkg/pulsecli/actions_test.go @@ -122,6 +122,53 @@ func TestActionsPlanCommandRequiresToken(t *testing.T) { } } +func TestActionsPlanCommandAllowsOmittedRequestedByCompatibilityLabel(t *testing.T) { + var received map[string]json.RawMessage + var receivedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&received); err != nil { + t.Fatalf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(unified.ActionPlan{ + ActionID: "act_optional", RequestID: "req-optional", Allowed: true, + PlannedAt: time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC), + ExpiresAt: time.Date(2026, 5, 3, 12, 5, 0, 0, time.UTC), PlanHash: "sha256:optional", + }) + })) + defer server.Close() + + cmd := newTestActionsRootCommand(map[string]string{ + "PULSE_API_TOKEN": "test-token", + "PULSE_API_URL": server.URL, + }) + cmd.SetArgs([]string{ + "actions", "plan", + "--request-id", "req-optional", + "--resource-id", "vm:42", + "--capability", "restart", + "--reason", "Recover", + }) + cmd.SetOut(io.Discard) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute actions plan without requested-by: %v", err) + } + if receivedAuth != "Bearer test-token" { + t.Fatalf("Authorization = %q", receivedAuth) + } + if _, present := received["requestedBy"]; present { + t.Fatalf("optional compatibility label unexpectedly emitted: %s", received["requestedBy"]) + } + planCmd, _, err := cmd.Find([]string{"actions", "plan"}) + if err != nil { + t.Fatalf("find actions plan command: %v", err) + } + flag := planCmd.Flags().Lookup("requested-by") + if flag == nil || !strings.Contains(flag.Usage, "deprecated compatibility label") || !strings.Contains(flag.Usage, "server derives requester identity") { + t.Fatalf("requested-by flag does not document non-authoritative compatibility semantics: %#v", flag) + } +} + func TestActionsDecideCommandPostsApprovalDecision(t *testing.T) { now := time.Date(2026, 5, 4, 14, 30, 0, 0, time.UTC) var receivedAuth string @@ -590,10 +637,18 @@ func TestActionsEventsCommandRequiresActionID(t *testing.T) { func TestActionsPlanCommandUsesRequestFileFromStdin(t *testing.T) { var received unified.ActionRequest + var receivedBody map[string]json.RawMessage server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := json.NewDecoder(r.Body).Decode(&received); err != nil { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read request: %v", err) + } + if err := json.Unmarshal(body, &received); err != nil { t.Fatalf("decode request: %v", err) } + if err := json.Unmarshal(body, &receivedBody); err != nil { + t.Fatalf("decode raw request: %v", err) + } _ = json.NewEncoder(w).Encode(unified.ActionPlan{ ActionID: "act_stdin", RequestID: "req-file", @@ -618,7 +673,13 @@ func TestActionsPlanCommandUsesRequestFileFromStdin(t *testing.T) { "resourceId": "vm:42", "capabilityName": "restart", "reason": "Recover", - "requestedBy": "agent:file" + "requestedBy": "agent:file", + "actor": { + "subjectId": "forged-user", + "kind": "user", + "credentialId": "session:forged", + "orgId": "other-org" + } }`)) cmd.SetOut(io.Discard) @@ -628,6 +689,9 @@ func TestActionsPlanCommandUsesRequestFileFromStdin(t *testing.T) { if received.RequestID != "req-file" || received.RequestedBy != "agent:file" { t.Fatalf("received request = %+v", received) } + if _, present := receivedBody["actor"]; present || received.Actor.SubjectID != "" { + t.Fatalf("server-owned actor leaked from request-file input: body=%s actor=%+v", receivedBody["actor"], received.Actor) + } } func newTestActionsRootCommand(env map[string]string) *cobra.Command {