mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Enforce server-owned action approval authority
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ package actionlifecycle
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -61,28 +62,71 @@ func serviceForStore(t *testing.T, store unified.ResourceStore, resource unified
|
||||
registry := unified.NewRegistry(store)
|
||||
registry.IngestResources([]unified.Resource{resource})
|
||||
return &Service{
|
||||
Registry: func(string) (*unified.ResourceRegistry, error) { return registry, nil },
|
||||
Store: func(string) (Store, error) { return store, nil },
|
||||
Executor: executor,
|
||||
Registry: func(string) (*unified.ResourceRegistry, error) { return registry, nil },
|
||||
Store: func(string) (Store, error) { return store, nil },
|
||||
Executor: executor,
|
||||
DecisionAuthorizer: DecisionAuthorizerFunc(func(context.Context, string, unified.ActionAuditRecord, unified.ActionDecision) error { return nil }),
|
||||
ExecutionAuthorizer: ExecutionAuthorizerFunc(func(context.Context, string, unified.ActionAuditRecord, unified.ActionActor) error { return nil }),
|
||||
}
|
||||
}
|
||||
|
||||
func testActionActor(subject, orgID string) unified.ActionActor {
|
||||
if strings.TrimSpace(subject) == "" {
|
||||
subject = "operator"
|
||||
}
|
||||
return unified.ActionActor{SubjectID: subject, Kind: unified.ActionActorUser, CredentialID: "session:test", OrgID: orgID}
|
||||
}
|
||||
|
||||
func testActionDecision(t *testing.T, service *Service, orgID, actionID string, approval unified.ActionApprovalRecord) unified.ActionDecision {
|
||||
t.Helper()
|
||||
record, found, _ := service.Get(orgID, actionID)
|
||||
planHash := "sha256:test"
|
||||
if found {
|
||||
planHash = record.Plan.PlanHash
|
||||
}
|
||||
actor := testActionActor(approval.Actor, orgID)
|
||||
return unified.ActionDecision{
|
||||
Actor: actor,
|
||||
Outcome: approval.Outcome,
|
||||
Reason: approval.Reason,
|
||||
Evidence: unified.ApprovalEvidence{
|
||||
Version: 1, Method: unified.MethodSession, Actor: actor, OrgID: orgID,
|
||||
ActionID: actionID, PlanHash: planHash, Outcome: approval.Outcome, IssuedAt: time.Now().UTC(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testBoundLifecycleApproval(record unified.ActionAuditRecord, subject string, method unified.ApprovalMethod, outcome unified.ApprovalOutcome, at time.Time) unified.ActionApprovalRecord {
|
||||
kind := unified.ActionActorUser
|
||||
credential := "session:test"
|
||||
if method == unified.MethodPolicy {
|
||||
kind = unified.ActionActorPolicy
|
||||
credential = "policy:test"
|
||||
}
|
||||
actor := unified.ActionActor{SubjectID: subject, Kind: kind, CredentialID: credential, OrgID: "default"}
|
||||
evidence := unified.ApprovalEvidence{Version: 1, Method: method, Actor: actor, OrgID: "default", ActionID: record.ID, PlanHash: record.Plan.PlanHash, Outcome: outcome, IssuedAt: at}
|
||||
return unified.ActionApprovalRecord{Actor: subject, ActorBinding: actor, Method: method, Timestamp: at, Outcome: outcome, Evidence: &evidence}
|
||||
}
|
||||
|
||||
func runConcurrentPlanReplayCannotRewindTerminalAction(t *testing.T, store unified.ResourceStore) {
|
||||
t.Helper()
|
||||
delayed := &delayedCreateStore{ResourceStore: store, secondArrived: make(chan struct{}), releaseSecond: make(chan struct{})}
|
||||
executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}}
|
||||
service := serviceForStore(t, delayed, testResource(time.Now().UTC(), unified.ApprovalAdmin), executor)
|
||||
firstPlan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
firstPlan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondResult := make(chan error, 1)
|
||||
go func() { _, err := service.Plan(context.Background(), "default", restartRequest()); secondResult <- err }()
|
||||
go func() {
|
||||
_, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
secondResult <- err
|
||||
}()
|
||||
<-delayed.secondArrived
|
||||
if _, err := service.Decide(context.Background(), "default", firstPlan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved}); err != nil {
|
||||
if _, err := service.Decide(context.Background(), "default", firstPlan.ActionID, testActionDecision(t, service, "default", firstPlan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Execute(context.Background(), "default", firstPlan.ActionID, "operator", "proof"); err != nil {
|
||||
if _, err := service.Execute(context.Background(), "default", firstPlan.ActionID, testActionActor("operator", "default"), "proof"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(delayed.releaseSecond)
|
||||
@@ -115,17 +159,17 @@ func runConcurrentExecuteAdmitsExecutorExactlyOnce(t *testing.T, store unified.R
|
||||
t.Helper()
|
||||
executor := &blockingExecutor{entered: make(chan struct{}), release: make(chan struct{})}
|
||||
service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalNone), executor)
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "first")
|
||||
_, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "first")
|
||||
first <- err
|
||||
}()
|
||||
<-executor.entered
|
||||
secondRecord, secondErr := service.Execute(context.Background(), "default", plan.ActionID, "operator", "second")
|
||||
secondRecord, secondErr := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "second")
|
||||
if secondErr != nil {
|
||||
t.Fatalf("duplicate Execute: %v", secondErr)
|
||||
}
|
||||
@@ -159,17 +203,17 @@ func runConcurrentDuplicateExecuteAtClaimBoundaryCallsExecutorOnce(t *testing.T,
|
||||
blocked := &claimBoundaryStore{ResourceStore: store, entered: make(chan struct{}), release: make(chan struct{})}
|
||||
executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}}
|
||||
service := serviceForStore(t, blocked, testResource(time.Now().UTC(), unified.ApprovalNone), executor)
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := make(chan error, 1)
|
||||
go func() {
|
||||
_, executeErr := service.Execute(context.Background(), "default", plan.ActionID, "operator", "first")
|
||||
_, executeErr := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "first")
|
||||
first <- executeErr
|
||||
}()
|
||||
<-blocked.entered
|
||||
second, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "duplicate")
|
||||
second, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "duplicate")
|
||||
if err != nil || second.State != unified.ActionStateExecuting {
|
||||
t.Fatalf("duplicate=%#v err=%v", second, err)
|
||||
}
|
||||
@@ -201,7 +245,7 @@ func TestPlanReplayReturnsAuthoritativeApprovedExecutingAndTerminalRecords(t *te
|
||||
t.Run(string(state), func(t *testing.T) {
|
||||
store := unified.NewMemoryStore()
|
||||
service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalAdmin), &stubExecutor{})
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,7 +254,8 @@ func TestPlanReplayReturnsAuthoritativeApprovedExecutingAndTerminalRecords(t *te
|
||||
if state == unified.ActionStateRejected {
|
||||
decisionOutcome = unified.OutcomeRejected
|
||||
}
|
||||
decided, decisionEvent, err := unified.ApplyActionDecision(record, unified.ActionApprovalRecord{Actor: "operator", Outcome: decisionOutcome}, time.Now().UTC())
|
||||
decisionAt := time.Now().UTC()
|
||||
decided, decisionEvent, err := unified.ApplyActionDecision(record, testBoundLifecycleApproval(record, "operator", unified.MethodSession, decisionOutcome, decisionAt), decisionAt)
|
||||
if err != nil || store.RecordActionDecision(decided, decisionEvent) != nil {
|
||||
t.Fatalf("decision: %v", err)
|
||||
}
|
||||
@@ -227,7 +272,7 @@ func TestPlanReplayReturnsAuthoritativeApprovedExecutingAndTerminalRecords(t *te
|
||||
}
|
||||
}
|
||||
}
|
||||
returned, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
returned, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -245,7 +290,7 @@ func TestPlanReplayReturnsAuthoritativeApprovedExecutingAndTerminalRecords(t *te
|
||||
func TestPlanReplayRejectsConflictingOriginForDeterministicActionID(t *testing.T) {
|
||||
store := unified.NewMemoryStore()
|
||||
service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalAdmin), &stubExecutor{})
|
||||
first := PlanOptions{Origin: &unified.ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "proposal-1"}}
|
||||
first := PlanOptions{Actor: testActionActor("requester", "default"), Origin: &unified.ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "proposal-1"}}
|
||||
if _, err := service.PlanWithOptions(context.Background(), "default", restartRequest(), first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -262,11 +307,11 @@ func TestExecutePublishesPersistedExecutingTransition(t *testing.T) {
|
||||
service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalNone), executor)
|
||||
var states []unified.ActionState
|
||||
service.OnActionTransition = func(_ string, record unified.ActionAuditRecord) { states = append(states, record.State) }
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", ""); err != nil {
|
||||
if _, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []unified.ActionState{unified.ActionStatePlanned, unified.ActionStateExecuting, unified.ActionStateCompleted}
|
||||
@@ -288,7 +333,7 @@ func TestExecuteAfterSQLiteRestartDoesNotReadmitExecutingAction(t *testing.T) {
|
||||
}
|
||||
executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}}
|
||||
service := serviceForStore(t, first, testResource(time.Now().UTC(), unified.ApprovalNone), executor)
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -304,7 +349,7 @@ func TestExecuteAfterSQLiteRestartDoesNotReadmitExecutingAction(t *testing.T) {
|
||||
}
|
||||
defer second.Close()
|
||||
service = serviceForStore(t, second, testResource(time.Now().UTC(), unified.ApprovalNone), executor)
|
||||
current, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "retry")
|
||||
current, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "retry")
|
||||
if err != nil || current.State != unified.ActionStateExecuting || executor.calls != 0 {
|
||||
t.Fatalf("current=%#v err=%v calls=%d", current, err, executor.calls)
|
||||
}
|
||||
@@ -314,11 +359,11 @@ func TestExecuteTimeoutAfterSendWaitsForCorrelatedLateResponseWithoutResend(t *t
|
||||
store := unified.NewMemoryStore()
|
||||
executor := &reconcilingExecutor{executeErr: context.DeadlineExceeded, result: &unified.ExecutionResult{Success: true}}
|
||||
service := serviceForStore(t, store, testResource(time.Now().UTC(), unified.ApprovalNone), executor)
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "timeout proof")
|
||||
current, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "timeout proof")
|
||||
if !errors.Is(err, context.DeadlineExceeded) || current.State != unified.ActionStateExecuting {
|
||||
t.Fatalf("current=%#v err=%v", current, err)
|
||||
}
|
||||
@@ -330,12 +375,12 @@ func TestExecuteTimeoutAfterSendWaitsForCorrelatedLateResponseWithoutResend(t *t
|
||||
t.Fatalf("receipt found=%v err=%v", found, err)
|
||||
}
|
||||
// Duplicate execute is a query-only resume and cannot call ExecuteAction.
|
||||
current, err = service.Execute(context.Background(), "default", plan.ActionID, "operator", "duplicate resume")
|
||||
current, err = service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "duplicate resume")
|
||||
if err != nil || current.State != unified.ActionStateExecuting || executor.executeCalls != 1 || executor.reconcileCalls != 1 {
|
||||
t.Fatalf("current=%#v err=%v execute=%d reconcile=%d", current, err, executor.executeCalls, executor.reconcileCalls)
|
||||
}
|
||||
executor.found = true
|
||||
current, err = service.Execute(context.Background(), "default", plan.ActionID, "operator", "late response")
|
||||
current, err = service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "late response")
|
||||
if err != nil || current.State != unified.ActionStateCompleted || executor.executeCalls != 1 || executor.reconcileCalls != 2 {
|
||||
t.Fatalf("current=%#v err=%v execute=%d reconcile=%d", current, err, executor.executeCalls, executor.reconcileCalls)
|
||||
}
|
||||
@@ -353,11 +398,11 @@ func TestSQLiteRestartRecoveryReconcilesReceiptPendingWithoutResend(t *testing.T
|
||||
}
|
||||
executor := &reconcilingExecutor{executeErr: context.DeadlineExceeded, result: &unified.ExecutionResult{Success: true}}
|
||||
service := serviceForStore(t, first, testResource(now, unified.ApprovalNone), executor)
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "before restart"); !errors.Is(err, context.DeadlineExceeded) {
|
||||
if _, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "before restart"); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("Execute error=%v", err)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
@@ -377,7 +422,7 @@ func TestSQLiteRestartRecoveryReconcilesReceiptPendingWithoutResend(t *testing.T
|
||||
if executor.executeCalls != 1 || executor.reconcileCalls != 1 {
|
||||
t.Fatalf("execute=%d reconcile=%d", executor.executeCalls, executor.reconcileCalls)
|
||||
}
|
||||
current, err := service.Execute(context.Background(), "default", plan.ActionID, "operator", "duplicate after recovery")
|
||||
current, err := service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), "duplicate after recovery")
|
||||
if err != nil || current.State != unified.ActionStateCompleted || executor.executeCalls != 1 {
|
||||
t.Fatalf("current=%#v err=%v execute=%d", current, err, executor.executeCalls)
|
||||
}
|
||||
@@ -388,7 +433,7 @@ func TestActionDetailAndInboxMaterializeExplicitExpiry(t *testing.T) {
|
||||
store := unified.NewMemoryStore()
|
||||
service := serviceForStore(t, store, testResource(now, unified.ApprovalAdmin), &stubExecutor{})
|
||||
service.Now = func() time.Time { return now }
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -408,7 +453,7 @@ func TestActionDetailAndInboxMaterializeExplicitExpiry(t *testing.T) {
|
||||
if err != nil || len(settled) != 1 || settled[0].State != unified.ActionStateExpired {
|
||||
t.Fatalf("settled=%#v err=%v", settled, err)
|
||||
}
|
||||
if _, err := service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Outcome: unified.OutcomeApproved}); !errors.Is(err, unified.ErrActionPlanExpired) {
|
||||
if _, err := service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, service, "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Outcome: unified.OutcomeApproved})); !errors.Is(err, unified.ErrActionPlanExpired) {
|
||||
t.Fatalf("Decide error=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -432,7 +477,7 @@ func runPolicyAdmissionCommitsAtomically(t *testing.T, store unified.ResourceSto
|
||||
executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}}
|
||||
service := serviceForStore(t, store, testResource(now, unified.ApprovalAdmin), executor)
|
||||
service.Now = func() time.Time { return now }
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -541,7 +586,7 @@ func runPolicyBarrierRevocations(t *testing.T, storeFactory func(t *testing.T) u
|
||||
executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}}
|
||||
service := serviceForStore(t, store, testResource(now, unified.ApprovalAdmin), executor)
|
||||
service.Now = func() time.Time { return now }
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -586,14 +631,14 @@ func runHumanApprovalSurvivesPolicyRevocation(t *testing.T, store unified.Resour
|
||||
now := time.Now().UTC()
|
||||
executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}}
|
||||
service := serviceForStore(t, store, testResource(now, unified.ApprovalAdmin), executor)
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved}); err != nil {
|
||||
if _, err = service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, service, "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.Execute(context.Background(), "default", plan.ActionID, "operator", ""); err != nil || executor.calls != 1 {
|
||||
if _, err = service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), ""); err != nil || executor.calls != 1 {
|
||||
t.Fatalf("err=%v calls=%d", err, executor.calls)
|
||||
}
|
||||
}
|
||||
@@ -616,19 +661,19 @@ func runEmergencyStopBlocksHumanAndPolicy(t *testing.T, store unified.ResourceSt
|
||||
service.EmergencyStop = func(string) (bool, error) { return true, nil }
|
||||
human := restartRequest()
|
||||
human.RequestID = "human-stop"
|
||||
plan, err := service.Plan(context.Background(), "default", human)
|
||||
plan, err := service.Plan(context.Background(), "default", human, testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved}); err != nil {
|
||||
if _, err = service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, service, "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.Execute(context.Background(), "default", plan.ActionID, "operator", ""); !errors.Is(err, unified.ErrActionEmergencyStop) {
|
||||
if _, err = service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), ""); !errors.Is(err, unified.ErrActionEmergencyStop) {
|
||||
t.Fatalf("human error=%v", err)
|
||||
}
|
||||
policy := restartRequest()
|
||||
policy.RequestID = "policy-stop"
|
||||
policyPlan, err := service.Plan(context.Background(), "default", policy)
|
||||
policyPlan, err := service.Plan(context.Background(), "default", policy, testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -660,13 +705,20 @@ func TestLegacyPolicyApprovalWithoutLeaseFailsClosedAfterRestart(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
service := serviceForStore(t, first, testResource(now, unified.ApprovalAdmin), &stubExecutor{})
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
request := restartRequest()
|
||||
request.Actor = testActionActor("requester", "default")
|
||||
request.RequestedBy = request.Actor.SubjectID
|
||||
plan, err := (actionplanner.Planner{Now: func() time.Time { return now }}).Plan(request, testResource(now, unified.ApprovalAdmin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "pulse_patrol_policy", Method: unified.MethodPolicy, Outcome: unified.OutcomeApproved}); err != nil {
|
||||
t.Fatal(err)
|
||||
legacyApproved := unified.ActionAuditRecord{
|
||||
ID: plan.ActionID, CreatedAt: now, UpdatedAt: now, State: unified.ActionStateApproved,
|
||||
Request: request, Plan: plan,
|
||||
Approvals: []unified.ActionApprovalRecord{{Actor: "pulse_patrol_policy", Method: unified.MethodPolicy, Timestamp: now, Outcome: unified.OutcomeApproved}},
|
||||
}
|
||||
if err := first.RecordActionAudit(legacyApproved); err != nil {
|
||||
t.Fatalf("legacy audit: %v", err)
|
||||
}
|
||||
if err = first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -677,7 +729,7 @@ func TestLegacyPolicyApprovalWithoutLeaseFailsClosedAfterRestart(t *testing.T) {
|
||||
}
|
||||
defer second.Close()
|
||||
executor := &stubExecutor{result: &unified.ExecutionResult{Success: true}}
|
||||
service = serviceForStore(t, second, testResource(now, unified.ApprovalAdmin), executor)
|
||||
service := serviceForStore(t, second, testResource(now, unified.ApprovalAdmin), executor)
|
||||
failed, err := service.ExecuteUnderPolicy(context.Background(), "default", plan.ActionID, "pulse_patrol_policy", func(_ context.Context, record unified.ActionAuditRecord, at time.Time) (unified.ActionPolicyAuthorizationLease, string, error) {
|
||||
return policyTestLease(record, at), "policy", nil
|
||||
})
|
||||
@@ -694,7 +746,7 @@ func TestQueuedPolicyActionRevalidatesAfterSQLiteRestart(t *testing.T) {
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
service := serviceForStore(t, first, testResource(now, unified.ApprovalAdmin), &stubExecutor{})
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -813,7 +865,9 @@ func newServiceEnv(t *testing.T, resource unified.Resource) *serviceEnv {
|
||||
}
|
||||
return env.store, nil
|
||||
},
|
||||
Executor: env.executor,
|
||||
Executor: env.executor,
|
||||
DecisionAuthorizer: DecisionAuthorizerFunc(func(context.Context, string, unified.ActionAuditRecord, unified.ActionDecision) error { return nil }),
|
||||
ExecutionAuthorizer: ExecutionAuthorizerFunc(func(context.Context, string, unified.ActionAuditRecord, unified.ActionActor) error { return nil }),
|
||||
OnActionCompleted: func(record unified.ActionAuditRecord) {
|
||||
env.completed = append(env.completed, record)
|
||||
},
|
||||
@@ -836,7 +890,7 @@ func TestPlanPersistsPendingAuditAndLifecycle(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
@@ -860,7 +914,7 @@ func TestPlanPersistsPendingAuditAndLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// Idempotent replan must not duplicate lifecycle events.
|
||||
if _, err := env.service.Plan(context.Background(), "default", restartRequest()); err != nil {
|
||||
if _, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")); err != nil {
|
||||
t.Fatalf("replan: %v", err)
|
||||
}
|
||||
events, err = env.store.GetActionLifecycleEvents(plan.ActionID, time.Time{}, 10)
|
||||
@@ -879,13 +933,13 @@ func TestPlanFailsClosedOnUnknownResourceAndCapability(t *testing.T) {
|
||||
missing := restartRequest()
|
||||
missing.ResourceID = "vm:404"
|
||||
var notFound *ResourceNotFoundError
|
||||
if _, err := env.service.Plan(context.Background(), "default", missing); !errors.As(err, ¬Found) {
|
||||
if _, err := env.service.Plan(context.Background(), "default", missing, testActionActor("requester", "default")); !errors.As(err, ¬Found) {
|
||||
t.Fatalf("unknown resource error = %v, want ResourceNotFoundError", err)
|
||||
}
|
||||
|
||||
unknownCap := restartRequest()
|
||||
unknownCap.CapabilityName = "detonate"
|
||||
_, err := env.service.Plan(context.Background(), "default", unknownCap)
|
||||
_, err := env.service.Plan(context.Background(), "default", unknownCap, testActionActor("requester", "default"))
|
||||
var capErr *CapabilityNotFoundError
|
||||
if !errors.As(err, &capErr) || !errors.Is(err, actionplanner.ErrCapabilityNotFound) {
|
||||
t.Fatalf("unknown capability error = %v, want CapabilityNotFoundError wrapping ErrCapabilityNotFound", err)
|
||||
@@ -897,7 +951,7 @@ func TestPlanFailsClosedOnUnknownResourceAndCapability(t *testing.T) {
|
||||
empty := restartRequest()
|
||||
empty.ResourceID = " "
|
||||
var validation *actionplanner.ValidationError
|
||||
if _, err := env.service.Plan(context.Background(), "default", empty); !errors.As(err, &validation) {
|
||||
if _, err := env.service.Plan(context.Background(), "default", empty, testActionActor("requester", "default")); !errors.As(err, &validation) {
|
||||
t.Fatalf("empty resource id error = %v, want ValidationError", err)
|
||||
}
|
||||
|
||||
@@ -920,7 +974,7 @@ func TestPlanAvailabilityRefusalPersistsNothing(t *testing.T) {
|
||||
Reason: "no connected command agent",
|
||||
}
|
||||
|
||||
_, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
_, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
var refused *AvailabilityRefusedError
|
||||
if !errors.As(err, &refused) {
|
||||
t.Fatalf("error = %v, want AvailabilityRefusedError", err)
|
||||
@@ -941,17 +995,18 @@ func TestDecideApprovesPendingAction(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
|
||||
updated, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{
|
||||
updated, err := env.service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, env.service, "default", plan.ActionID, unified.ActionApprovalRecord{
|
||||
Actor: "operator@example.com",
|
||||
Method: unified.MethodAPI,
|
||||
Outcome: unified.OutcomeApproved,
|
||||
Reason: "confirmed outage",
|
||||
})
|
||||
}))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
@@ -963,9 +1018,9 @@ func TestDecideApprovesPendingAction(t *testing.T) {
|
||||
}
|
||||
|
||||
var notFound *ActionNotFoundError
|
||||
if _, err := env.service.Decide(context.Background(), "default", "act_missing", unified.ActionApprovalRecord{
|
||||
if _, err := env.service.Decide(context.Background(), "default", "act_missing", testActionDecision(t, env.service, "default", "act_missing", unified.ActionApprovalRecord{
|
||||
Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved,
|
||||
}); !errors.As(err, ¬Found) {
|
||||
})); !errors.As(err, ¬Found) {
|
||||
t.Fatalf("unknown action error = %v, want ActionNotFoundError", err)
|
||||
}
|
||||
}
|
||||
@@ -974,17 +1029,17 @@ func TestExecuteRunsApprovedActionToTerminalAudit(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{
|
||||
if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, env.service, "default", plan.ActionID, unified.ActionApprovalRecord{
|
||||
Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved,
|
||||
}); err != nil {
|
||||
})); err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
|
||||
completed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "operator@example.com", "approved restart")
|
||||
completed, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator@example.com", "default"), "approved restart")
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
@@ -1011,11 +1066,11 @@ func TestExecuteRefusesUnapprovedActionWithoutDispatch(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "operator@example.com", ""); !errors.Is(err, unified.ErrActionNotApproved) {
|
||||
if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator@example.com", "default"), ""); !errors.Is(err, unified.ErrActionNotApproved) {
|
||||
t.Fatalf("error = %v, want ErrActionNotApproved", err)
|
||||
}
|
||||
if env.executor.calls != 0 {
|
||||
@@ -1027,7 +1082,7 @@ func TestExecuteRefusesRemediationLockedResource(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalNone))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
@@ -1038,7 +1093,7 @@ func TestExecuteRefusesRemediationLockedResource(t *testing.T) {
|
||||
t.Fatalf("SetResourceOperatorState: %v", err)
|
||||
}
|
||||
|
||||
failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", "")
|
||||
failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("agent:test", "default"), "")
|
||||
if !errors.Is(err, unified.ErrResourceRemediationLocked) {
|
||||
t.Fatalf("error = %v, want ErrResourceRemediationLocked", err)
|
||||
}
|
||||
@@ -1057,7 +1112,7 @@ func TestExecuteRefusesDriftedPlan(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalNone))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
@@ -1068,7 +1123,7 @@ func TestExecuteRefusesDriftedPlan(t *testing.T) {
|
||||
env.registry = unified.NewRegistry(env.store)
|
||||
env.registry.IngestResources([]unified.Resource{drifted})
|
||||
|
||||
failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", "")
|
||||
failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("agent:test", "default"), "")
|
||||
if !errors.Is(err, unified.ErrActionPlanDrift) {
|
||||
t.Fatalf("error = %v, want ErrActionPlanDrift", err)
|
||||
}
|
||||
@@ -1084,12 +1139,12 @@ func TestExecuteFailsClosedWithoutExecutor(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalNone))
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
env.service.Executor = nil
|
||||
if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", ""); !errors.Is(err, ErrExecutorUnavailable) {
|
||||
if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("agent:test", "default"), ""); !errors.Is(err, ErrExecutorUnavailable) {
|
||||
t.Fatalf("error = %v, want ErrExecutorUnavailable", err)
|
||||
}
|
||||
}
|
||||
@@ -1099,13 +1154,13 @@ func TestLifecycleFailsClosedWithoutStore(t *testing.T) {
|
||||
env := newServiceEnv(t, testResource(now, unified.ApprovalNone))
|
||||
env.service.Store = func(string) (Store, error) { return nil, errors.New("db offline") }
|
||||
|
||||
if _, err := env.service.Plan(context.Background(), "default", restartRequest()); !errors.Is(err, ErrStoreUnavailable) {
|
||||
if _, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")); !errors.Is(err, ErrStoreUnavailable) {
|
||||
t.Fatalf("Plan error = %v, want ErrStoreUnavailable", err)
|
||||
}
|
||||
if _, err := env.service.Decide(context.Background(), "default", "act_x", unified.ActionApprovalRecord{Outcome: unified.OutcomeApproved}); !errors.Is(err, ErrStoreUnavailable) {
|
||||
if _, err := env.service.Decide(context.Background(), "default", "act_x", testActionDecision(t, env.service, "default", "act_x", unified.ActionApprovalRecord{Outcome: unified.OutcomeApproved})); !errors.Is(err, ErrStoreUnavailable) {
|
||||
t.Fatalf("Decide error = %v, want ErrStoreUnavailable", err)
|
||||
}
|
||||
if _, err := env.service.Execute(context.Background(), "default", "act_x", "agent:test", ""); !errors.Is(err, ErrStoreUnavailable) {
|
||||
if _, err := env.service.Execute(context.Background(), "default", "act_x", testActionActor("agent:test", "default"), ""); !errors.Is(err, ErrStoreUnavailable) {
|
||||
t.Fatalf("Execute error = %v, want ErrStoreUnavailable", err)
|
||||
}
|
||||
}
|
||||
@@ -1138,7 +1193,7 @@ func TestPlanWithOptionsPersistsOriginAcrossLifecycle(t *testing.T) {
|
||||
InvestigationID: "inv-9",
|
||||
ProposalID: "prop-9",
|
||||
}
|
||||
plan, err := env.service.PlanWithOptions(context.Background(), "default", restartRequest(), PlanOptions{Origin: origin})
|
||||
plan, err := env.service.PlanWithOptions(context.Background(), "default", restartRequest(), PlanOptions{Actor: testActionActor("requester", "default"), Origin: origin})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanWithOptions: %v", err)
|
||||
}
|
||||
@@ -1154,9 +1209,10 @@ func TestPlanWithOptionsPersistsOriginAcrossLifecycle(t *testing.T) {
|
||||
// Origin must survive the decision transition: the decision persists
|
||||
// the record loaded from the store, so the broker-owned metadata
|
||||
// stays reconcilable at approval time.
|
||||
updated, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{
|
||||
updated, err := env.service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, env.service, "default", plan.ActionID, unified.ActionApprovalRecord{
|
||||
Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved,
|
||||
})
|
||||
}))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
@@ -1167,7 +1223,7 @@ func TestPlanWithOptionsPersistsOriginAcrossLifecycle(t *testing.T) {
|
||||
// Plain Plan must never stamp an origin.
|
||||
plain := restartRequest()
|
||||
plain.RequestID = "req-plain"
|
||||
plainPlan, err := env.service.Plan(context.Background(), "default", plain)
|
||||
plainPlan, err := env.service.Plan(context.Background(), "default", plain, testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
@@ -1190,16 +1246,16 @@ func TestOnActionTransitionFiresAfterEachPersistedState(t *testing.T) {
|
||||
transitionOrgs = append(transitionOrgs, orgID)
|
||||
}
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, unified.ActionApprovalRecord{
|
||||
if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, env.service, "default", plan.ActionID, unified.ActionApprovalRecord{
|
||||
Actor: "operator@example.com", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved,
|
||||
}); err != nil {
|
||||
})); err != nil {
|
||||
t.Fatalf("Decide: %v", err)
|
||||
}
|
||||
if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "operator@example.com", ""); err != nil {
|
||||
if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator@example.com", "default"), ""); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
@@ -1225,7 +1281,7 @@ func TestOnActionTransitionFiresForPersistedRefusals(t *testing.T) {
|
||||
transitions = append(transitions, record.State)
|
||||
}
|
||||
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest())
|
||||
plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
|
||||
if err != nil {
|
||||
t.Fatalf("Plan: %v", err)
|
||||
}
|
||||
@@ -1235,7 +1291,7 @@ func TestOnActionTransitionFiresForPersistedRefusals(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("SetResourceOperatorState: %v", err)
|
||||
}
|
||||
if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, "agent:test", ""); !errors.Is(err, unified.ErrResourceRemediationLocked) {
|
||||
if _, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("agent:test", "default"), ""); !errors.Is(err, unified.ErrResourceRemediationLocked) {
|
||||
t.Fatalf("error = %v, want ErrResourceRemediationLocked", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user