Enforce server-owned action approval authority

This commit is contained in:
rcourtman
2026-07-11 19:49:17 +01:00
parent e5dbbb3145
commit cd7886aa44
50 changed files with 3125 additions and 616 deletions
+113 -2
View File
@@ -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",
@@ -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`
@@ -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.
@@ -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.
@@ -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.
@@ -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
@@ -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.
@@ -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.
@@ -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
@@ -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
@@ -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")
}
}
+278 -47
View File
@@ -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
+138 -82
View File
@@ -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, &notFound) {
if _, err := env.service.Plan(context.Background(), "default", missing, testActionActor("requester", "default")); !errors.As(err, &notFound) {
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, &notFound) {
})); !errors.As(err, &notFound) {
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)
}
+42 -17
View File
@@ -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,
+5
View File
@@ -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 {
+8
View File
@@ -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"
+18 -4
View File
@@ -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 }",
+14 -3
View File
@@ -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,
},
},
}
+1 -1
View File
@@ -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)
}
+199
View File
@@ -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)
}
}
+163
View File
@@ -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)
}
}
+81 -29
View File
@@ -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"))
}
+134 -103
View File
@@ -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())
@@ -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()
+3 -3
View File
@@ -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()
+21 -20
View File
@@ -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)
}
+6 -6
View File
@@ -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)
@@ -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
}
+143 -93
View File
@@ -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()
@@ -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())
}
+6
View File
@@ -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,
+3 -3
View File
@@ -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())
}
@@ -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{
+16 -6
View File
@@ -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 {
+15 -8
View File
@@ -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",
+22 -15
View File
@@ -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
+3
View File
@@ -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 {
+8 -7
View File
@@ -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)))
@@ -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,
}
+3
View File
@@ -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).
@@ -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
}
+449 -19
View File
@@ -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
}
+26 -16
View File
@@ -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") {
@@ -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",
},
}
+176 -45
View File
@@ -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
+79 -39
View File
@@ -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)
}
}
+8 -4
View File
@@ -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"
)
+6
View File
@@ -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,
}
+12 -3
View File
@@ -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 == "" {
+66 -2
View File
@@ -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 {