diff --git a/README.md b/README.md index 95c3bdb9..89527725 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,9 @@ places the decision outside the model. text can change what the model wants to do. It cannot change what the model is allowed to do. - **Slots.** A session commits to the specific resource it is working on, as a - relationship that can be written only once. It cannot drift to another - resource even when its credentials would reach one. + pinned relationship. A different resource is refused unless a human approves a + plan amendment that moves the pin and revokes the old resource's access — and + a slot set `rebind: never` admits no move at all. - **Approvals the model cannot reword.** The platform generates the description of the action from the call itself. The model contributes only its reasoning, so it cannot restate the action in different terms. @@ -367,10 +368,11 @@ For architecture and implementation: ## Defense in depth, enforced by code To misuse an OAP agent, an attacker has to get past a plan a human approved, a -slot that cannot be reopened, a tool lens that cannot be widened, an -authorization check on every call, and a sandbox that never held the credential -in the first place. None of them is sufficient alone, and each is built on the -assumption that the others may fail. +slot that cannot be silently reopened — a different instance needs a human's +approval, and `rebind: never` needs a new session — a tool lens that cannot be +widened, an authorization check on every call, and a sandbox that never held the +credential in the first place. None of them is sufficient alone, and each is +built on the assumption that the others may fail. ## Contributing diff --git a/cmd/oap/internal/sessioncmd/show.go b/cmd/oap/internal/sessioncmd/show.go index 11ef67d6..5638c9fb 100644 --- a/cmd/oap/internal/sessioncmd/show.go +++ b/cmd/oap/internal/sessioncmd/show.go @@ -6,6 +6,7 @@ import ( "io" "strings" + humanize "github.com/dustin/go-humanize" "github.com/spf13/cobra" "sigs.k8s.io/controller-runtime/pkg/client" @@ -112,6 +113,24 @@ func newSessionShowCmd(g *apcmd.Globals) *cobra.Command { } } + // Slot pins: display-only mirror of SpiceDB's slot_pin relation (see + // spiceboxv1alpha1.SlotPin's doc comment) — one line per + // single-occupancy resource type the session is currently pinned to. + if len(s.Status.SlotPins) > 0 { + fmt.Fprintln(out, "Slot pins:") + for _, p := range s.Status.SlotPins { + if p.MovedBy != "" { + when := "-" + if p.MovedAt != nil && !p.MovedAt.IsZero() { + when = humanize.Time(p.MovedAt.Time) + } + fmt.Fprintf(out, " %s → %s (moved %s by %s)\n", p.ResourceType, p.ResourceID, when, p.MovedBy) + } else { + fmt.Fprintf(out, " %s → %s\n", p.ResourceType, p.ResourceID) + } + } + } + // Operations summary (built from ToolCall labels — no memory fetch). renderOperationsSummary(cmd.Context(), out, b, s.Name) diff --git a/config/crds/agentprimitives.authzed.com_agentclasses.yaml b/config/crds/agentprimitives.authzed.com_agentclasses.yaml index b2d99f3c..d5f1ab3b 100644 --- a/config/crds/agentprimitives.authzed.com_agentclasses.yaml +++ b/config/crds/agentprimitives.authzed.com_agentclasses.yaml @@ -694,6 +694,30 @@ spec: - dynamic - frozen type: string + occupancy: + default: single + description: |- + Occupancy decides how many instances may occupy this slot at once. + + single (default): at most one instance occupies the slot for the + session's life, recorded as a non-expiring slot_pin relationship. A + bind to a DIFFERENT instance is refused rather than silently added. + A second permission on the SAME instance, and a re-grant of the same + instance after its grants expired, are not drift and still bind. + + multi: the slot binds a set; each addition is gated as it is today. + No pin is written. + + UPGRADE NOTE. Before this field existed every slot behaved as multi, and a + class written then has no occupancy set, so the apiserver now defaults it + to single. A slot listing two or more defaults is then refused + (SlotDeclarationInvalid), and a thread seed or trigger binding several + instances of one type is refused rather than binding them all. Set + occupancy: multi on such slots before upgrading. + enum: + - single + - multi + type: string permission: minLength: 1 pattern: ^[a-z][a-z0-9_]*$ @@ -720,6 +744,21 @@ spec: type: string type: array x-kubernetes-list-type: atomic + rebind: + default: approval + description: |- + Rebind decides what a bind to a DIFFERENT instance does on a filled + single-occupancy slot. Meaningful only when occupancy is single + (as triggerInstance is meaningful only with fillFrom "trigger"). + + approval (default): refused, and the refusal names the route — an + approved plan amendment naming the new instance moves the pin and + revokes the old instance's grants. + never: refused unconditionally; retargeting requires a new session. + enum: + - approval + - never + type: string requires: description: |- Requires are predicates over the facts a candidate arrived with, all of @@ -1017,6 +1056,30 @@ spec: - dynamic - frozen type: string + occupancy: + default: single + description: |- + Occupancy decides how many instances may occupy this slot at once. + + single (default): at most one instance occupies the slot for the + session's life, recorded as a non-expiring slot_pin relationship. A + bind to a DIFFERENT instance is refused rather than silently added. + A second permission on the SAME instance, and a re-grant of the same + instance after its grants expired, are not drift and still bind. + + multi: the slot binds a set; each addition is gated as it is today. + No pin is written. + + UPGRADE NOTE. Before this field existed every slot behaved as multi, and a + class written then has no occupancy set, so the apiserver now defaults it + to single. A slot listing two or more defaults is then refused + (SlotDeclarationInvalid), and a thread seed or trigger binding several + instances of one type is refused rather than binding them all. Set + occupancy: multi on such slots before upgrading. + enum: + - single + - multi + type: string permission: minLength: 1 pattern: ^[a-z][a-z0-9_]*$ @@ -1043,6 +1106,21 @@ spec: type: string type: array x-kubernetes-list-type: atomic + rebind: + default: approval + description: |- + Rebind decides what a bind to a DIFFERENT instance does on a filled + single-occupancy slot. Meaningful only when occupancy is single + (as triggerInstance is meaningful only with fillFrom "trigger"). + + approval (default): refused, and the refusal names the route — an + approved plan amendment naming the new instance moves the pin and + revokes the old instance's grants. + never: refused unconditionally; retargeting requires a new session. + enum: + - approval + - never + type: string requires: description: |- Requires are predicates over the facts a candidate arrived with, all of diff --git a/config/crds/agentprimitives.authzed.com_agentsessions.yaml b/config/crds/agentprimitives.authzed.com_agentsessions.yaml index 00984ca9..909c8694 100644 --- a/config/crds/agentprimitives.authzed.com_agentsessions.yaml +++ b/config/crds/agentprimitives.authzed.com_agentsessions.yaml @@ -4340,6 +4340,63 @@ spec: gone. Diagnostic + drives the reap decision's "already slept" guard. format: date-time type: string + slotPins: + description: |- + SlotPins is a DISPLAY-ONLY observation, one entry per single-occupancy + resource type, mirroring the instance SpiceDB's slot_pin relation + currently pins this session to. SpiceDB is the enforcement source of + truth for single-occupancy pinning; nothing that authorizes a bind or a + tool call reads this field. See SlotPin's doc comment for what writes + it and what MovedBy/MovedAt mean. + items: + description: |- + SlotPin is a DISPLAY-ONLY mirror of one single-occupancy slot's current + commitment — the instance SpiceDB's slot_pin relation pins the session to + for ResourceType, written by authz.GrantSlots (via SlotPinner.EnsurePin) on + a first bind and repointed by authz.BindApproved's move path + (SlotPinner.MovePin) when an approved plan moves it. + + SpiceDB is the enforcement source of truth: the slot_pin tuple is what a + tool-call Check and GrantSlots' own single-occupancy gate actually consult. + Nothing that authorizes a bind or a call reads this field — it exists so + `oap session show` and operator logs can see the commitment without a live + SpiceDB read, and it can lag or be absent (e.g. no status writer wired) + without changing what is actually enforced. + properties: + movedAt: + description: |- + MovedAt is when MovedBy's move landed on this pin. Empty for a pin that + has only ever been first-filled. + format: date-time + type: string + movedBy: + description: |- + MovedBy is the approval-record reference that authorized the most + recent MOVE off a prior instance — empty for a pin that has only ever + been first-filled. Populated only by the plan-gate approval path + (pkg/agent/runner's narrowToApproved), from the plan-gate audit record + that carried the approval: its PhaseKey, or PlanDigest+PhaseIndex when + PhaseKey is unset on an older record. + type: string + resourceID: + description: ResourceID is the instance ResourceType is currently + pinned to. + type: string + resourceType: + description: |- + ResourceType is the slot's declared resource type, and the key + UpsertSlotPin replaces by — a single-occupancy slot holds at most one + instance per type for the session's life, so one entry per type is + always the complete picture. + type: string + required: + - resourceID + - resourceType + type: object + type: array + x-kubernetes-list-map-keys: + - resourceType + x-kubernetes-list-type: map startApprovalParkedAt: description: |- StartApprovalParkedAt is when the session entered AwaitingStartApproval diff --git a/docs/security-model.md b/docs/security-model.md index 753e50d6..e103ac9d 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -181,24 +181,90 @@ Implemented in: `pkg/authz/plangate`, `pkg/agent/runner/plangate_*.go`, ### Slots -**A slot binds a session to a specific resource, and once filled it cannot be -changed.** +**A slot binds a session to a specific resource. A single-occupancy slot cannot +be silently reopened to a different one — a different instance needs a human's +approval, and a slot configured `rebind: never` needs a new session.** Credentials are usually broader than any single task. A GitHub token might reach dozens of repositories when the request at hand concerns one. A slot is a named -placeholder for a resource, filled at plan time or on first encounter, and the -commitment is recorded as a SpiceDB relationship whose write checks that the -slot is still empty as a precondition. Every later action is checked against the -filled slot, so a session cannot drift to another resource even when its -credentials would allow it. The intended scope of a task becomes an enforced -boundary. - -| Typical agent platform | OAP | -| ------------------------------------------------ | --------------------------------------------------- | -| A session can act anywhere its credentials reach | A session acts only on the resource it committed to | +placeholder for a resource, filled at plan time or on first encounter. By +default a slot is single-occupancy: the first bind writes a non-expiring +`slot_pin` relationship recording the one instance that occupies it, guarded by +an atomic precondition that the slot was still empty. A bind to a different +instance is refused rather than silently added alongside it, so the intended +scope of a task becomes an enforced boundary rather than a hint the agent can +wander past. (A slot can instead be declared `occupancy: multi`, which keeps the +unpinned behavior: each addition is gated on its own and no pin is written.) + +A class whose tools bind a constant, class-fixed instance (a literal +`resourceIDTemplate`, not a per-call `resourceIDExpr`) alongside the chosen +instance under the same resource type needs `occupancy: multi` on that slot — or +a split into two resource types — because the pin treats the constant and the +chosen instance as two distinct instances of one type, and the default +single-occupancy slot refuses the second as drift away from the first. + +**Upgrading changes the default for existing classes.** An AgentClass written +before slot pinning has no `occupancy` field, and the CRD now defaults it to +`single`. Any such slot that lists two or more `defaults` is then rejected as +`SlotDeclarationInvalid`, and a thread seed or trigger that binds several +instances of one type is refused instead of binding them all. Before upgrading, +set `occupancy: multi` on every slot that is meant to hold more than one +instance. + +The refusal names a way out, and which way depends on the slot's `rebind` +setting. The default, `rebind: approval`, routes to a plan amendment: the agent +proposes a plan naming the new instance, and if a human approves it, the +approval moves the pin and revokes the old instance's grants in the same atomic +write — the approval card shows the move as current → proposed, with a line +stating plainly that approving it revokes the session's access to the current +instance. `rebind: never` admits no such move; the refusal is unconditional and +retargeting the slot means starting a new session. Neither mode is tripped by a +second permission check on the already-pinned instance, or by a re-grant of that +same instance after its prior grant expired — those are not drift, and they bind +without needing approval. + +One honest limitation: the plan-amendment route exists only for a class that +runs behind the plan gate. A class without it has nowhere to propose the +amendment, so for such a class `rebind: approval` behaves like a refusal whose +only remedy is the same as `rebind: never`'s — a new session for the different +instance. + +A grant and a pin carry different things and have different lifetimes. The grant +is the actual authority to call tools against the instance, and it expires and +can be revoked by anyone who could have approved it. The pin is only the record +of which instance was committed to, and it does not expire on its own: it is +deleted when the session ends, swept again at admission for any session that is +not a fork (so a reused session name can never inherit a dead predecessor's +commitment), and copied verbatim onto a forked or restarted session rather than +re-asked. A tool can never write a `slot_pin` or `slot_grant_*` relation +directly — those are platform mechanism relations, and the write is refused +before it reaches SpiceDB. + +Two limits are worth stating plainly, because the enforcement is real but +narrower than it can sound. A pin is scoped to a resource **type**, not to a +real-world resource: a class that exposes one underlying resource through two +different SpiceDB types — say a URL-keyed git-repo type and an identity-keyed +GitHub-repo type for the same repository — pins each independently, and nothing +stops the two from drifting to different targets at once. And pin equality is +derived-ID string equality: a type keyed by a transform that does not fully +canonicalize spelling (a trailing slash, a `.git` suffix, inconsistent casing) +treats two spellings of the same resource as two different instances, each +eligible to be "first" into the slot. Where an identity-keyed transform is +available — an account or repository ID rather than a URL — prefer it for a +single-occupancy slot. + +`AgentSession.status.slotPins` mirrors the current pin for display, and +`oap session show` renders it, but the status field is an observation, not the +enforcement point: SpiceDB's relationship is what every check reads, and the +mirror can lag behind it. + +| Typical agent platform | OAP | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| A session can act anywhere its credentials reach | A session commits to one resource; changing it takes a human's approval or a new session | Implemented in: `pkg/authz/slotspec`, `pkg/authz/slot_grant.go`, -`pkg/authz/data_slot.go`, `pkg/agent/runner/dataslot_*.go` +`pkg/authz/slot_pin.go`, `pkg/authz/data_slot.go`, +`pkg/agent/runner/dataslot_*.go` ### Deterministic approval descriptions @@ -623,10 +689,11 @@ Implemented in: `pkg/platform/oap`, `cmd/oap/internal/agentcmd` ## What an attacker has to get through To misuse an OAP agent, an attacker has to get past a plan a human approved, a -slot that cannot be reopened, a tool lens that cannot be widened, an -authorization check on every call, and a sandbox that never held the credential -in the first place. No single one of these is sufficient on its own, which is -the point: each assumes the others may fail. +slot that cannot be silently reopened — a different instance needs a human's +approval, and `rebind: never` needs a new session — a tool lens that cannot be +widened, an authorization check on every call, and a sandbox that never held the +credential in the first place. No single one of these is sufficient on its own, +which is the point: each assumes the others may fail. ## Related reading diff --git a/internal/cmd/operator/main.go b/internal/cmd/operator/main.go index c4d61da7..9da20bbe 100644 --- a/internal/cmd/operator/main.go +++ b/internal/cmd/operator/main.go @@ -2598,8 +2598,9 @@ func run(cfg *config) { ForkChecker: spiceDBClient, // *spicedb.Client implements authz.ForkChecker (CheckFork) DeniedLister: spiceDBClient, // *spicedb.Client implements authz.DeniedLister (ListDeniedUsers) // *spicedb.Client implements authz.SlotGrantCopier (ListSlotGrants + - // GrantSlots). Carries a parent's bound instances onto a restart/inherit - // child; the takeover carve-out lives in ReconcileRestart. + // ListSlotPins + CopySlotTuples). Carries a parent's bound instances AND + // pin onto a restart/inherit child, verbatim (no gate); the takeover + // carve-out lives in ReconcileRestart. SlotGrantCopier: spiceDBClient, ForkNoticePublish: forkNoticePublish, // StartChecker enforces spec.authz.session.allowedStarters before any diff --git a/internal/cmd/runner/main.go b/internal/cmd/runner/main.go index 0d1968e8..28fcd11c 100644 --- a/internal/cmd/runner/main.go +++ b/internal/cmd/runner/main.go @@ -2516,6 +2516,11 @@ func run(cfg *config) error { // amendment reads at-least-as-fresh as the grant instead of a pre-write // snapshot — see Loop.AdvanceAuthzFloor. loop.SlotBinder = spdbCli.RelationsWithFloor(loop.AdvanceAuthzFloor) + // Mirror onto status.slotPins any pin the cold-start BindClassDefaults + // above produced — display-only, read back from SpiceDB per declared + // single-occupancy type. Placed here rather than beside the bind because + // the mirror reads through the SlotBinder just wired. + loop.MirrorDeclaredSlotPins(rootCtx) // Info-leakage gate wiring. Resolve the policy from AgentClass spec, set up // helpers that bind the gate to the current session's scope. diff --git a/pkg/agent/runner/hooks_dataplane.go b/pkg/agent/runner/hooks_dataplane.go index 702984e7..a12e6707 100644 --- a/pkg/agent/runner/hooks_dataplane.go +++ b/pkg/agent/runner/hooks_dataplane.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" + "github.com/authzed/openagentprimitives/pkg/authz" "github.com/authzed/openagentprimitives/pkg/authz/contentguard" "github.com/authzed/openagentprimitives/pkg/authz/hooks" "github.com/authzed/openagentprimitives/pkg/authz/toolguard" @@ -52,6 +53,16 @@ func init() { if l.PlanGateMode == "" || l.PlanGateMode == "disabled" { return nil } + // The SlotBinder is a *spicedb.RelationWriter in production, which + // implements authz.SlotPinner; the card uses it to read the current pin + // and render a move. Type-asserted into an interface variable rather than + // assigned as a pointer, so a nil or non-pinner binder yields a true nil + // interface (never a typed-nil that would panic on ReadPin) — the gate + // then renders every card as a first-fill, which is correct. + var slotPinner authz.SlotPinner + if sp, ok := l.SlotBinder.(authz.SlotPinner); ok { + slotPinner = sp + } return []pipeline.Hook{hooks.NewPlanGate(hooks.PlanGateDeps{ Mode: l.PlanGateMode, RequirePlan: l.PlanGateRequirePlan, @@ -94,6 +105,13 @@ func init() { Records: l.PlanGateRecords, Recorder: planGateRecorder{l: l}, Logger: slog.Default(), + // Advisory: used only to render a slot MOVE on the card. The MovePin + // MUST_MATCH at decision time, not this read, is what makes the move + // safe; a nil pinner just renders first-fills. + SlotPinner: slotPinner, + // Which slots may never move by approval, so the card offers no move + // for them. Read once at hook-build, like the other slot maps. + SlotRebind: l.slotRebindByType(), })} }}) diff --git a/pkg/agent/runner/host_approval.go b/pkg/agent/runner/host_approval.go index 596124c6..d24694b2 100644 --- a/pkg/agent/runner/host_approval.go +++ b/pkg/agent/runner/host_approval.go @@ -633,6 +633,7 @@ func (h *runnerHost) buildToolCallPending(ctx context.Context, reqID string, ask // Details: the non-display grant-write inputs the channelsd handler reads // back to write the grant tuple (invariant — resolveToolApprovalDetails // decodes these). + occupancy, rebind := h.l.slotOccupancyRebindFor(resourceType) det := channelevents.ToolApprovalDetails{ ToolName: toolName, Permission: permission, @@ -641,6 +642,8 @@ func (h *runnerHost) buildToolCallPending(ctx context.Context, reqID string, ask ArgsHash: argsHash, StateImpact: stateImpact, NoSlotGrant: resourceType != "" && !slices.Contains(h.l.PlanGateSlotTypes, resourceType), + Occupancy: occupancy, + Rebind: rebind, ArgsJSON: extractString(ask.Payload, "args_json"), ToolDescription: extractString(ask.Payload, "tool_description"), Justification: justification, @@ -827,6 +830,7 @@ func (h *runnerHost) buildPreconditionWaiverPending(ctx context.Context, reqID s // Details: the grant-write inputs the waiver handler reads back // (resolveToolApprovalDetails decodes these) to bind the slot grant that IS // the waiver. The SAME ToolApprovalDetails shape a tool_call card carries. + occupancy, rebind := h.l.slotOccupancyRebindFor(resourceType) det := channelevents.ToolApprovalDetails{ ToolName: toolName, Permission: permission, @@ -835,6 +839,8 @@ func (h *runnerHost) buildPreconditionWaiverPending(ctx context.Context, reqID s ArgsHash: argsHash, StateImpact: stateImpact, NoSlotGrant: resourceType != "" && !slices.Contains(h.l.PlanGateSlotTypes, resourceType), + Occupancy: occupancy, + Rebind: rebind, ArgsJSON: extractString(ask.Payload, "args_json"), ToolDescription: extractString(ask.Payload, "tool_description"), } @@ -2357,7 +2363,10 @@ func (h *runnerHost) recordPlanGateDecision( // they approve, it fails identically, and they approve again. A silent // loop is worse than a silent error: the system looks like it is working // and the human is the one being made to repeat themselves. - h.publishPlanGateApprovalFailed(ctx, err) + // + // A standing-lookup blip is a recoverable FAULT, not a pin refusal, so the + // "fault on our side, try once more" copy is the honest one here. + h.publishPlanGateApprovalFailed(ctx, err, approvalFault) return } @@ -2482,7 +2491,7 @@ func (h *runnerHost) narrowToApproved( } bindings := planGateBindings( h.l.PlanGateSurface, pg, rec, granted, declaredPermissions, heldByTypeID, h.l.PlanGateSlotStanding, h.l.PlanGateSlotTransforms, - h.planGateSlotPreconditions()) + h.planGateSlotPreconditions(), h.planGateSlotOccupancy()) if len(bindings) == 0 { if len(requested) > 0 { // A human clicked Approve and the session gained no instance. That is @@ -2531,10 +2540,42 @@ func (h *runnerHost) narrowToApproved( if err := authz.BindApproved(writeCtx, h.l.Mem, scp, sess, h.l.SlotBinder, bindings, authz.EnforcePreconditions, authz.SlotGrantExpiry(time.Now(), sessionExpiration), logr.FromContextOrDiscard(ctx), time.Now); err != nil { + if kind, ok := classifyApprovalBindFailure(err); ok { + // Three different facts the approver needs told apart: + // + // - a MOVE committed and the new grant then failed: the displaced + // instance's access is gone, so "nothing was granted" would hide + // a revocation the approver just caused. + // - a MOVE the card promised could not execute (its MUST_MATCH + // failed: the pin drifted since the card was shown). Nothing + // moved, and re-approving rebuilds the card from the current pin. + // A fault, not a refusal. + // - a plain pinned REFUSAL: a binding named a different instance of a + // slot already pinned to another, with no approvable move (none + // was shown, the slot is `rebind: never`, or the approval would + // leave two instances in it). GrantSlots is per-type partitioned, + // so OTHER types may well have bound, and re-approving changes + // nothing. + slog.Default().Info("plan_gate: an approved slot bind did not fully apply; telling the approver", + "session", scp.ID, "bindings", len(bindings), "failure", kind.String(), "err", err.Error()) + h.publishPlanGateApprovalFailed(ctx, err, kind) + return + } slog.Default().Info("plan_gate: the approval was recorded but did not narrow the session; "+ "the approved instance is permitted and others are not excluded", "session", scp.ID, "bindings", len(bindings), "err", err.Error()) + return } + // A successful approved bind — possibly a MOVE — supersedes any recorded + // promotion refusal for these types: a refusal that said "pinned to A" must + // not be appended to a later denial after an approved A→B move, where it + // would name a pin that no longer exists. + h.l.clearSlotPinRefusals(bindingResourceTypes(bindings)) + // Mirror what SpiceDB now holds onto status.slotPins — display-only, + // best-effort, derived from a ReadPin per involved type rather than from + // `bindings` (which BindApproved may have partially dropped and still + // returned nil); see mirrorApprovedSlotPins' doc comment. + mirrorApprovedSlotPins(ctx, h.l.Status, h.l.SlotBinder, sess, rec, bindings, time.Now()) } // planGateSlotPreconditions returns the compiled precondition set per resource @@ -2572,6 +2613,71 @@ func (h *runnerHost) planGateSlotPreconditions() map[string][]precondition.Rule return out } +// slotOccupancy is a slot's single-vs-multi commitment paired with its rebind +// policy — the two GrantSlots' pinning gate reads together. Both empty is the +// common (single, default-rebind) case and is left out of the by-type map. +type slotOccupancy struct { + Occupancy string + Rebind string +} + +// planGateSlotOccupancy returns the occupancy/rebind pair per resource type the +// class declares, for planGateBindings to stamp onto every binding it emits so +// the plan-gate approval's grant is pinned exactly as a non-approval bind of +// the same slot would be. See Loop.slotOccupancyByType. +func (h *runnerHost) planGateSlotOccupancy() map[string]slotOccupancy { + if h.l == nil { + return nil + } + return h.l.slotOccupancyByType() +} + +// slotOccupancyByType returns the occupancy/rebind pair per resource type the +// class declares. +// +// Derived from the SAME boundEntitySpecsForAutofill → slotspec.FromClass source +// as planGateSlotPreconditions, so occupancy reaches the bind through the one +// converter that owns the AuthzSlot→BoundEntitySpec mapping — never a second +// hand-rolled read of the spec. A compile failure in the class's preconditions +// leaves the map empty (single/default for every type) and is logged, because +// that silently treats a multi-occupancy slot as single; the case is +// unreachable for a class that passed admission. +func (l *Loop) slotOccupancyByType() map[string]slotOccupancy { + if l == nil || l.AgentClass == nil { + return nil + } + specs, err := boundEntitySpecsForAutofill(l.AgentClass) + if err != nil { + slog.Default().Info("slot occupancy: the class's slot declarations did not compile; "+ + "every slot is treated as single-occupancy with the default rebind", + "session", l.SessionKey.Namespace+"/"+l.SessionKey.Name, "err", err.Error()) + return nil + } + out := make(map[string]slotOccupancy, len(specs)) + for _, s := range specs { + if s.Occupancy != "" || s.Rebind != "" { + out[s.ResourceType] = slotOccupancy{Occupancy: s.Occupancy, Rebind: s.Rebind} + } + } + return out +} + +// slotRebindByType projects slotOccupancyByType onto the rebind mode alone, the +// shape PlanGateDeps.SlotRebind takes. +func (l *Loop) slotRebindByType() map[string]string { + occ := l.slotOccupancyByType() + if len(occ) == 0 { + return nil + } + out := make(map[string]string, len(occ)) + for rt, o := range occ { + if o.Rebind != "" { + out[rt] = o.Rebind + } + } + return out +} + // approvedPhaseRecords is what a yes writes: one record per covered phase, or // the active phase alone when the ask carried no coverage (an older gate, or a // card that only ever spoke for one phase). @@ -2867,6 +2973,11 @@ func planGateBindings( standing map[string]string, transforms map[string][]string, requires map[string][]precondition.Rule, + // occupancy is the per-type single-vs-multi commitment and rebind policy, + // stamped onto every emitted binding so the plan-gate grant is pinned the + // same way a non-approval bind of the slot would be. A type absent from the + // map is single with the default rebind — the fail-closed reading. + occupancy map[string]slotOccupancy, ) []authz.SlotBinding { // The object id each (type, permission) pair fixes in advance, if any. constants := map[string]string{} @@ -2884,6 +2995,23 @@ func planGateBindings( } } + // The prior instance a MOVE displaces, keyed by (type, raw moved-to id) — + // taken ONLY from the record's MovedFrom, which is what the card showed the + // approver, NEVER a live pin read here. A card can sit parked for days; the + // human must execute exactly the move they saw, and a pin that drifted + // meanwhile makes MovePin's MUST_MATCH fail loudly rather than moving + // something unseen. Keyed by (type,id) rather than type alone so a move is + // stamped only onto the binding whose instance IS the moved-to one — never a + // constant instance of the same type, which this approval did not move. + // MovedFrom is already a canonical object id (read from the pin), so + // TrustedObjectID is correct: there is no transform to re-apply. + priorByTypeID := map[string]authz.ObjectID{} + for _, r := range granted { + if r.MovedFrom != "" && r.ID != "" { + priorByTypeID[r.Type+"\x00"+r.ID] = authz.TrustedObjectID(r.MovedFrom) + } + } + // Every type this record touches: one the approval granted an instance for, // or one reached only through a constant. types := map[string]bool{} @@ -2956,6 +3084,15 @@ func planGateBindings( // a Refused/Undetermined instance rather than binding it. Empty // requires (the common case) makes that a no-op. RawID: raw, Requires: requires[resourceType], + // Occupancy/Rebind travel too, so GrantSlots pins a + // single-occupancy type's grant rather than writing it unpinned. + Occupancy: occupancy[resourceType].Occupancy, + Rebind: occupancy[resourceType].Rebind, + // PriorID, when the card recorded a move for THIS instance, + // makes BindApproved repoint the pin off the displaced instance + // and revoke its grants. Zero for a first-fill — a record with + // no MovedFrom never yields a move, whatever the live pin is. + PriorID: priorByTypeID[resourceType+"\x00"+raw], }) } } diff --git a/pkg/agent/runner/host_approval_move_test.go b/pkg/agent/runner/host_approval_move_test.go new file mode 100644 index 00000000..f39d826e --- /dev/null +++ b/pkg/agent/runner/host_approval_move_test.go @@ -0,0 +1,210 @@ +package runner + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/authz/permsurface" + "github.com/authzed/openagentprimitives/pkg/channels/channelevents" + "github.com/authzed/openagentprimitives/pkg/channels/channelinteractions/categories" + "github.com/authzed/openagentprimitives/pkg/memory" + "github.com/authzed/openagentprimitives/pkg/memory/inmem" + "github.com/authzed/openagentprimitives/pkg/memory/kinds/plangateaudit" +) + +// moveTrackingBinder is a SlotBinder for the approved-move path. `pinnedTo` +// seeds the current pin ("" = unpinned, a clean first-fill). MovePin always +// fails as ErrSlotPinned (the pin drifted since the card) and counts its calls, +// so a test can both check the failure surfaces AND prove a first-fill never +// attempts a move at all. +type moveTrackingBinder struct { + pinnedTo string + moveCalls int +} + +func (*moveTrackingBinder) WriteRelationships(context.Context, []authz.Relation) error { return nil } +func (*moveTrackingBinder) DeleteRelationships(context.Context, []authz.Relation) error { return nil } +func (b *moveTrackingBinder) EnsurePin(_ context.Context, _, resourceID string, _ authz.SessionRef) (bool, string, error) { + if b.pinnedTo == "" { + // First-in-wins: this call writes the pin, nothing was held before. + return false, resourceID, nil + } + return true, b.pinnedTo, nil +} +func (*moveTrackingBinder) WriteGrantsPinned(context.Context, []authz.Relation, string, string, authz.SessionRef) error { + return nil +} +func (b *moveTrackingBinder) MovePin(_ context.Context, _, fromID, toID string, _ []authz.Relation, _ authz.SessionRef) error { + b.moveCalls++ + return fmt.Errorf("move pin git_repo:%s -> git_repo:%s: %w", fromID, toID, authz.ErrSlotPinned) +} +func (b *moveTrackingBinder) ReadPin(context.Context, string, authz.SessionRef) (string, error) { + return b.pinnedTo, nil +} +func (*moveTrackingBinder) ListGrantsFor(context.Context, string, string, authz.SessionRef) ([]authz.Relation, error) { + return nil, nil +} + +var _ authz.SlotPinner = (*moveTrackingBinder)(nil) + +// A move the human approved that cannot execute — the pin drifted since the +// card — is a BROKEN PROMISE, not an operator-log footnote. narrowToApproved +// must surface it as a failed approval (the InternalError notice that tells the +// approver to try again and that nothing was granted), the same mechanism a +// discarded-approval uses, rather than swallowing the ErrSlotPinned in a +// slog.Info the approver never sees. +func TestNarrowToApproved_approvedMoveFailureTellsTheApprover(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + var published []channelevents.Envelope + h := planGateHost(t, &channelevents.Envelope{}) + h.l.Mem = mem + binder := &moveTrackingBinder{pinnedTo: "repoA"} + h.l.SlotBinder = binder + h.l.PlanGateSlotPermissionSets = map[string][]string{"git_repo": {"push"}} + h.l.InteractionRequestPublish = func(_ context.Context, _, _ string, env channelevents.Envelope) error { + published = append(published, env) + return nil + } + + pushHandle, err := permsurface.NewPermHandle("push", "git_repo") + require.NoError(t, err) + idx := int32(0) + ask := planPhaseAsk() + ask.Payload["phaseKey"] = "p1" + ask.Payload["ceiling"] = []string{pushHandle.String()} + // The card recorded a MOVE: the session is pinned to repoA and the phase + // names repoB. A yes must repoint the pin — and when it cannot, say so. + ask.Payload["covered"] = []plangateaudit.Content{{ + Event: plangateaudit.EventPhaseApproved, + PhaseKey: "p1", + PhaseIndex: &idx, + Ceiling: []string{pushHandle.String()}, + Slots: []string{"git_repo"}, + SlotRefs: []plangateaudit.SlotRef{{Type: "git_repo", ID: "repoB", MovedFrom: "repoA"}}, + }} + + driveDecision(t, h, mem, ask, true) + + assert.Equal(t, 1, binder.moveCalls, "the approved move was attempted") + var told bool + for _, env := range published { + pl := decodeInteractionRequest(t, env) + if pl.Category == categories.InternalError { + told = true + // A DRIFTED MOVE is a recoverable fault: the "fault on our side, try + // once more" copy, not the committed-elsewhere refusal copy. + assert.Contains(t, pl.Lead, "fault on our side", + "a drifted move is a fault, not a refusal") + assert.Contains(t, pl.NextStep, "Try approving once more") + assert.NotContains(t, pl.Lead, "already committed", + "a drifted move must not read as a plain pinned refusal") + } + } + assert.True(t, told, + "an approved move that could not execute must tell the approver, not vanish into a log line") +} + +// A plain pinned refusal — the plan named a different instance of a slot already +// committed to another, with NO move approved (no MovedFrom) — is an answer, not +// a fault. narrowToApproved must tell the approver the slot is committed +// elsewhere, that other instances may still have bound, and that re-approving +// this card will not move it — the opposite advice to the drifted-move case. +func TestNarrowToApproved_plainPinnedRefusalSaysCommittedElsewhere(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + var published []channelevents.Envelope + h := planGateHost(t, &channelevents.Envelope{}) + h.l.Mem = mem + // Pinned to repoA; the phase names repoB with NO MovedFrom, so no move is + // approved. GrantSlots refuses the different-instance bind as ErrSlotPinned. + binder := &moveTrackingBinder{pinnedTo: "repoA"} + h.l.SlotBinder = binder + h.l.PlanGateSlotPermissionSets = map[string][]string{"git_repo": {"push"}} + h.l.InteractionRequestPublish = func(_ context.Context, _, _ string, env channelevents.Envelope) error { + published = append(published, env) + return nil + } + + pushHandle, err := permsurface.NewPermHandle("push", "git_repo") + require.NoError(t, err) + idx := int32(0) + ask := planPhaseAsk() + ask.Payload["phaseKey"] = "p1" + ask.Payload["ceiling"] = []string{pushHandle.String()} + ask.Payload["covered"] = []plangateaudit.Content{{ + Event: plangateaudit.EventPhaseApproved, + PhaseKey: "p1", + PhaseIndex: &idx, + Ceiling: []string{pushHandle.String()}, + Slots: []string{"git_repo"}, + // No MovedFrom: the bind is refused outright, not moved. + SlotRefs: []plangateaudit.SlotRef{{Type: "git_repo", ID: "repoB"}}, + }} + + driveDecision(t, h, mem, ask, true) + + assert.Zero(t, binder.moveCalls, "a plain refusal names no prior pin, so it never attempts a move") + var told bool + for _, env := range published { + pl := decodeInteractionRequest(t, env) + if pl.Category == categories.InternalError { + told = true + assert.Contains(t, pl.Lead, "already committed to a different target", + "a plain pinned refusal names the commitment, not a fault") + assert.Contains(t, pl.Lead, "Other instances", + "it must not claim nothing was granted — other types may have bound") + assert.Contains(t, pl.NextStep, "Re-approving this card will not move it") + assert.NotContains(t, pl.Lead, "fault on our side", + "a refusal is not a fault") + } + } + assert.True(t, told, "a plain pinned refusal must still tell the approver why their approval did not bind this instance") +} + +// The control: the SAME binding WITHOUT a recorded move (a first-fill) does not +// trip the move path at all — ErrSlotPinned is never produced, so no +// InternalError notice is raised. This pins that the re-raise is specific to a +// failed MOVE, not to any slot approval. +func TestNarrowToApproved_firstFillDoesNotRaiseAMoveFailure(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + var published []channelevents.Envelope + h := planGateHost(t, &channelevents.Envelope{}) + h.l.Mem = mem + // Unpinned: a genuine first-fill. MovePin would fail IF reached, so moveCalls + // staying 0 proves the move path was never entered. + binder := &moveTrackingBinder{pinnedTo: ""} + h.l.SlotBinder = binder + h.l.PlanGateSlotPermissionSets = map[string][]string{"git_repo": {"push"}} + h.l.InteractionRequestPublish = func(_ context.Context, _, _ string, env channelevents.Envelope) error { + published = append(published, env) + return nil + } + + pushHandle, err := permsurface.NewPermHandle("push", "git_repo") + require.NoError(t, err) + idx := int32(0) + ask := planPhaseAsk() + ask.Payload["phaseKey"] = "p1" + ask.Payload["ceiling"] = []string{pushHandle.String()} + ask.Payload["covered"] = []plangateaudit.Content{{ + Event: plangateaudit.EventPhaseApproved, + PhaseKey: "p1", + PhaseIndex: &idx, + Ceiling: []string{pushHandle.String()}, + Slots: []string{"git_repo"}, + // No MovedFrom: a first-fill. The move path must not run. + SlotRefs: []plangateaudit.SlotRef{{Type: "git_repo", ID: "repoB"}}, + }} + + driveDecision(t, h, mem, ask, true) + + assert.Zero(t, binder.moveCalls, "a first-fill must never attempt a move") + for _, env := range published { + assert.NotEqual(t, categories.InternalError, decodeInteractionRequest(t, env).Category, + "a first-fill names no prior pin, so it never attempts a move and never fails one") + } +} diff --git a/pkg/agent/runner/loop_autofill.go b/pkg/agent/runner/loop_autofill.go index 0a06d032..d0e79ec2 100644 --- a/pkg/agent/runner/loop_autofill.go +++ b/pkg/agent/runner/loop_autofill.go @@ -2,8 +2,10 @@ package runner import ( "context" + "errors" "log/slog" "path/filepath" + "strings" spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" "github.com/authzed/openagentprimitives/pkg/authz" @@ -375,9 +377,30 @@ func (l *Loop) promoteObservedSlots(ctx context.Context) { } if err := l.Engine.PromoteObservedSlots(ctx, l.bindingScope(), l.authzSessionRef(), specs, l.AuthSubject()); err != nil { + if errors.Is(err, authz.ErrSlotPinned) { + // An authorization outcome, not a hiccup: a single-occupancy pin + // refused this instance. Warn (louder than a mechanism error), keep + // the Info-trail grep key below for an operator, and record the + // refusal so the next gated call on this type explains itself to the + // model rather than reading as a bare denial. + slog.Default().Warn("promoting observed slot candidates refused by a single-occupancy pin; surfacing to the model", + "session", l.SessionKey.Namespace+"/"+l.SessionKey.Name, "err", err.Error()) + l.recordSlotPinRefusal(ctx, specs, err.Error()) + return + } slog.Default().Info("promoting observed slot candidates failed; any gated call stays unbound", "session", l.SessionKey.Namespace+"/"+l.SessionKey.Name, "err", err.Error()) + return } + // A promotion that succeeded supersedes any recorded refusal for these + // types: the facts that refused no longer hold, and a stale "pinned to A" + // explanation must not be appended to a later denial. + l.clearSlotPinRefusalsForSpecs(specs) + // Mirror any pin the promotion just produced onto status.slotPins — + // display-only, read back from SpiceDB per not-yet-mirrored + // single-occupancy type (steady state: no SpiceDB reads once mirrored, + // but still one AgentSession GET; see slot_pin_mirror.go). + l.mirrorBoundSlotPins(ctx, specs) } // explainSlotPrecondition builds the ToolCallAuthz hook's ExplainPrecondition @@ -507,3 +530,172 @@ func (l *Loop) classDeclaresGateOn(resourceType string) bool { } return false } + +// slotOccupancyRebindFor returns the occupancy and rebind modes the class +// declared for resourceType, read straight off the spec slot — plain spec +// fields that need no status derivation, the same way classDeclaresGateOn reads +// `requires`. +// +// These are recorded onto a tool_approval / precondition_waiver card's details +// at RAISE time, because the channelsd decision handler that eventually binds +// the grant holds no AgentClass to resolve them from. Empty for a type the +// class does not declare — which also has no slot_grant to write, so there is +// no bind to pin. +// +// Mirrors classDeclaresGateOn: the FIRST slot matching the type answers, since +// admission refuses a class declaring the same type twice. +func (l *Loop) slotOccupancyRebindFor(resourceType string) (occupancy, rebind string) { + if l.AgentClass == nil || resourceType == "" { + return "", "" + } + for _, s := range l.AgentClass.Spec.GetSlots() { + if s.ResourceType == resourceType { + return s.Occupancy, s.Rebind + } + } + return "", "" +} + +// slotPinRefusal is one recorded single-occupancy pin refusal: the user-visible +// text (authz.GrantSlots' own message, naming the pinned instance and the route +// out) plus the pinned instance id read back from SpiceDB at record time — +// which is what lets the denial attachment skip a call that named the pinned +// instance itself (see pinRefusalApplies). PinnedID is "" when the pin could +// not be read (no SlotPinner wired, or the advisory read failed); the +// attachment then proceeds without the instance-skip. +type slotPinRefusal struct { + Text string + PinnedID string +} + +// recordSlotPinRefusal remembers a single-occupancy pin's refusal so a later +// gated tool call denied on the same resource type can explain itself to the +// model. Keyed per resource type (the declared spec types the text names, via +// the same token-boundary match slotPinRefusalFor used to do at read time) with +// NEWEST-WINS overwrite — two refusals for one type across turns keep only the +// latest, so the model is never handed an explanation older than the most +// recent ruling. The entry is cleared again when the type binds or its pin +// moves (clearSlotPinRefusals at the promote/approval success paths). +func (l *Loop) recordSlotPinRefusal(ctx context.Context, specs []authz.BoundEntitySpec, text string) { + if text == "" || len(specs) == 0 { + return + } + pinner, _ := l.SlotBinder.(authz.SlotPinner) + for _, et := range specs { + if et.ResourceType == "" || !mentionsResourceType(text, et.ResourceType) { + continue + } + ref := slotPinRefusal{Text: text} + if pinner != nil { + id, err := pinner.ReadPin(ctx, et.ResourceType, l.authzSessionRef()) + if err != nil { + // Advisory read: without it the attachment just loses the + // named-the-pinned-instance skip, never the explanation itself. + // Said out loud per the no-silent-errors rule. + slog.Default().Info("slot pin refusal recorded without the pinned id (ReadPin failed); the denial attachment proceeds without the pinned-instance skip", + "session", l.SessionKey.Namespace+"/"+l.SessionKey.Name, + "resourceType", et.ResourceType, "err", err.Error()) + } else { + ref.PinnedID = id + } + } + l.slotPinRefusalMu.Lock() + if l.slotPinRefusals == nil { + l.slotPinRefusals = map[string]slotPinRefusal{} + } + l.slotPinRefusals[et.ResourceType] = ref // newest wins + l.slotPinRefusalMu.Unlock() + } +} + +// clearSlotPinRefusals drops the recorded refusal for each named resource type. +// Called at every path where the type's pin state just CHANGED or was +// reaffirmed — a nil-error promotion (the facts the refusal described no longer +// refuse), and narrowToApproved's successful bind/move (the pin may now be on a +// different instance) — so a stale "pinned to A" explanation cannot be appended +// to a denial after an approved A→B move. +func (l *Loop) clearSlotPinRefusals(types []string) { + if len(types) == 0 { + return + } + l.slotPinRefusalMu.Lock() + defer l.slotPinRefusalMu.Unlock() + for _, rt := range types { + delete(l.slotPinRefusals, rt) + } +} + +// clearSlotPinRefusalsForSpecs is clearSlotPinRefusals over the resource types +// a promotion's specs declare — the shape the two promote nil-error call sites +// have in hand. +func (l *Loop) clearSlotPinRefusalsForSpecs(specs []authz.BoundEntitySpec) { + if len(specs) == 0 { + return + } + types := make([]string, 0, len(specs)) + for _, et := range specs { + if et.ResourceType != "" { + types = append(types, et.ResourceType) + } + } + l.clearSlotPinRefusals(types) +} + +// bindingResourceTypes returns the distinct resource types a binding set names +// — the shape narrowToApproved has in hand when its bind/move succeeds. +func bindingResourceTypes(bindings []authz.SlotBinding) []string { + seen := make(map[string]bool, len(bindings)) + var out []string + for _, b := range bindings { + if b.ResourceType != "" && !seen[b.ResourceType] { + seen[b.ResourceType] = true + out = append(out, b.ResourceType) + } + } + return out +} + +// slotPinRefusalFor returns the recorded pin refusal for resourceType, and +// whether one exists. A map lookup, not a scan: the record is keyed per type +// at write time, holding only the newest refusal for each. +func (l *Loop) slotPinRefusalFor(resourceType string) (slotPinRefusal, bool) { + if resourceType == "" { + return slotPinRefusal{}, false + } + l.slotPinRefusalMu.Lock() + defer l.slotPinRefusalMu.Unlock() + ref, ok := l.slotPinRefusals[resourceType] + return ref, ok +} + +// mentionsResourceType reports whether text names resourceType as a WHOLE +// token — bounded on each side by a non-identifier byte or a string edge — so a +// declared type whose name is a tail of another (a "pull_request" slot against +// a "github_pull_request" refusal) is not matched by accident. Both refusal +// shapes end the type on a non-identifier byte (a ':' or a space), so the one +// rule covers both. +func mentionsResourceType(text, resourceType string) bool { + for from := 0; ; { + i := strings.Index(text[from:], resourceType) + if i < 0 { + return false + } + i += from + end := i + len(resourceType) + leftOK := i == 0 || !isResourceTypeByte(text[i-1]) + rightOK := end == len(text) || !isResourceTypeByte(text[end]) + if leftOK && rightOK { + return true + } + from = i + 1 + } +} + +// isResourceTypeByte reports whether b can appear inside a SpiceDB object type +// name ([a-zA-Z0-9_]). Used to require a token boundary around a type match. +func isResourceTypeByte(b byte) bool { + return b == '_' || + (b >= 'a' && b <= 'z') || + (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') +} diff --git a/pkg/agent/runner/loop_autofill_integration_test.go b/pkg/agent/runner/loop_autofill_integration_test.go index 78dc2318..2a9cc7e9 100644 --- a/pkg/agent/runner/loop_autofill_integration_test.go +++ b/pkg/agent/runner/loop_autofill_integration_test.go @@ -97,6 +97,14 @@ type autofillLoopSpec struct { seedExtracted string // slotFillFrom overrides the class slot's fillFrom. slotFillFrom []string + // seedPin, when non-empty, pre-pins the single-occupancy github_repo slot to + // this instance before Run, so a promotion of a DIFFERENT instance is refused + // by the pin (authz.ErrSlotPinned). + seedPin string + // toolChecker overrides the engine's ToolChecker. Nil keeps the allow-all + // default; a custom one lets a test deny the dispatch Check while still + // admitting the promotion candidate. + toolChecker engine.ToolChecker } // buildAutofillLoop constructs the Loop + capturingTool + session fixtures. @@ -155,11 +163,20 @@ func buildAutofillLoop(t *testing.T, spec autofillLoopSpec) (*runner.Loop, *inpu memory.Scope{Kind: "session", ID: "default/af-sess"}, eex.Content{ResourceType: "github_repo", ResourceID: spec.seedExtracted, TurnIndex: 0})) } + if spec.seedPin != "" { + _, _, err := slots.EnsurePin(context.Background(), "github_repo", spec.seedPin, + authz.SessionRef{Namespace: "default", Name: "af-sess"}) + require.NoError(t, err, "pre-seed pin") + } + var chk engine.ToolChecker = &allowAllToolChecker{} + if spec.toolChecker != nil { + chk = spec.toolChecker + } spdb := &alwaysAllowSpiceDB{} zedCache := toolcheck.NewZedTokenCache() eng := engine.New(engine.Deps{ - ToolChecker: &allowAllToolChecker{}, + ToolChecker: chk, RelWriter: slots, SlotLister: slots, Memory: memLocal, @@ -210,6 +227,10 @@ func buildAutofillLoop(t *testing.T, spec autofillLoopSpec) (*runner.Loop, *inpu AuthzCache: zedCache, Engine: eng, AgentClass: cls, + // The same store the engine grants through, so the pin-refusal record + // can read the pinned instance back (and the mirror its pin state), + // exactly as production wires one SpiceDB client into both. + SlotBinder: slots, BindingAutofillEnabled: spec.autofillEnabled, BindingAutofillDeadline: 100 * time.Millisecond, diff --git a/pkg/agent/runner/loop_binding_integration_test.go b/pkg/agent/runner/loop_binding_integration_test.go index d79b214c..30a27025 100644 --- a/pkg/agent/runner/loop_binding_integration_test.go +++ b/pkg/agent/runner/loop_binding_integration_test.go @@ -52,46 +52,98 @@ func (r *recordingEngine) BindClassDefaults(ctx context.Context, scope memory.Sc } // slotGrantStore is an in-memory stand-in for SpiceDB's slot-grant tuples: it -// satisfies both the write side (engine.RelWriter) and the read side -// (engine.SlotListerImpl), so a test can assert that what BindClassDefaults -// grants is exactly what FillToolArgs later reads back. +// satisfies the write side (engine.RelWriter), the read side +// (engine.SlotListerImpl), AND authz.SlotPinner — the single-occupancy gate +// refuses a plain RelWriter, so a slot-mechanics fake that is not also a pinner +// cannot bind the (single-by-default) slots these tests exercise. Every grant +// lands in `grants` whether it arrived through the pinned write (single types) +// or the plain batched write (multi types), so a read-back reads the same +// whichever path a binding took. type slotGrantStore struct { grants map[string][]authz.SlotBinding // keyed by "/" + pins map[string]string // keyed by "/\x00" } func newSlotGrantStore() *slotGrantStore { - return &slotGrantStore{grants: map[string][]authz.SlotBinding{}} + return &slotGrantStore{grants: map[string][]authz.SlotBinding{}, pins: map[string]string{}} +} + +// recordGrant applies one grant tuple with TOUCH semantics, shared by the plain +// and pinned write paths so ListSlotGrants reads the same set regardless of +// which one a binding took. +func (s *slotGrantStore) recordGrant(rel authz.Relation) { + if !strings.HasPrefix(rel.Relation, authz.SlotGrantRelationPrefix) { + return + } + key := rel.SubjectID + b := authz.SlotBinding{ + ResourceType: rel.ResourceType, + // TrustedObjectID: rel.ResourceID is a tuple already written by + // GrantSlots, so this is the read-back stand-in for the real + // ListSlotGrants (pkg/spicedb/slot_grants.go), which does the same. + ResourceID: authz.TrustedObjectID(rel.ResourceID), + Permission: strings.TrimPrefix(rel.Relation, authz.SlotGrantRelationPrefix), + } + // TOUCH semantics, keyed on the grant-identifying triple: SlotBinding + // carries a []precondition.Rule now and so is no longer comparable, but + // this read-back stand-in only ever populates the triple anyway. + dup := slices.ContainsFunc(s.grants[key], func(g authz.SlotBinding) bool { + return g.ResourceType == b.ResourceType && + g.ResourceID.String() == b.ResourceID.String() && + g.Permission == b.Permission + }) + if !dup { + s.grants[key] = append(s.grants[key], b) + } } func (s *slotGrantStore) WriteRelationships(_ context.Context, rels []authz.Relation) error { for _, rel := range rels { - if !strings.HasPrefix(rel.Relation, authz.SlotGrantRelationPrefix) { - continue - } - key := rel.SubjectID - b := authz.SlotBinding{ - ResourceType: rel.ResourceType, - // TrustedObjectID: rel.ResourceID is a tuple already written by - // GrantSlots, so this is the read-back stand-in for the real - // ListSlotGrants (pkg/spicedb/slot_grants.go), which does the same. - ResourceID: authz.TrustedObjectID(rel.ResourceID), - Permission: strings.TrimPrefix(rel.Relation, authz.SlotGrantRelationPrefix), - } - // TOUCH semantics, keyed on the grant-identifying triple: SlotBinding - // carries a []precondition.Rule now and so is no longer comparable, but - // this read-back stand-in only ever populates the triple anyway. - dup := slices.ContainsFunc(s.grants[key], func(g authz.SlotBinding) bool { - return g.ResourceType == b.ResourceType && - g.ResourceID.String() == b.ResourceID.String() && - g.Permission == b.Permission - }) - if !dup { - s.grants[key] = append(s.grants[key], b) - } + s.recordGrant(rel) + } + return nil +} + +func slotGrantStorePinKey(scope authz.SessionRef, resourceType string) string { + return scope.String() + "\x00" + resourceType +} + +func (s *slotGrantStore) EnsurePin(_ context.Context, resourceType, resourceID string, scope authz.SessionRef) (bool, string, error) { + key := slotGrantStorePinKey(scope, resourceType) + if cur, ok := s.pins[key]; ok { + return true, cur, nil + } + s.pins[key] = resourceID + return false, resourceID, nil +} + +func (s *slotGrantStore) WriteGrantsPinned(_ context.Context, rels []authz.Relation, _, _ string, _ authz.SessionRef) error { + for _, rel := range rels { + s.recordGrant(rel) } return nil } +func (s *slotGrantStore) MovePin(_ context.Context, resourceType, _, toID string, revoke []authz.Relation, scope authz.SessionRef) error { + s.pins[slotGrantStorePinKey(scope, resourceType)] = toID + _ = s.DeleteRelationships(context.Background(), revoke) + return nil +} + +func (s *slotGrantStore) ReadPin(_ context.Context, resourceType string, scope authz.SessionRef) (string, error) { + return s.pins[slotGrantStorePinKey(scope, resourceType)], nil +} + +func (s *slotGrantStore) ListGrantsFor(_ context.Context, resourceType, resourceID string, scope authz.SessionRef) ([]authz.Relation, error) { + var out []authz.Relation + for _, b := range s.grants[scope.String()] { + if b.ResourceType == resourceType && b.ResourceID.String() == resourceID { + out = append(out, authz.SlotGrantRelation(resourceType, resourceID, b.Permission, scope)) + } + } + return out, nil +} + func (s *slotGrantStore) DeleteRelationships(_ context.Context, rels []authz.Relation) error { for _, rel := range rels { key := rel.SubjectID @@ -164,6 +216,14 @@ func TestLoop_BindClassDefaults_WritesAllowedDefaultsToMemory(t *testing.T) { }, } entities := boundEntitySpecsForTest(classEntities) + // Two defaults of one type is a MULTI-occupancy slot: the class pins a SET + // of default repos it wants all reachable, not one. The AgentClass validator + // refuses two defaults on a single-occupancy slot (slot_declaration.go) and + // says exactly this — "set occupancy: multi" — so the valid class a test of + // the multiple-default bind loop must model is a multi slot, and GrantSlots' + // pinning gate (empty occupancy = single) would otherwise refuse the second + // arrival. + entities[0].Occupancy = authz.SlotOccupancyMulti err := rec.BindClassDefaults(memory.WithSystemApproval(context.Background(), "test"), scope, bindTestSession(), entities, "alice@example.com") require.NoError(t, err) diff --git a/pkg/agent/runner/loop_deps.go b/pkg/agent/runner/loop_deps.go index 4702077a..5c5ecec3 100644 --- a/pkg/agent/runner/loop_deps.go +++ b/pkg/agent/runner/loop_deps.go @@ -299,6 +299,26 @@ type Loop struct { deliveredThisRound bool deliveredMu sync.Mutex + // slotPinRefusals holds, PER RESOURCE TYPE, the newest user-visible refusal + // produced when a mid-turn slot promotion (extracted at the autofill site, + // observed after dispatch) was refused by a single-occupancy pin — the text + // names the pinned instance and the route out. A subsequent tool call denied + // on the same resource type reads the matching refusal appended to its + // result, so the model learns WHY the instance it named is unusable instead + // of seeing a bare "permission denied" that reads as a system fault. + // + // Keyed by type with newest-wins overwrite, and CLEARED whenever that type + // binds or its pin moves (the promote nil-error paths, narrowToApproved's + // success path): a refusal that said "pinned to A" must not outlive an + // approved A→B move, or the model is handed advice about a pin that no + // longer exists. Written from the per-call dispatch goroutines (extracted), + // the loop goroutine (observed) and the approval host (clear on move), read + // from the per-call goroutines at denial time — hence the mutex. + // Process-scoped and advisory: losing it on restart costs one unexplained + // denial, never a wrong authorization. + slotPinRefusalMu sync.Mutex + slotPinRefusals map[string]slotPinRefusal + // uiEscalated is set by applyUIResource the first time a tool result carries a // UIResource (the MCP ui:// interception), and gates every later widget in the // same run from re-publishing the session_view_offer anchor. Best-effort: it diff --git a/pkg/agent/runner/loop_dispatch.go b/pkg/agent/runner/loop_dispatch.go index 580bf4ca..60a6dc57 100644 --- a/pkg/agent/runner/loop_dispatch.go +++ b/pkg/agent/runner/loop_dispatch.go @@ -3,6 +3,7 @@ package runner import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "sort" @@ -15,6 +16,7 @@ import ( lifecyclecore "github.com/authzed/openagentprimitives/pkg/agent/session/lifecycle" "github.com/authzed/openagentprimitives/pkg/agent/tool" "github.com/authzed/openagentprimitives/pkg/agent/tool/sandbox" + "github.com/authzed/openagentprimitives/pkg/authz" "github.com/authzed/openagentprimitives/pkg/authz/toolguard" "github.com/authzed/openagentprimitives/pkg/platform/identity" "github.com/authzed/openagentprimitives/pkg/platform/identity/credupdate" @@ -205,6 +207,51 @@ func (l *Loop) executeToolRaw(ctx context.Context, t tool.Tool, name string, arg return res, false } +// pinRefusalApplies reports whether attaching ref's explanation to a denial of +// this call is truthful. It is false for the one case where the explanation +// would MISLABEL the denial: the call resolved to the PINNED instance itself, +// so some other gate (an approval refusal, a scope rule) denied it, and +// appending "this session is pinned to ; propose a plan to +// move" would send the model chasing a move it does not need. +// +// The id is resolved with authz.ResolveResourceID — the same exported resolver +// the backend checker dispatches through (template, CEL expr, and transforms), +// over the same unwrapped argsMap — so the comparison cannot drift from what +// the check itself named. Fails OPEN to attaching: with no pinned id recorded +// (ReadPin unavailable at record time) or an id that will not resolve (that +// denial carries its own unresolved-resource message, and the pin context is +// still true of the type), the explanation is kept rather than guessed away. +func pinRefusalApplies(ref slotPinRefusal, check authz.PermissionCheck, args map[string]any) bool { + if ref.PinnedID == "" { + return true + } + id, err := authz.ResolveResourceID(check, args) + if err != nil || id == "" { + return true + } + return id != ref.PinnedID +} + +// withPinRefusalExplanation appends a recorded single-occupancy pin refusal to +// a denied tool result, so the model reads WHY the instance it named could not +// be used — the refusal text names the pinned instance and the route out. The +// base denial is kept and the explanation added after it: the refusal explains +// the denial, it does not replace the authorization record behind it. +// Idempotent — a refusal already present (a retry of the same denied call) is +// not appended twice. +func withPinRefusalExplanation(res tool.Result, refusal string) tool.Result { + if refusal == "" || strings.Contains(res.Content, refusal) { + return res + } + if res.Content == "" { + res.Content = refusal + } else { + res.Content += "\n\n" + refusal + } + res.IsError = true + return res +} + // approvalObservation reports one observable point of a single contained // call's approval. It exists so a browser surface can render the lifecycle // the design spec names; nothing about the approval flow itself changes. @@ -738,8 +785,32 @@ func (l *Loop) dispatchToolUses(ctx context.Context, uses []llm.ToolUseBlock, se // and the per-tool Check below still gates the call. if perr := l.Engine.PromoteExtractedSlots(callCtx, l.bindingScope(), l.authzSessionRef(), specs, l.AuthSubject(), l.CurrentInboxIdx); perr != nil { - slog.Default().Info("promoting extracted slot candidates failed; continuing unbound", - "tool", t.Name(), "turn", l.CurrentInboxIdx, "err", perr.Error()) + if errors.Is(perr, authz.ErrSlotPinned) { + // An authorization outcome, not a hiccup: a + // single-occupancy pin refused this instance. Warn + // (louder than a mechanism error), keep the Info-trail + // grep key below for an operator, and record the refusal + // so the per-tool Check's denial on this type explains + // itself to the model rather than reading as a bare + // "permission denied". + slog.Default().Warn("promoting extracted slot candidates refused by a single-occupancy pin; surfacing to the model", + "tool", t.Name(), "turn", l.CurrentInboxIdx, "err", perr.Error()) + l.recordSlotPinRefusal(callCtx, specs, perr.Error()) + } else { + slog.Default().Info("promoting extracted slot candidates failed; continuing unbound", + "tool", t.Name(), "turn", l.CurrentInboxIdx, "err", perr.Error()) + } + } else { + // A promotion that succeeded supersedes any recorded + // refusal for these types: the facts that refused no + // longer hold, and a stale "pinned to A" explanation + // must not be appended to a later denial. + l.clearSlotPinRefusalsForSpecs(specs) + // Mirror any pin the promotion just produced onto + // status.slotPins — display-only, read back from SpiceDB + // per not-yet-mirrored single-occupancy type (see + // slot_pin_mirror.go). + l.mirrorBoundSlotPins(callCtx, specs) } } filled, ferr := l.Engine.FillToolArgs(callCtx, l.authzSessionRef(), @@ -910,6 +981,21 @@ func (l *Loop) dispatchToolUses(ctx context.Context, uses []llm.ToolUseBlock, se // never ran, so return without the Terminal check. return case containPreDeny: + // If a slot promotion for this resource type was refused by a + // single-occupancy pin earlier this turn, the denial the model + // is about to read is a consequence of that pin — append the + // pin's own refusal text (pinned instance + route out) so the + // model knows which instance IS usable and how to move, rather + // than reading a bare "permission denied" as a system fault. + // pinRefusalApplies skips a call that resolved to the PINNED + // instance itself (denied by some other gate), where "move" + // advice would mislabel the denial. + if perm.Check != nil { + if ref, ok := l.slotPinRefusalFor(perm.Check.ResourceType); ok && + pinRefusalApplies(ref, *perm.Check, argsMap) { + results[i] = withPinRefusalExplanation(results[i], ref.Text) + } + } mu.Lock() preDeny++ mu.Unlock() diff --git a/pkg/agent/runner/loop_observed_binding_test.go b/pkg/agent/runner/loop_observed_binding_test.go index 77e92b80..b9091e7c 100644 --- a/pkg/agent/runner/loop_observed_binding_test.go +++ b/pkg/agent/runner/loop_observed_binding_test.go @@ -127,7 +127,16 @@ func (observedAllowAll) CheckToolCall(_ context.Context, _ authz.Permission, _ a return authz.Result{Outcome: authz.OutcomeAllowed} } -type observedRelWriter struct{ wrote []authz.Relation } +// observedRelWriter records every grant write. It implements authz.SlotPinner +// as well as authz.RelWriter because the slots these tests exercise are +// single-occupancy (empty occupancy defaults to single), and the gate refuses a +// plain RelWriter for a single type. The pinned write records into the same +// `wrote` slice as the plain path, so an assertion on `wrote` reads the same +// whichever path a binding took. +type observedRelWriter struct { + wrote []authz.Relation + pins map[string]string // keyed by "/\x00" +} func (w *observedRelWriter) WriteRelationships(_ context.Context, rels []authz.Relation) error { w.wrote = append(w.wrote, rels...) @@ -137,6 +146,39 @@ func (w *observedRelWriter) DeleteRelationships(_ context.Context, _ []authz.Rel return nil } +func (w *observedRelWriter) EnsurePin(_ context.Context, resourceType, resourceID string, scope authz.SessionRef) (bool, string, error) { + if w.pins == nil { + w.pins = map[string]string{} + } + key := scope.String() + "\x00" + resourceType + if cur, ok := w.pins[key]; ok { + return true, cur, nil + } + w.pins[key] = resourceID + return false, resourceID, nil +} + +func (w *observedRelWriter) WriteGrantsPinned(_ context.Context, rels []authz.Relation, _, _ string, _ authz.SessionRef) error { + w.wrote = append(w.wrote, rels...) + return nil +} + +func (w *observedRelWriter) MovePin(_ context.Context, resourceType, _, toID string, _ []authz.Relation, scope authz.SessionRef) error { + if w.pins == nil { + w.pins = map[string]string{} + } + w.pins[scope.String()+"\x00"+resourceType] = toID + return nil +} + +func (w *observedRelWriter) ReadPin(_ context.Context, resourceType string, scope authz.SessionRef) (string, error) { + return w.pins[scope.String()+"\x00"+resourceType], nil +} + +func (w *observedRelWriter) ListGrantsFor(_ context.Context, _, _ string, _ authz.SessionRef) ([]authz.Relation, error) { + return nil, nil +} + // dispatchObservingTool drives ONE round of the real dispatcher against the // observing tool and returns the results. func dispatchObservingTool(t *testing.T, l *Loop, mt agenttool.Tool) []agenttool.Result { diff --git a/pkg/agent/runner/loop_slot_pin_refusal_test.go b/pkg/agent/runner/loop_slot_pin_refusal_test.go new file mode 100644 index 00000000..dc8f52dd --- /dev/null +++ b/pkg/agent/runner/loop_slot_pin_refusal_test.go @@ -0,0 +1,156 @@ +package runner_test + +// Verifies that a mid-turn slot promotion refused by a single-occupancy pin +// does not die in an operator log: the refusal (naming the pinned instance and +// the route out) reaches the MODEL, appended to the denied tool result for the +// call that named the refused instance. +// +// The scenario the test reconstructs: +// - the session is already pinned to github_repo:demo-org/pinned-repo, +// - the user's message proposed a DIFFERENT repo, which authzd recorded as an +// extracted candidate (demo-org/from-the-message), +// - the agent then calls a slot-gated tool naming that different repo. +// +// The extracted-slot promotion admits the candidate (the requester has standing +// on it) but GrantSlots refuses to bind it — the pin holds a different instance +// — so the slot stays empty, the per-tool Check denies the call, and the denied +// tool result must carry the pin's own explanation. + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + llmfake "github.com/authzed/openagentprimitives/pkg/agent/llm/fake" + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/memory" +) + +// pinScenarioChecker allows the promotion's admissibility Check (which names the +// candidate under the synthetic {__resourceID__} template authz.checkBindable +// uses) so the candidate reaches GrantSlots and the pin refuses it, but DENIES +// the dispatch Check (which names the repo via the tool's own {repo} template) +// because the slot never bound that instance. Keying on the arg name is how the +// one fake tells the two checks apart; both flow through CheckToolCall. +type pinScenarioChecker struct{} + +func (pinScenarioChecker) CheckToolCall(_ context.Context, _ authz.Permission, in authz.Inputs) authz.Result { + if _, isPromotionAdmissibility := in.Args["__resourceID__"]; isPromotionAdmissibility { + return authz.Result{Outcome: authz.OutcomeAllowed} + } + return authz.Result{ + Outcome: authz.OutcomeDenied, + Message: "permission denied: alice does not have read on github_repo:demo-org/from-the-message", + } +} + +// TestAutofill_ExtractedCandidate_PinRefusalReachesModel is the surfacing proof +// for the extracted-slot promotion path. +func TestAutofill_ExtractedCandidate_PinRefusalReachesModel(t *testing.T) { + l, capTool := buildAutofillLoop(t, autofillLoopSpec{ + autofillEnabled: true, + toolName: "gh_pr_view", + seedExtracted: "demo-org/from-the-message", + seedPin: "demo-org/pinned-repo", + inputJSON: `{"repo":"demo-org/from-the-message","prNumber":13}`, + toolChecker: pinScenarioChecker{}, + }) + require.NoError(t, l.Run(memory.WithSystemApproval(context.Background(), "test"))) + + // The call was denied, so the tool never ran. + assert.Nil(t, capTool.gotJSON.Load(), "a denied call must not execute") + + // The denied tool result fed back to the model must carry the pin's own + // refusal text: the pinned instance and the route out. + provider, ok := l.Provider.(*llmfake.Provider) + require.True(t, ok, "expected the fake provider") + reqs := provider.Requests() + require.GreaterOrEqual(t, len(reqs), 2, "expected at least 2 LLM requests") + + var denyMsg string + for _, msg := range reqs[1].Messages { + for _, blk := range msg.Content { + if blk.ToolResult != nil && blk.ToolResult.IsError { + denyMsg = blk.ToolResult.Content + } + } + } + require.NotEmpty(t, denyMsg, "no IsError tool_result reached the model") + assert.Contains(t, denyMsg, "demo-org/pinned-repo", + "the model must be told which instance the session is pinned to") + assert.Contains(t, denyMsg, "propose an updated plan", + "the model must be given the route out the pin refusal names") +} + +// TestAutofill_NoPinRefusal_PlainDenialUnchanged guards against the explanation +// leaking onto an ordinary denial. With no pin seeded (nothing refused a +// promotion), the same denied call carries only the checker's own message — +// the pin text is appended ONLY when a refusal was actually recorded. +func TestAutofill_NoPinRefusal_PlainDenialUnchanged(t *testing.T) { + l, capTool := buildAutofillLoop(t, autofillLoopSpec{ + autofillEnabled: true, + toolName: "gh_pr_view", + seedExtracted: "demo-org/from-the-message", + inputJSON: `{"repo":"demo-org/from-the-message","prNumber":13}`, + toolChecker: pinScenarioChecker{}, + }) + require.NoError(t, l.Run(memory.WithSystemApproval(context.Background(), "test"))) + + assert.Nil(t, capTool.gotJSON.Load(), "a denied call must not execute") + + provider := l.Provider.(*llmfake.Provider) + reqs := provider.Requests() + require.GreaterOrEqual(t, len(reqs), 2) + var denyMsg string + for _, msg := range reqs[1].Messages { + for _, blk := range msg.Content { + if blk.ToolResult != nil && blk.ToolResult.IsError { + denyMsg = blk.ToolResult.Content + } + } + } + require.NotEmpty(t, denyMsg) + assert.Contains(t, denyMsg, "permission denied", "the plain checker denial still reaches the model") + assert.NotContains(t, denyMsg, "pinned to", + "with nothing refused, no pin explanation must be appended") +} + +// TestAutofill_DenialOfThePinnedInstanceItself_NoMoveAdvice guards the +// mislabel case: a refusal about instance B is recorded, but the DENIED call +// names the PINNED instance A — some other gate refused it, not the pin — so +// appending "this session is pinned to A; propose a plan to move" would send +// the model chasing a move it does not need. The seam resolves the call's own +// resource id (authz.ResolveResourceID over the same args the checker saw) and +// skips the append when it equals the pinned instance read back at record time. +func TestAutofill_DenialOfThePinnedInstanceItself_NoMoveAdvice(t *testing.T) { + l, capTool := buildAutofillLoop(t, autofillLoopSpec{ + autofillEnabled: true, + toolName: "gh_pr_view", + seedExtracted: "demo-org/from-the-message", + seedPin: "demo-org/pinned-repo", + // The call names the PINNED instance, not the refused candidate. + inputJSON: `{"repo":"demo-org/pinned-repo","prNumber":14}`, + toolChecker: pinScenarioChecker{}, + }) + require.NoError(t, l.Run(memory.WithSystemApproval(context.Background(), "test"))) + + assert.Nil(t, capTool.gotJSON.Load(), "a denied call must not execute") + + provider := l.Provider.(*llmfake.Provider) + reqs := provider.Requests() + require.GreaterOrEqual(t, len(reqs), 2) + var denyMsg string + for _, msg := range reqs[1].Messages { + for _, blk := range msg.Content { + if blk.ToolResult != nil && blk.ToolResult.IsError { + denyMsg = blk.ToolResult.Content + } + } + } + require.NotEmpty(t, denyMsg) + assert.Contains(t, denyMsg, "permission denied", "the gate's own denial still reaches the model") + assert.NotContains(t, denyMsg, "propose an updated plan", + "a denial of the pinned instance itself must not carry move advice") +} diff --git a/pkg/agent/runner/pipeline_wiring_approvers_test.go b/pkg/agent/runner/pipeline_wiring_approvers_test.go index 778f8f4c..1ea2c368 100644 --- a/pkg/agent/runner/pipeline_wiring_approvers_test.go +++ b/pkg/agent/runner/pipeline_wiring_approvers_test.go @@ -504,7 +504,7 @@ func TestPlanGateBindings_EachPermissionBindsTheInstanceItResolvesTo(t *testing. "git_repo\x00workspace": allGitRepo, } got := planGateBindings(surface, nil, rec, granted, - map[string][]string{"git_repo": {"push", "read", "write", "fetch"}}, held, nil, nil, nil) + map[string][]string{"git_repo": {"push", "read", "write", "fetch"}}, held, nil, nil, nil, nil) want := map[string]string{ "write": "workspace", @@ -517,6 +517,65 @@ func TestPlanGateBindings_EachPermissionBindsTheInstanceItResolvesTo(t *testing. } } +// TestPlanGateBindings_CarriesOccupancyAndRebind is the plan-gate family's +// propagation guard: a plan-phase approval binds through authz.BindApproved → +// GrantSlots, so each binding must carry its type's occupancy/rebind or a multi +// slot's approved instance arrives reading as single and GrantSlots pins it. The +// by-type map comes from the same slotspec.FromClass source as the requires and +// transforms maps beside it. +func TestPlanGateBindings_CarriesOccupancyAndRebind(t *testing.T) { + surface := []permsurface.Descriptor{permDescConst(t, "read", "git_repo", "")} + h, err := permsurface.NewPermHandle("read", "git_repo") + require.NoError(t, err) + rec := plangateaudit.Content{PhaseKey: "phase-1", Ceiling: []string{h.String()}} + const repo = "https://github.com/acme/app" + granted := []plangateaudit.SlotRef{{Type: "git_repo", ID: repo}} + held := map[string]map[string]struct{}{"git_repo\x00" + repo: {"read": {}}} + + occ := map[string]slotOccupancy{"git_repo": {Occupancy: "multi", Rebind: "approval"}} + got := planGateBindings(surface, nil, rec, granted, + map[string][]string{"git_repo": {"read"}}, held, nil, nil, nil, occ) + require.Len(t, got, 1) + assert.Equal(t, "multi", got[0].Occupancy, "the class slot's occupancy must ride onto the plan-gate binding") + assert.Equal(t, "approval", got[0].Rebind) + + // A type absent from the map stays empty — which GrantSlots reads as single, + // the fail-closed default. + got2 := planGateBindings(surface, nil, rec, granted, + map[string][]string{"git_repo": {"read"}}, held, nil, nil, nil, nil) + require.Len(t, got2, 1) + assert.Empty(t, got2[0].Occupancy) + assert.Empty(t, got2[0].Rebind) +} + +// TestLoopSlotOccupancyRebindFor is the JIT family's raise-side guard: the +// tool_approval / precondition_waiver cards record occupancy/rebind at raise +// time (the channelsd decision handler holds no class), and this is the lookup +// that resolves them from the spec slot. A type the class does not declare +// answers empty — it has no slot_grant to write, so no bind to pin. +func TestLoopSlotOccupancyRebindFor(t *testing.T) { + l := &Loop{AgentClass: &spiceboxv1alpha1.AgentClass{ + Spec: spiceboxv1alpha1.AgentClassSpec{ + Authz: &spiceboxv1alpha1.AuthzBlock{Slots: []spiceboxv1alpha1.AuthzSlot{ + {ResourceType: "label", Permission: "apply", Occupancy: "multi", Rebind: "approval"}, + {ResourceType: "git_repo", Permission: "push", Occupancy: "single", Rebind: "never"}, + }}, + }, + }} + + occ, reb := l.slotOccupancyRebindFor("label") + assert.Equal(t, "multi", occ) + assert.Equal(t, "approval", reb) + + occ, reb = l.slotOccupancyRebindFor("git_repo") + assert.Equal(t, "single", occ) + assert.Equal(t, "never", reb) + + occ, reb = l.slotOccupancyRebindFor("undeclared") + assert.Empty(t, occ, "a type the class does not declare resolves to empty") + assert.Empty(t, reb) +} + // A constant instance is bound ONCE however many declared slots the phase // names — it does not depend on them, so pairing it with each would write the // same grant repeatedly. @@ -532,7 +591,7 @@ func TestPlanGateBindings_ConstantInstanceIsBoundOnce(t *testing.T) { } held := map[string]map[string]struct{}{"git_repo\x00workspace": {"read": {}}} - got := planGateBindings(surface, nil, rec, granted, map[string][]string{"git_repo": {"read"}}, held, nil, nil, nil) + got := planGateBindings(surface, nil, rec, granted, map[string][]string{"git_repo": {"read"}}, held, nil, nil, nil, nil) assert.Len(t, got, 1, "workspace is one instance, not one per declared repo") assert.Equal(t, "workspace", got[0].ResourceID.String()) } @@ -584,7 +643,7 @@ func TestPlanGateBindings_DropsAPermissionTheApproverDoesNotHold(t *testing.T) { } got := planGateBindings(surface, nil, rec, granted, - map[string][]string{"git_repo": {"read", "push"}}, held, nil, nil, nil) + map[string][]string{"git_repo": {"read", "push"}}, held, nil, nil, nil, nil) perms := map[string]bool{} for _, b := range got { @@ -594,6 +653,38 @@ func TestPlanGateBindings_DropsAPermissionTheApproverDoesNotHold(t *testing.T) { assert.False(t, perms["push"], "push is NOT held and must not be bound — this is the over-grant fix") } +// PriorID — which turns a binding into an approved MOVE — is derived ONLY from +// the record's MovedFrom, never from a live pin read. A record that carries it +// yields a PriorID-bearing binding; a record without it yields a zero PriorID, +// a first-fill, whatever the live pin happens to be (planGateBindings does no +// read at all, which is the structural guarantee). +func TestPlanGateBindings_PriorIDComesFromRecordMovedFromOnly(t *testing.T) { + surface := []permsurface.Descriptor{permDescConst(t, "push", "git_repo", "")} + h, err := permsurface.NewPermHandle("push", "git_repo") + require.NoError(t, err) + const repoB = "https://github.com/acme/app" + rec := plangateaudit.Content{PhaseKey: "p1", Ceiling: []string{h.String()}} + held := map[string]map[string]struct{}{"git_repo\x00" + repoB: {"push": {}}} + declared := map[string][]string{"git_repo": {"push"}} + occ := map[string]slotOccupancy{"git_repo": {Occupancy: "single"}} + + t.Run("record carries MovedFrom: binding MOVES, PriorID set from it", func(t *testing.T) { + granted := []plangateaudit.SlotRef{{Type: "git_repo", ID: repoB, MovedFrom: "acme-prior-id"}} + got := planGateBindings(surface, nil, rec, granted, declared, held, nil, nil, nil, occ) + require.Len(t, got, 1) + assert.Equal(t, "acme-prior-id", got[0].PriorID.String(), + "PriorID is the record's MovedFrom, already-canonical, taken verbatim") + }) + + t.Run("record omits MovedFrom: zero PriorID, a first-fill", func(t *testing.T) { + granted := []plangateaudit.SlotRef{{Type: "git_repo", ID: repoB}} + got := planGateBindings(surface, nil, rec, granted, declared, held, nil, nil, nil, occ) + require.Len(t, got, 1) + assert.True(t, got[0].PriorID.IsZero(), + "no MovedFrom on the record means no move, whatever the live pin is") + }) +} + // A CONSTANT instance (a check resolvable with no tool args, e.g. // git_repo:workspace) is session-only and must bind on the approver's say-so — // the per-permission held filter must NOT drop it. It is absent from the @@ -622,7 +713,7 @@ func TestPlanGateBindings_ConstantInstanceBindsEvenWhenNotInTheHeldMap(t *testin held := map[string]map[string]struct{}{"git_repo\x00" + repo: {"push": {}}} got := planGateBindings(surface, nil, rec, granted, - map[string][]string{"git_repo": {"read", "push"}}, held, nil, nil, nil) + map[string][]string{"git_repo": {"read", "push"}}, held, nil, nil, nil, nil) byInstance := map[string]string{} // resourceID -> permission for _, b := range got { @@ -653,7 +744,7 @@ func TestPlanGateBindings_StandingRequiredConstantIsStillFiltered(t *testing.T) standing := map[string]string{"shared_thing": spiceboxv1alpha1.StandingRequired} got := planGateBindings(surface, nil, rec, granted, - map[string][]string{"shared_thing": {"admin"}}, held, standing, nil, nil) + map[string][]string{"shared_thing": {"admin"}}, held, standing, nil, nil, nil) assert.Empty(t, got, "a StandingRequired constant the approver holds nothing on must not be bound — the constant skip is for session-only types only") @@ -662,6 +753,6 @@ func TestPlanGateBindings_StandingRequiredConstantIsStillFiltered(t *testing.T) // approver's say-so (the legitimate constant path stays intact). sessionOnly := map[string]string{"shared_thing": spiceboxv1alpha1.StandingSessionOnly} got2 := planGateBindings(surface, nil, rec, granted, - map[string][]string{"shared_thing": {"admin"}}, held, sessionOnly, nil, nil) + map[string][]string{"shared_thing": {"admin"}}, held, sessionOnly, nil, nil, nil) assert.Len(t, got2, 1, "a session-only constant binds on the approval alone") } diff --git a/pkg/agent/runner/plangate_notice.go b/pkg/agent/runner/plangate_notice.go index c0e9e8ab..b1e8e5c1 100644 --- a/pkg/agent/runner/plangate_notice.go +++ b/pkg/agent/runner/plangate_notice.go @@ -13,9 +13,11 @@ package runner import ( "context" + "errors" "fmt" "log/slog" + "github.com/authzed/openagentprimitives/pkg/authz" "github.com/authzed/openagentprimitives/pkg/authz/plangate" "github.com/authzed/openagentprimitives/pkg/channels/channelevents" "github.com/authzed/openagentprimitives/pkg/channels/channelinteractions/categories" @@ -49,7 +51,26 @@ func approverIDOf(p identity.Principal) string { // lookup" is not something the person clicking Approve can act on, and this // surface is a chat window, not an operator console; the operator detail is in // the log line beside this call. -func (h *runnerHost) publishPlanGateApprovalFailed(ctx context.Context, cause error) { +// +// kind branches the copy, because the reasons an approval fails to apply fall +// into opposite buckets and one wording lies about at least one of them: +// +// - a RECOVERABLE FAULT (approvalFault): a standing lookup blipped, or an +// approved MOVE could not execute because the pin drifted since the card +// was shown. Nothing bound, and re-approving can take once the transient +// condition clears — "a fault on our side, try once more" is true. +// - a PLAIN PINNED REFUSAL (approvalPinnedRefusal): a binding named a +// different instance of a slot already committed to another, with no +// approvable move. This is NOT a fault, re-approving the same card will not +// change it, and because GrantSlots is per-type partitioned OTHER instances +// the plan named may well have bound — so "nothing was granted" would be a +// lie. +// - a COMMITTED MOVE (approvalMoveCommitted): the approved move landed, so +// the displaced instance's access is already revoked, but the grant on the +// new instance was not written. "Nothing was granted" would hide the +// revocation; the honest copy says what changed and that the new target +// will ask again when the agent reaches it. +func (h *runnerHost) publishPlanGateApprovalFailed(ctx context.Context, cause error, kind approvalFailure) { if h.l == nil || h.l.InteractionRequestPublish == nil { return } @@ -58,22 +79,41 @@ func (h *runnerHost) publishPlanGateApprovalFailed(ctx context.Context, cause er sessNS, sessName = h.sess.Namespace, h.sess.Name } + // Recoverable-fault copy (the default): nothing bound, and re-approving may + // take. Bounded rather than "retry"/"don't retry" — one more attempt + // distinguishes a transient blip from a persistent one; a third never does. + lead := "Your approval could not be applied, so nothing was granted and the plan " + + "has not started. This is a fault on our side, not a refusal." + nextStep := "Try approving once more. If it fails again, report it — nothing " + + "has been granted either way." + switch kind { + case approvalMoveCommitted: + lead = "Your approval moved this session to the new target and removed its access " + + "to the previous one, but access to the new target could not be granted." + nextStep = "The agent will ask again when it reaches the new target. If it fails " + + "again, report it." + case approvalPinnedRefusal: + // A plain pinned refusal: the slot is committed elsewhere and this is an + // answer, not a fault. Honest that other instances may have bound and that + // re-approving the SAME card changes nothing. + lead = "This session is already committed to a different target for one of its " + + "slots, so the instance your approval named was not granted. Other instances " + + "the plan named may have been granted; this one was not." + nextStep = "To target the committed instance, the plan must name it so an approved " + + "amendment can move the commitment; otherwise start a new session for it. " + + "Re-approving this card will not move it." + case approvalFault: + } + pl := channelevents.InteractionRequestPayload{ AgentSessionRef: channelevents.SessionRef{Namespace: sessNS, Name: sessName}, Category: categories.InternalError, RequestRef: newRequestID(), - Lead: "Your approval could not be applied, so nothing was granted and the plan " + - "has not started. This is a fault on our side, not a refusal.", - // Required of every degraded-tone notice, and the reason the rule - // exists: without it this says something went wrong and leaves the - // reader holding an unanswered card. - // - // Bounded rather than "retry" or "don't retry", because BOTH causes are - // real and they want opposite advice: an unreachable permission service - // clears on its own, and an identity the service cannot accept never - // will. One more attempt distinguishes them; a third never does. - NextStep: "Try approving once more. If it fails again, report it — nothing " + - "has been granted either way.", + Lead: lead, + // Required of every degraded-tone notice, and the reason the rule exists: + // without it this says something went wrong and leaves the reader holding + // an unanswered card. + NextStep: nextStep, Audience: channelevents.InteractionAudience{Scope: channelevents.AudienceApprovers}, } env, err := channelevents.BuildEnvelope(sessNS, sessName, channelevents.KindInteractionRequest, pl) @@ -160,6 +200,16 @@ func planGateItems(c plangate.Card) []channelevents.InteractionItem { item.Items = append(item.Items, channelevents.InteractionItem{ Text: "reaches " + r.Text, Detail: r.Detail, Icon: r.Icon, Href: r.Href, }) + if r.MovedFrom != "" { + // The cost half of a slot move, as its own line: approving + // repoints the pin and revokes the displaced instance. External + // tone because the approver loses something, and a surface must + // not render that at the weight of a read. + item.Items = append(item.Items, channelevents.InteractionItem{ + Text: "moves the pin and revokes this session's access to " + r.MovedFrom, + Tone: channelevents.ToneExternal, + }) + } } out = append(out, item) } @@ -172,3 +222,42 @@ func planGateItems(c plangate.Card) []channelevents.InteractionItem { } return out } + +// approvalFailure is why an approved bind did not fully apply; it selects the +// approver-facing copy in publishPlanGateApprovalFailed. +type approvalFailure int + +const ( + approvalFault approvalFailure = iota + approvalPinnedRefusal + approvalMoveCommitted +) + +func (k approvalFailure) String() string { + switch k { + case approvalPinnedRefusal: + return "pinned-refusal" + case approvalMoveCommitted: + return "move-committed" + default: + return "fault" + } +} + +// classifyApprovalBindFailure maps a BindApproved error onto the notice the +// approver gets. ok=false means the error is none of the slot-pin outcomes and +// the caller keeps its own (log-only) handling. Order matters: a committed move +// can wrap a drifted or pinned error from a later type, and a drifted move also +// wraps ErrSlotPinned. +func classifyApprovalBindFailure(err error) (approvalFailure, bool) { + switch { + case errors.Is(err, authz.ErrSlotMoveCommitted): + return approvalMoveCommitted, true + case errors.Is(err, authz.ErrSlotMoveDrifted): + return approvalFault, true + case errors.Is(err, authz.ErrSlotPinned): + return approvalPinnedRefusal, true + default: + return approvalFault, false + } +} diff --git a/pkg/agent/runner/plangate_notice_test.go b/pkg/agent/runner/plangate_notice_test.go index 6eaa5da6..bfd9b387 100644 --- a/pkg/agent/runner/plangate_notice_test.go +++ b/pkg/agent/runner/plangate_notice_test.go @@ -1,11 +1,14 @@ package runner import ( + "errors" + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/authzed/openagentprimitives/pkg/authz" "github.com/authzed/openagentprimitives/pkg/authz/plangate" "github.com/authzed/openagentprimitives/pkg/channels/channelevents" ) @@ -120,3 +123,45 @@ func TestPlanGateItems_ResourceLineWithNoHrefCarriesNone(t *testing.T) { assert.Empty(t, items[0].Items[0].Href) assert.Empty(t, items[0].Items[0].Icon) } + +// A slot move on a resource line gets its own revocation line on the wire, so +// a surface cannot render the move while dropping what it costs. +func TestPlanGateItems_MoveCarriesARevocationLine(t *testing.T) { + items := planGateItems(plangate.Card{Phases: []plangate.CardPhase{{ + Title: "Phase 1", + Resources: []plangate.CardLine{{Text: "git_repo", Detail: "repoB", MovedFrom: "repoA"}}, + }}}) + require.Len(t, items, 1) + require.Len(t, items[0].Items, 2, "the resource line, then its revocation") + assert.Equal(t, "repoB", items[0].Items[0].Detail) + assert.Contains(t, items[0].Items[1].Text, "revokes this session's access to repoA") + assert.Equal(t, channelevents.ToneExternal, items[0].Items[1].Tone) +} + +// Each BindApproved failure maps onto the approver copy that is true for it. +// The order matters: a committed move wraps whatever failed after it, and a +// drifted move also wraps ErrSlotPinned. +func TestClassifyApprovalBindFailure(t *testing.T) { + cases := []struct { + name string + err error + want approvalFailure + wantOK bool + }{ + {"committed move wrapping a later pin refusal: move-committed", + fmt.Errorf("%w: %w", authz.ErrSlotMoveCommitted, authz.ErrSlotPinned), approvalMoveCommitted, true}, + {"drifted move (also ErrSlotPinned): retryable fault", + fmt.Errorf("%w: %w", authz.ErrSlotMoveDrifted, authz.ErrSlotPinned), approvalFault, true}, + {"plain pin refusal: pinned-refusal", + fmt.Errorf("wrap: %w", authz.ErrSlotPinned), approvalPinnedRefusal, true}, + {"unrelated store error: not a slot-pin outcome", + errors.New("spicedb unavailable"), approvalFault, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := classifyApprovalBindFailure(tc.err) + assert.Equal(t, tc.wantOK, ok) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/agent/runner/slot_pin_match_test.go b/pkg/agent/runner/slot_pin_match_test.go new file mode 100644 index 00000000..d4cbf5dd --- /dev/null +++ b/pkg/agent/runner/slot_pin_match_test.go @@ -0,0 +1,159 @@ +package runner + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/memory" +) + +// TestMentionsResourceType pins the token-boundary match slotPinRefusalFor uses +// to tie a recorded pin refusal to the denial it explains — including the edge +// that a declared type whose name is a tail of another must NOT match the +// longer one's refusal, and that both refusal shapes (drift and multi-arrival) +// name the type on a non-identifier boundary. +func TestMentionsResourceType(t *testing.T) { + const drift = "slot already pinned to a different instance: this session is pinned to " + + "github_pull_request:acme/original; to work on github_pull_request:acme/widgets, propose an updated plan" + const multi = "slot type github_repo cannot hold 2 distinct instances at once; it is single-occupancy" + + cases := []struct { + name string + text string + resourceType string + want bool + }{ + {name: "drift refusal names the exact type", text: drift, resourceType: "github_pull_request", want: true}, + {name: "tail type must not match the longer type's refusal", text: drift, resourceType: "pull_request", want: false}, + {name: "unrelated type does not match", text: drift, resourceType: "github_repo", want: false}, + {name: "multi-arrival refusal names the type before a space", text: multi, resourceType: "github_repo", want: true}, + {name: "tail type must not match the multi-arrival refusal", text: multi, resourceType: "repo", want: false}, + {name: "empty text never matches", text: "", resourceType: "github_repo", want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, mentionsResourceType(tc.text, tc.resourceType)) + }) + } +} + +// fakePinReader is the minimal authz.RelWriter + authz.SlotPinner the refusal +// record needs: ReadPin answers from a canned map; everything else no-ops. +type fakePinReader struct { + pins map[string]string // keyed by resource type +} + +func (f *fakePinReader) WriteRelationships(context.Context, []authz.Relation) error { return nil } +func (f *fakePinReader) DeleteRelationships(context.Context, []authz.Relation) error { return nil } +func (f *fakePinReader) EnsurePin(_ context.Context, rt, id string, _ authz.SessionRef) (bool, string, error) { + if cur, ok := f.pins[rt]; ok { + return true, cur, nil + } + f.pins[rt] = id + return false, id, nil +} +func (f *fakePinReader) WriteGrantsPinned(context.Context, []authz.Relation, string, string, authz.SessionRef) error { + return nil +} +func (f *fakePinReader) MovePin(_ context.Context, rt, _, toID string, _ []authz.Relation, _ authz.SessionRef) error { + f.pins[rt] = toID + return nil +} +func (f *fakePinReader) ReadPin(_ context.Context, rt string, _ authz.SessionRef) (string, error) { + return f.pins[rt], nil +} +func (f *fakePinReader) ListGrantsFor(context.Context, string, string, authz.SessionRef) ([]authz.Relation, error) { + return nil, nil +} + +const pinRefusalToB = "slot already pinned to a different instance: this session is pinned to " + + "github_repo:demo-org/a; to work on github_repo:demo-org/b, propose an updated plan naming it — an approved plan moves the pin" + +const pinRefusalToC = "slot already pinned to a different instance: this session is pinned to " + + "github_repo:demo-org/a; to work on github_repo:demo-org/c, propose an updated plan naming it — an approved plan moves the pin" + +// TestSlotPinRefusals_NewestWinsAndClearedOnSuccess pins the per-type record's +// two lifecycle rules. Newest wins: two refusals recorded for one type keep +// only the later, so the model is never handed an explanation older than the +// most recent ruling. Cleared on success: the clear the promote nil-error and +// narrowToApproved success paths invoke empties the type's entry, so after an +// approved move (or a promotion that now binds) a subsequent denial for that +// type carries NO stale append — slotPinRefusalFor finds nothing to attach. +func TestSlotPinRefusals_NewestWinsAndClearedOnSuccess(t *testing.T) { + l := &Loop{ + SessionKey: memory.NamespacedName{Namespace: "default", Name: "s1"}, + SlotBinder: &fakePinReader{pins: map[string]string{"github_repo": "demo-org/a"}}, + } + specs := []authz.BoundEntitySpec{{ResourceType: "github_repo"}} + ctx := context.Background() + + l.recordSlotPinRefusal(ctx, specs, pinRefusalToB) + l.recordSlotPinRefusal(ctx, specs, pinRefusalToC) + + ref, ok := l.slotPinRefusalFor("github_repo") + require.True(t, ok, "a recorded refusal must be retrievable by its type") + assert.Equal(t, pinRefusalToC, ref.Text, "newest refusal must win, not the oldest") + assert.Equal(t, "demo-org/a", ref.PinnedID, "the pinned id is read back at record time") + + // Simulate the success/move path's clear (the call narrowToApproved and the + // promote nil-error branches make with the same type list in hand). + l.clearSlotPinRefusals(bindingResourceTypes([]authz.SlotBinding{ + {ResourceType: "github_repo", Permission: "read"}, + })) + + _, ok = l.slotPinRefusalFor("github_repo") + assert.False(t, ok, + "after the type's bind/move succeeds, no refusal remains — the attachment seam "+ + "appends only on a slotPinRefusalFor hit, so a subsequent denial carries no stale append") +} + +// TestSlotPinRefusals_ClearForSpecs covers the promote-success shape of the +// clear, which derives the type list from the specs in hand. +func TestSlotPinRefusals_ClearForSpecs(t *testing.T) { + l := &Loop{SessionKey: memory.NamespacedName{Namespace: "default", Name: "s1"}} + specs := []authz.BoundEntitySpec{{ResourceType: "github_repo"}} + l.recordSlotPinRefusal(context.Background(), specs, pinRefusalToB) + _, ok := l.slotPinRefusalFor("github_repo") + require.True(t, ok) + + l.clearSlotPinRefusalsForSpecs(specs) + _, ok = l.slotPinRefusalFor("github_repo") + assert.False(t, ok, "a nil-error promotion clears its specs' types") +} + +// TestPinRefusalApplies pins the mislabel guard: a denial whose call resolved +// to the PINNED instance itself (refused by some other gate) must not get the +// "move" advice, while every uncertain case fails open to attaching. +func TestPinRefusalApplies(t *testing.T) { + check := authz.PermissionCheck{ + ResourceType: "github_repo", + ResourceIDTemplate: "{repo}", + Permission: "read", + } + ref := slotPinRefusal{Text: pinRefusalToB, PinnedID: "demo-org/a"} + + cases := []struct { + name string + ref slotPinRefusal + args map[string]any + want bool + }{ + {name: "call names the pinned instance itself: skip the move advice", + ref: ref, args: map[string]any{"repo": "demo-org/a"}, want: false}, + {name: "call names a different instance: attach", + ref: ref, args: map[string]any{"repo": "demo-org/b"}, want: true}, + {name: "no pinned id recorded: fail open to attaching", + ref: slotPinRefusal{Text: pinRefusalToB}, args: map[string]any{"repo": "demo-org/a"}, want: true}, + {name: "id does not resolve (arg absent): fail open to attaching", + ref: ref, args: map[string]any{}, want: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, pinRefusalApplies(tc.ref, check, tc.args)) + }) + } +} diff --git a/pkg/agent/runner/slot_pin_mirror.go b/pkg/agent/runner/slot_pin_mirror.go new file mode 100644 index 00000000..5c339d26 --- /dev/null +++ b/pkg/agent/runner/slot_pin_mirror.go @@ -0,0 +1,237 @@ +package runner + +// slot_pin_mirror.go mirrors SpiceDB's slot_pin relation onto AgentSession +// status.slotPins, for every runner path that can produce a pin: +// +// - the plan-gate approval (narrowToApproved, host_approval.go) — first +// fills AND moves, the one path that can stamp MovedBy/MovedAt; +// - the class-defaults bind at session start (internal/cmd/runner/main.go, +// via the exported MirrorDeclaredSlotPins); +// - the extracted promotion (loop_dispatch.go) and the observed promotion +// (loop_autofill.go), via mirrorBoundSlotPins. +// +// The mirror is derived from ACTUAL SpiceDB state — SlotPinner.ReadPin per +// involved single-occupancy type — never from the bindings a caller ASKED to +// write. The two genuinely differ: authz.BindApproved under +// EnforcePreconditions silently drops Refused/Undetermined candidates and +// returns nil having bound nothing, and the promotion paths report only +// overall success, so a binding-derived mirror would claim pins SpiceDB never +// wrote. ReadPin returning "" for a type therefore mirrors NOTHING for it. +// +// Cost is bounded by skipping the read for a type already present in +// status.slotPins: a pin never changes outside an approved move, and the move +// site refreshes the mirror itself (its types are exempt from the skip), so a +// mirrored type is never re-read — steady-state cost zero. Only a declared +// single-occupancy type that has never bound keeps paying one advisory read +// per mirror call. +import ( + "context" + "fmt" + "log/slog" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/memory/kinds/plangateaudit" +) + +// recordSlotPin mirrors one pin observation onto status.slotPins, best +// effort: SpiceDB's slot_pin relation is the enforcement truth, so a mirror +// failure must never retroactively fail the bind that already succeeded. +// Logged rather than swallowed, per the no-silent-errors rule — same shape as +// sidecarsynth.go's recordReachability wrapping RecordSidecarReachability. +func recordSlotPin(ctx context.Context, sp *StatusPatcher, pin spiceboxv1alpha1.SlotPin) { + if sp == nil { + return + } + if err := sp.RecordSlotPin(ctx, pin); err != nil { + slog.Default().Info("slot pin mirror failed; status.slotPins may lag SpiceDB's slot_pin relation", + "resourceType", pin.ResourceType, "resourceID", pin.ResourceID, "movedBy", pin.MovedBy, "err", err.Error()) + } +} + +// planGateApprovalRef derives the stable reference SlotPin.MovedBy records +// for a move a plan-gate approval authorized. PhaseKey identifies the +// approved phase by its authority (the permissions it holds and the +// instances it names) and is what a current record carries; PlanDigest +// plus the active phase index is the fallback for a record written before +// PhaseKey existed, the same fallback the fold itself uses (see +// plangateaudit.Content.PhaseKey's doc comment). +func planGateApprovalRef(rec plangateaudit.Content) string { + if rec.PhaseKey != "" { + return rec.PhaseKey + } + return fmt.Sprintf("%s#%d", rec.PlanDigest, phaseIndexOf(rec)) +} + +// pinMirrorMove marks a resource type the current call MOVED: ref is the +// approval-record reference MovedBy records, toID the instance the approval +// repointed the pin to. MovedBy is stamped only when the pin READ BACK equals +// toID — a pin that is somewhere else was not produced by this move, and +// attributing it would record a move nobody made. +type pinMirrorMove struct { + ref string + toID string +} + +// mirrorSlotPinsFromSpiceDB reads the CURRENT pin for each type and mirrors +// exactly what SpiceDB holds — see the file comment for why the read, the +// skip, and the empty-read-mirrors-nothing rule each exist. types carries the +// single-occupancy resource types the finished call attempted (the caller +// filters out multi-occupancy, which holds no pin); moves the subset it moved. +// +// The already-mirrored skip consults status through sp.Get once per call. A +// failed Get degrades to "nothing mirrored yet" — at worst a redundant +// advisory read plus an upsert that UpsertSlotPin's same-instance carve-out +// turns into a no-op — because skipping the whole mirror on a transient read +// error would leave a fresh pin invisible until the next bind. +func mirrorSlotPinsFromSpiceDB( + ctx context.Context, + sp *StatusPatcher, + pinner authz.SlotPinner, + sess authz.SessionRef, + types []string, + moves map[string]pinMirrorMove, + now time.Time, +) { + if sp == nil || sp.IsLocal() || pinner == nil || len(types) == 0 { + return + } + mirrored := make(map[string]bool) + var cur spiceboxv1alpha1.AgentSession + if err := sp.Get(ctx, &cur); err != nil { + slog.Default().Info("slot pin mirror could not read current status; mirroring without the already-mirrored skip", + "session", sess.String(), "err", err.Error()) + } else { + for _, p := range cur.Status.SlotPins { + mirrored[p.ResourceType] = true + } + } + movedAt := metav1.NewTime(now) + seen := make(map[string]bool, len(types)) + for _, rt := range types { + if rt == "" || seen[rt] { + continue + } + seen[rt] = true + mv, moved := moves[rt] + if !moved && mirrored[rt] { + // Already mirrored, and pins change only through the move path, + // whose types bypass this skip — nothing to re-read. + continue + } + id, err := pinner.ReadPin(ctx, rt, sess) + if err != nil { + slog.Default().Info("slot pin mirror could not read the pin; status.slotPins may lag SpiceDB", + "session", sess.String(), "resourceType", rt, "err", err.Error()) + continue + } + if id == "" { + // Nothing pinned — the bind may have dropped every candidate for + // this type (a Refused precondition, an approver holding nothing). + // Mirroring the attempt would claim a pin SpiceDB never wrote. + continue + } + pin := spiceboxv1alpha1.SlotPin{ResourceType: rt, ResourceID: id} + if moved && mv.toID == id { + pin.MovedBy = mv.ref + pin.MovedAt = &movedAt + } + recordSlotPin(ctx, sp, pin) + } +} + +// mirrorApprovedSlotPins reflects a nil-error authz.BindApproved onto +// status.slotPins: every single-occupancy type the approved bindings name is +// re-read from SpiceDB and mirrored as found. A binding carrying a non-zero +// PriorID asked to MOVE the pin — moveApprovedPins runs before bindSlots +// inside BindApproved, so on the nil-error path the move already landed — and +// its type gets MovedBy (planGateApprovalRef(rec)) and MovedAt stamped, but +// only when the read-back pin equals the move's target. +// +// Deliberately NOT called on BindApproved's error paths: a non-nil error can +// leave SpiceDB mid-way (a move landed, a later type refused), and the +// existing error handling already treats that as "did not narrow the session" +// without accounting per type. status.slotPins is display-only and may lag; +// the next successful bind or move catches it up. +func mirrorApprovedSlotPins( + ctx context.Context, sp *StatusPatcher, binder authz.RelWriter, sess authz.SessionRef, + rec plangateaudit.Content, bindings []authz.SlotBinding, now time.Time, +) { + // Same assertion shape as hooks_dataplane.go: a binder that is not a + // SlotPinner cannot have pinned anything (GrantSlots refuses + // single-occupancy types for it), so there is nothing to read or mirror. + pinner, ok := binder.(authz.SlotPinner) + if !ok { + return + } + var types []string + var moves map[string]pinMirrorMove + seen := make(map[string]bool, len(bindings)) + for _, b := range bindings { + // Multi-occupancy slots hold no pin — SlotPin mirrors the + // single-occupancy commitment, not the open-ended multi-occupancy + // grant set (that is what `oap session slots` lists). + if b.ResourceType == "" || b.Occupancy == authz.SlotOccupancyMulti { + continue + } + if !seen[b.ResourceType] { + seen[b.ResourceType] = true + types = append(types, b.ResourceType) + } + if !b.PriorID.IsZero() { + if moves == nil { + moves = make(map[string]pinMirrorMove) + } + if _, dup := moves[b.ResourceType]; !dup { + moves[b.ResourceType] = pinMirrorMove{ref: planGateApprovalRef(rec), toID: b.ResourceID.String()} + } + } + } + mirrorSlotPinsFromSpiceDB(ctx, sp, pinner, sess, types, moves, now) +} + +// mirrorBoundSlotPins mirrors the pin state of every single-occupancy slot +// type the given specs declare, after one of the non-approval bind paths +// (class defaults, extracted promotion, observed promotion) reported overall +// success. Those paths never say which instance (if any) a type ended up +// pinned to — their signatures return only error — so the mirror reads the +// answer from SpiceDB. First-fill shape only: no MovedBy/MovedAt, because a +// non-approval path can never move a pin. +func (l *Loop) mirrorBoundSlotPins(ctx context.Context, specs []authz.BoundEntitySpec) { + if l == nil || len(specs) == 0 { + return + } + pinner, ok := l.SlotBinder.(authz.SlotPinner) + if !ok { + return + } + var types []string + for _, et := range specs { + // Empty Occupancy means single (occupancyOf's fail-closed default). + if et.ResourceType != "" && et.Occupancy != authz.SlotOccupancyMulti { + types = append(types, et.ResourceType) + } + } + mirrorSlotPinsFromSpiceDB(ctx, l.Status, pinner, l.authzSessionRef(), types, nil, time.Now()) +} + +// MirrorDeclaredSlotPins is mirrorBoundSlotPins for a caller outside this +// package that holds no spec slice — internal/cmd/runner, after the +// cold-start BindClassDefaults — deriving the declared slots from the Loop's +// own AgentClass. A class whose slot preconditions do not compile mirrors +// nothing, exactly as every bind path skips such a class. +func (l *Loop) MirrorDeclaredSlotPins(ctx context.Context) { + if l == nil || l.AgentClass == nil || len(l.AgentClass.Spec.GetSlots()) == 0 { + return + } + specs, err := boundEntitySpecsForAutofill(l.AgentClass) + if err != nil { + // The bind this mirrors was itself skipped for the same reason and + // said so; one more line here would be noise. + return + } + l.mirrorBoundSlotPins(ctx, specs) +} diff --git a/pkg/agent/runner/slot_pin_mirror_test.go b/pkg/agent/runner/slot_pin_mirror_test.go new file mode 100644 index 00000000..abf1eec8 --- /dev/null +++ b/pkg/agent/runner/slot_pin_mirror_test.go @@ -0,0 +1,262 @@ +package runner + +// slot_pin_mirror_test.go covers the status.slotPins mirror wiring: that it +// reflects what SpiceDB actually holds (never what a caller asked to bind), +// that a plan-gate approval first-fill and move each land with the right +// shape, that the promote path mirrors through its real call site, and that +// an already-mirrored type costs no advisory read. + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + ctrlfake "sigs.k8s.io/controller-runtime/pkg/client/fake" + + agenttool "github.com/authzed/openagentprimitives/pkg/agent/tool" + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/authz/permsurface" + "github.com/authzed/openagentprimitives/pkg/channels/channelevents" + "github.com/authzed/openagentprimitives/pkg/memory" + "github.com/authzed/openagentprimitives/pkg/memory/inmem" + "github.com/authzed/openagentprimitives/pkg/memory/kinds/plangateaudit" +) + +// recordingPinner is a stateful SlotBinder + SlotPinner: EnsurePin pins +// first-in-wins, MovePin repoints, and ReadPin reports the current pin while +// counting its calls — the count is what proves the already-mirrored skip. +type recordingPinner struct { + pins map[string]string // resourceType -> pinned id + readCalls int +} + +func (*recordingPinner) WriteRelationships(context.Context, []authz.Relation) error { return nil } +func (*recordingPinner) DeleteRelationships(context.Context, []authz.Relation) error { return nil } +func (p *recordingPinner) EnsurePin(_ context.Context, resourceType, resourceID string, _ authz.SessionRef) (bool, string, error) { + if p.pins == nil { + p.pins = map[string]string{} + } + if cur, ok := p.pins[resourceType]; ok { + return true, cur, nil + } + p.pins[resourceType] = resourceID + return false, resourceID, nil +} +func (*recordingPinner) WriteGrantsPinned(context.Context, []authz.Relation, string, string, authz.SessionRef) error { + return nil +} +func (p *recordingPinner) MovePin(_ context.Context, resourceType, _, toID string, _ []authz.Relation, _ authz.SessionRef) error { + if p.pins == nil { + p.pins = map[string]string{} + } + p.pins[resourceType] = toID + return nil +} +func (p *recordingPinner) ReadPin(_ context.Context, resourceType string, _ authz.SessionRef) (string, error) { + p.readCalls++ + return p.pins[resourceType], nil +} +func (*recordingPinner) ListGrantsFor(context.Context, string, string, authz.SessionRef) ([]authz.Relation, error) { + return nil, nil +} + +var _ authz.SlotPinner = (*recordingPinner)(nil) + +// mirrorStatusPatcher builds a fake-client-backed StatusPatcher for ns/name, +// returning the client so a test can Get the session back. +func mirrorStatusPatcher(t *testing.T, ns, name string) (*StatusPatcher, ctrlclient.Client) { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, spiceboxv1alpha1.AddToScheme(s), "AddToScheme") + sess := &spiceboxv1alpha1.AgentSession{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + } + c := ctrlfake.NewClientBuilder(). + WithScheme(s). + WithObjects(sess). + WithStatusSubresource(&spiceboxv1alpha1.AgentSession{}). + Build() + return NewStatusPatcher(c, types.NamespacedName{Namespace: ns, Name: name}), c +} + +func slotPinsOf(t *testing.T, c ctrlclient.Client, ns, name string) []spiceboxv1alpha1.SlotPin { + t.Helper() + var out spiceboxv1alpha1.AgentSession + require.NoError(t, c.Get(context.Background(), + types.NamespacedName{Namespace: ns, Name: name}, &out), "Get session") + return out.Status.SlotPins +} + +// The review's false-positive case, end to end: a plan-gate approval whose +// one slot instance a Refused precondition DROPS reaches BindApproved, which +// returns nil having bound (and pinned) NOTHING. The mirror must reflect +// SpiceDB — no pin — not the bindings the call was asked to write. The +// Satisfied control proves the same wiring mirrors a pin that WAS written. +func TestNarrowToApproved_refusedPreconditionMirrorsNoSlotPin(t *testing.T) { + cases := []struct { + name string + isPublic bool // the recorded fact; false → the precondition Refuses + wantMirror bool + why string + }{ + { + name: "Refused precondition: nothing pinned, nothing mirrored", + isPublic: false, + wantMirror: false, + why: "BindApproved dropped the candidate and pinned nothing; a mirror entry would claim a pin SpiceDB never wrote", + }, + { + name: "Satisfied precondition: the pinned instance is mirrored", + isPublic: true, + wantMirror: true, + why: "the bound instance was pinned via EnsurePin, so the mirror must surface it", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, mem := slotDecisionHost(t) + h.l.PlanGateSlotPermissions = map[string]string{"crm_company": "contact_access"} + h.l.AgentClass = classWithGatedCompany(`facts.observed.is_public == true`) + recordCompanyFact(t, mem, "4210", tc.isPublic) + + pinner := &recordingPinner{} + h.l.SlotBinder = pinner + sp, c := mirrorStatusPatcher(t, "ns", "s") + h.l.Status = sp + + ask := planPhaseAsk() + ask.Payload["slots"] = []string{"crm_company"} + ask.Payload["slotValues"] = map[string]string{"crm_company": "4210"} + + driveDecision(t, h, mem, ask, true) + + pins := slotPinsOf(t, c, "ns", "s") + if !tc.wantMirror { + assert.Empty(t, pins, tc.why) + return + } + require.Len(t, pins, 1, tc.why) + assert.Equal(t, "crm_company", pins[0].ResourceType) + assert.Equal(t, "4210", pins[0].ResourceID) + assert.Empty(t, pins[0].MovedBy, "a first fill is not a move") + assert.Nil(t, pins[0].MovedAt) + }) + } +} + +// An approved MOVE that executes must mirror the repointed pin WITH its +// provenance: MovedBy names the approval record (its PhaseKey) and MovedAt +// the mirror time. Same scenario shape as the move-failure test beside it +// (host_approval_move_test.go), with a pinner whose MovePin succeeds. +func TestNarrowToApproved_executedMoveMirrorsThePinWithMovedBy(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + h := planGateHost(t, &channelevents.Envelope{}) + h.l.Mem = mem + pinner := &recordingPinner{pins: map[string]string{"git_repo": "repoA"}} + h.l.SlotBinder = pinner + h.l.PlanGateSlotPermissionSets = map[string][]string{"git_repo": {"push"}} + sp, c := mirrorStatusPatcher(t, "ns", "s") + h.l.Status = sp + + pushHandle := mustPermHandle(t, "push", "git_repo") + idx := int32(0) + ask := planPhaseAsk() + ask.Payload["phaseKey"] = "p1" + ask.Payload["ceiling"] = []string{pushHandle} + ask.Payload["covered"] = []plangateaudit.Content{{ + Event: plangateaudit.EventPhaseApproved, + PhaseKey: "p1", + PhaseIndex: &idx, + Ceiling: []string{pushHandle}, + Slots: []string{"git_repo"}, + SlotRefs: []plangateaudit.SlotRef{{Type: "git_repo", ID: "repoB", MovedFrom: "repoA"}}, + }} + + driveDecision(t, h, mem, ask, true) + + require.Equal(t, "repoB", pinner.pins["git_repo"], "the approved move must have executed") + pins := slotPinsOf(t, c, "ns", "s") + require.Len(t, pins, 1, "the moved pin must be mirrored") + assert.Equal(t, "git_repo", pins[0].ResourceType) + assert.Equal(t, "repoB", pins[0].ResourceID, "the mirror must show the instance the pin moved TO") + assert.Equal(t, "p1", pins[0].MovedBy, "MovedBy must name the approval record's phase key") + require.NotNil(t, pins[0].MovedAt, "a move must carry its timestamp") +} + +// The promote path, through its REAL call site: one dispatch round of an +// observing tool records a fact, promoteObservedSlots binds and pins it, and +// the mirror at the end of the round surfaces exactly the pin ReadPin +// reports. Same harness as TestDispatch_ObservedFactBindsTheSlotWithoutA- +// FurtherTurn (loop_observed_binding_test.go), plus the SlotBinder + Status +// the mirror needs — delete the mirrorBoundSlotPins call in +// promoteObservedSlots and this fails with empty status.slotPins. +func TestDispatch_promotedObservedPinIsMirroredOntoStatus(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + w := &observedRelWriter{} + mt := observedMCPTool(t, mem) + l := observedBindingLoop(t, mem, w, []agenttool.Tool{mt}) + // The engine's grant writer doubles as the mirror's pinner, exactly as + // production's spicedb RelationWriter does for both roles. + l.SlotBinder = w + sp, c := mirrorStatusPatcher(t, "default", "disp") + l.Status = sp + + results := dispatchObservingTool(t, l, mt) + require.False(t, results[0].IsError, "the observing tool must succeed; content=%q", results[0].Content) + + require.Len(t, w.wrote, 1, "the promotion must have bound the observed instance") + pins := slotPinsOf(t, c, "default", "disp") + require.Len(t, pins, 1, "the pin the promotion wrote must be mirrored within the same round") + assert.Equal(t, "git_commit", pins[0].ResourceType) + assert.Equal(t, "ba03f5969a", pins[0].ResourceID, "the mirror must report what ReadPin reports") + assert.Empty(t, pins[0].MovedBy, "a promotion fill is never a move") + assert.Nil(t, pins[0].MovedAt) +} + +// The cost bound: a type already present in status.slotPins is skipped +// without an advisory read (pins change only through the move path, which +// refreshes the mirror itself), while an unmirrored sibling still reads once +// and lands. Multi-occupancy types are never read — they hold no pin. +func TestMirrorBoundSlotPins_alreadyMirroredTypeSkipsTheRead(t *testing.T) { + ctx := context.Background() + sp, c := mirrorStatusPatcher(t, "ns", "s") + require.NoError(t, sp.RecordSlotPin(ctx, + spiceboxv1alpha1.SlotPin{ResourceType: "git_repo", ResourceID: "repoA"}), "seed the mirrored entry") + + pinner := &recordingPinner{pins: map[string]string{"git_repo": "repoA", "crm_company": "4210"}} + l := &Loop{ + Status: sp, + SlotBinder: pinner, + SessionKey: memory.NamespacedName{Namespace: "ns", Name: "s"}, + } + + // Only the already-mirrored type attempted: zero reads. + l.mirrorBoundSlotPins(ctx, []authz.BoundEntitySpec{{ResourceType: "git_repo", Permission: "push"}}) + assert.Zero(t, pinner.readCalls, "an already-mirrored type must not be re-read") + + // An unmirrored sibling reads exactly once; the mirrored one still skips; + // a multi-occupancy type is never a candidate at all. + l.mirrorBoundSlotPins(ctx, []authz.BoundEntitySpec{ + {ResourceType: "git_repo", Permission: "push"}, + {ResourceType: "crm_company", Permission: "contact_access"}, + {ResourceType: "log_stream", Permission: "read", Occupancy: authz.SlotOccupancyMulti}, + }) + assert.Equal(t, 1, pinner.readCalls, "only the unmirrored single-occupancy type is read") + pins := slotPinsOf(t, c, "ns", "s") + require.Len(t, pins, 2, "both single-occupancy pins mirrored, the multi type absent") +} + +// mustPermHandle builds a permsurface handle string, failing the test on a +// malformed pair. +func mustPermHandle(t *testing.T, perm, resType string) string { + t.Helper() + h, err := permsurface.NewPermHandle(perm, resType) + require.NoError(t, err) + return h.String() +} diff --git a/pkg/agent/runner/status.go b/pkg/agent/runner/status.go index 8dd23d07..a40e832e 100644 --- a/pkg/agent/runner/status.go +++ b/pkg/agent/runner/status.go @@ -470,6 +470,19 @@ func (s *StatusPatcher) RecordObservedPin(ctx context.Context, name string, pin }) } +// RecordSlotPin upserts (by pin.ResourceType) one per-session slot-pin +// observation onto status.slotPins. DISPLAY-ONLY, mirroring the slot_pin +// relation SpiceDB already enforces — see SlotPin's doc comment. Callers must +// treat a non-nil error as advisory: log it (no-silent-errors) and continue, +// never fail the bind that already succeeded in SpiceDB on account of a +// status-mirror write failing. See recordSlotPin in slot_pin_mirror.go for +// the logging wrapper every mirror path routes through. +func (s *StatusPatcher) RecordSlotPin(ctx context.Context, pin spiceboxv1alpha1.SlotPin) error { + return s.mutate(ctx, func(sess *spiceboxv1alpha1.AgentSession) { + sess.Status.SlotPins = spiceboxv1alpha1.UpsertSlotPin(sess.Status.SlotPins, pin) + }) +} + // RecordSidecarReachability upserts (by name — the LLM-facing prefix) the // runner's per-session live-probe result for one sidecar onto // status.sidecarReachability. Both the boot pass and the mid-session refresher diff --git a/pkg/agent/runner/status_test.go b/pkg/agent/runner/status_test.go index 1b60d102..bef29452 100644 --- a/pkg/agent/runner/status_test.go +++ b/pkg/agent/runner/status_test.go @@ -189,6 +189,59 @@ func TestRecordObservedPin(t *testing.T) { assert.Len(t, out.Status.ObservedPins, 2, "second distinct name must produce a second entry") } +func TestRecordSlotPin(t *testing.T) { + c, sess := newFakeClientWithSession(t) + sp := runner.NewStatusPatcher(c, client.ObjectKeyFromObject(sess)) + ctx := context.Background() + + pin1 := spiceboxv1alpha1.SlotPin{ResourceType: "github_repo", ResourceID: "owner/repo-a"} + require.NoError(t, sp.RecordSlotPin(ctx, pin1), "RecordSlotPin (first)") + + var out spiceboxv1alpha1.AgentSession + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(sess), &out), "Get after first RecordSlotPin") + require.Len(t, out.Status.SlotPins, 1, "expected one entry after first write") + assert.Equal(t, "github_repo", out.Status.SlotPins[0].ResourceType) + assert.Equal(t, "owner/repo-a", out.Status.SlotPins[0].ResourceID) + assert.Empty(t, out.Status.SlotPins[0].MovedBy, "a first-fill pin carries no MovedBy") + + // Upsert (a MOVE): same ResourceType replaces the existing entry, carrying + // MovedBy/MovedAt, rather than appending. + movedAt := metav1.Now() + pin1moved := spiceboxv1alpha1.SlotPin{ + ResourceType: "github_repo", + ResourceID: "owner/repo-b", + MovedBy: "phase-key-1", + MovedAt: &movedAt, + } + require.NoError(t, sp.RecordSlotPin(ctx, pin1moved), "RecordSlotPin (move)") + + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(sess), &out), "Get after move") + require.Len(t, out.Status.SlotPins, 1, "a move must not append a second entry") + assert.Equal(t, "owner/repo-b", out.Status.SlotPins[0].ResourceID, "move must update the resource id") + assert.Equal(t, "phase-key-1", out.Status.SlotPins[0].MovedBy) + require.NotNil(t, out.Status.SlotPins[0].MovedAt) + + // Same-instance re-grant (no move metadata): the recorded MovedBy/MovedAt + // must be PRESERVED, not clobbered back to a first-fill shape. + require.NoError(t, sp.RecordSlotPin(ctx, + spiceboxv1alpha1.SlotPin{ResourceType: "github_repo", ResourceID: "owner/repo-b"}), + "RecordSlotPin (same-instance re-grant)") + + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(sess), &out), "Get after same-instance re-grant") + require.Len(t, out.Status.SlotPins, 1) + assert.Equal(t, "phase-key-1", out.Status.SlotPins[0].MovedBy, + "a same-instance re-grant must preserve MovedBy") + require.NotNil(t, out.Status.SlotPins[0].MovedAt, + "a same-instance re-grant must preserve MovedAt") + + // Different resource type appends independently. + pin2 := spiceboxv1alpha1.SlotPin{ResourceType: "github_pr", ResourceID: "owner/repo#42"} + require.NoError(t, sp.RecordSlotPin(ctx, pin2), "RecordSlotPin (second resource type)") + + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(sess), &out), "Get after second resource type write") + assert.Len(t, out.Status.SlotPins, 2, "second distinct resource type must produce a second entry") +} + func TestSetAndClearAwaitingUserInput(t *testing.T) { c, sess := newFakeClientWithSession(t) p := runner.NewStatusPatcher(c, client.ObjectKeyFromObject(sess)) diff --git a/pkg/apis/v1alpha1/agentclass_types.go b/pkg/apis/v1alpha1/agentclass_types.go index 763a8956..61cf1a0d 100644 --- a/pkg/apis/v1alpha1/agentclass_types.go +++ b/pkg/apis/v1alpha1/agentclass_types.go @@ -1111,6 +1111,14 @@ type BoundChannelRef struct { // pkg/platform/oap/install/apply.go's completion of spec.authz.slots. const AuthzSlotMembershipDefault = "frozen" +// AuthzSlotOccupancyDefault mirrors AuthzSlot.Occupancy's +kubebuilder:default. +// Completed by the installer before SSA apply for the same reason membership is +// (slots is +listType=atomic) — see pkg/platform/oap/install/apply.go. +const AuthzSlotOccupancyDefault = "single" + +// AuthzSlotRebindDefault mirrors AuthzSlot.Rebind's +kubebuilder:default. +const AuthzSlotRebindDefault = "approval" + // AuthzSlot declares a TYPE of resource this agent operates on, gated by a // permission — the instance axis of the two-axis ceiling: the permission // Check'd at bind time, optional default IDs to bind at session start, and @@ -1245,6 +1253,41 @@ type AuthzSlot struct { // +kubebuilder:default=frozen Membership string `json:"membership,omitempty"` + // Occupancy decides how many instances may occupy this slot at once. + // + // single (default): at most one instance occupies the slot for the + // session's life, recorded as a non-expiring slot_pin relationship. A + // bind to a DIFFERENT instance is refused rather than silently added. + // A second permission on the SAME instance, and a re-grant of the same + // instance after its grants expired, are not drift and still bind. + // + // multi: the slot binds a set; each addition is gated as it is today. + // No pin is written. + // + // UPGRADE NOTE. Before this field existed every slot behaved as multi, and a + // class written then has no occupancy set, so the apiserver now defaults it + // to single. A slot listing two or more defaults is then refused + // (SlotDeclarationInvalid), and a thread seed or trigger binding several + // instances of one type is refused rather than binding them all. Set + // occupancy: multi on such slots before upgrading. + // +optional + // +kubebuilder:validation:Enum=single;multi + // +kubebuilder:default=single + Occupancy string `json:"occupancy,omitempty"` + + // Rebind decides what a bind to a DIFFERENT instance does on a filled + // single-occupancy slot. Meaningful only when occupancy is single + // (as triggerInstance is meaningful only with fillFrom "trigger"). + // + // approval (default): refused, and the refusal names the route — an + // approved plan amendment naming the new instance moves the pin and + // revokes the old instance's grants. + // never: refused unconditionally; retargeting requires a new session. + // +optional + // +kubebuilder:validation:Enum=approval;never + // +kubebuilder:default=approval + Rebind string `json:"rebind,omitempty"` + // Requires are predicates over the facts a candidate arrived with, all of // which must be Satisfied before it may occupy this slot. Any Refused or // Undetermined verdict holds the slot closed. diff --git a/pkg/apis/v1alpha1/agentsession_ownership.go b/pkg/apis/v1alpha1/agentsession_ownership.go index cc4dc489..41e391d8 100644 --- a/pkg/apis/v1alpha1/agentsession_ownership.go +++ b/pkg/apis/v1alpha1/agentsession_ownership.go @@ -94,6 +94,11 @@ var operatorOwnedStatusFields = map[string]bool{ "resolvedContentGuardDetectors": true, "resolvedSkillBundles": true, "observedPins": true, + // slotPins is written by the RUNNER (the plan-gate approval path, + // narrowToApproved), like observedPins beside it — a display-only mirror + // of SpiceDB's slot_pin relation, never read by channelsd's approvals + // surface. + "slotPins": true, // sidecarReachability is written by the RUNNER (the session-layer sidecar // prober), like observedPins/progress — the operator never writes it, so a // runner merge-patch of it never conflicts with the operator rebuilding the diff --git a/pkg/apis/v1alpha1/agentsession_types.go b/pkg/apis/v1alpha1/agentsession_types.go index 0191a853..b0ad1027 100644 --- a/pkg/apis/v1alpha1/agentsession_types.go +++ b/pkg/apis/v1alpha1/agentsession_types.go @@ -220,6 +220,17 @@ type AgentSessionStatus struct { // +optional ObservedPins []ObservedPin `json:"observedPins,omitempty"` + // SlotPins is a DISPLAY-ONLY observation, one entry per single-occupancy + // resource type, mirroring the instance SpiceDB's slot_pin relation + // currently pins this session to. SpiceDB is the enforcement source of + // truth for single-occupancy pinning; nothing that authorizes a bind or a + // tool call reads this field. See SlotPin's doc comment for what writes + // it and what MovedBy/MovedAt mean. + // +optional + // +listType=map + // +listMapKey=resourceType + SlotPins []SlotPin `json:"slotPins,omitempty"` + // CredentialAuthFailures records, per tool ORIGIN, that the platform ITSELF // observed an auth-shaped failure there — the independent corroboration a // CredentialUpdateRequest needs when the provider cannot be re-probed to diff --git a/pkg/apis/v1alpha1/slot_pin_status.go b/pkg/apis/v1alpha1/slot_pin_status.go new file mode 100644 index 00000000..b1eda427 --- /dev/null +++ b/pkg/apis/v1alpha1/slot_pin_status.go @@ -0,0 +1,66 @@ +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// SlotPin is a DISPLAY-ONLY mirror of one single-occupancy slot's current +// commitment — the instance SpiceDB's slot_pin relation pins the session to +// for ResourceType, written by authz.GrantSlots (via SlotPinner.EnsurePin) on +// a first bind and repointed by authz.BindApproved's move path +// (SlotPinner.MovePin) when an approved plan moves it. +// +// SpiceDB is the enforcement source of truth: the slot_pin tuple is what a +// tool-call Check and GrantSlots' own single-occupancy gate actually consult. +// Nothing that authorizes a bind or a call reads this field — it exists so +// `oap session show` and operator logs can see the commitment without a live +// SpiceDB read, and it can lag or be absent (e.g. no status writer wired) +// without changing what is actually enforced. +type SlotPin struct { + // ResourceType is the slot's declared resource type, and the key + // UpsertSlotPin replaces by — a single-occupancy slot holds at most one + // instance per type for the session's life, so one entry per type is + // always the complete picture. + ResourceType string `json:"resourceType"` + + // ResourceID is the instance ResourceType is currently pinned to. + ResourceID string `json:"resourceID"` + + // MovedBy is the approval-record reference that authorized the most + // recent MOVE off a prior instance — empty for a pin that has only ever + // been first-filled. Populated only by the plan-gate approval path + // (pkg/agent/runner's narrowToApproved), from the plan-gate audit record + // that carried the approval: its PhaseKey, or PlanDigest+PhaseIndex when + // PhaseKey is unset on an older record. + // +optional + MovedBy string `json:"movedBy,omitempty"` + + // MovedAt is when MovedBy's move landed on this pin. Empty for a pin that + // has only ever been first-filled. + // +optional + MovedAt *metav1.Time `json:"movedAt,omitempty"` +} + +// UpsertSlotPin upserts (by ResourceType) one SlotPin into list, with one +// carve-out that keeps move provenance stable: when the existing entry +// already names pin's OWN ResourceID and pin carries no MovedBy/MovedAt (a +// same-instance re-grant, or a redundant advisory re-read of an unchanged +// pin), the existing entry — including any recorded MovedBy/MovedAt — is +// preserved untouched. Everything else replaces the entry wholesale: a move +// (pin carries MovedBy) records its new provenance, and a DIFFERENT +// ResourceID arriving without move metadata clears the old provenance rather +// than attributing the previous move to an instance it never produced. +// Otherwise pin is appended. The updated slice is returned; the caller is +// responsible for assigning it back (e.g. +// sess.Status.SlotPins = UpsertSlotPin(sess.Status.SlotPins, pin)). +func UpsertSlotPin(list []SlotPin, pin SlotPin) []SlotPin { + for i := range list { + if list[i].ResourceType != pin.ResourceType { + continue + } + if list[i].ResourceID == pin.ResourceID && pin.MovedBy == "" && pin.MovedAt == nil { + return list + } + list[i] = pin + return list + } + return append(list, pin) +} diff --git a/pkg/apis/v1alpha1/slot_pin_status_test.go b/pkg/apis/v1alpha1/slot_pin_status_test.go new file mode 100644 index 00000000..ca98fb6f --- /dev/null +++ b/pkg/apis/v1alpha1/slot_pin_status_test.go @@ -0,0 +1,73 @@ +package v1alpha1_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" +) + +// TestUpsertSlotPin covers the insert, update, and multi-type semantics of +// UpsertSlotPin: first write appends; same ResourceType updates in-place +// (including clearing MovedBy/MovedAt back to a first-fill shape); a distinct +// ResourceType appends independently. +func TestUpsertSlotPin(t *testing.T) { + pin1 := spiceboxv1alpha1.SlotPin{ResourceType: "github_repo", ResourceID: "owner/repo-a"} + + // Insert into empty list. + list := spiceboxv1alpha1.UpsertSlotPin(nil, pin1) + require.Len(t, list, 1, "first insert produces one entry") + assert.Equal(t, "github_repo", list[0].ResourceType) + assert.Equal(t, "owner/repo-a", list[0].ResourceID) + assert.Empty(t, list[0].MovedBy, "a first-fill pin carries no MovedBy") + assert.Nil(t, list[0].MovedAt, "a first-fill pin carries no MovedAt") + + // Upsert (same ResourceType) replaces the existing entry, not appends — + // this is the shape a move produces: a new ResourceID plus MovedBy/MovedAt. + movedAt := metav1.Now() + pin1moved := spiceboxv1alpha1.SlotPin{ + ResourceType: "github_repo", + ResourceID: "owner/repo-b", + MovedBy: "phase-key-1", + MovedAt: &movedAt, + } + list = spiceboxv1alpha1.UpsertSlotPin(list, pin1moved) + require.Len(t, list, 1, "upsert must not duplicate") + assert.Equal(t, "owner/repo-b", list[0].ResourceID, "upsert must update the resource id") + assert.Equal(t, "phase-key-1", list[0].MovedBy) + require.NotNil(t, list[0].MovedAt) + assert.True(t, movedAt.Time.Equal(list[0].MovedAt.Time)) + + // Same-instance re-mirror (a re-grant, a redundant advisory read): the + // entry already says everything this upsert would, so the recorded move + // provenance is PRESERVED, not clobbered back to a first-fill shape. + list = spiceboxv1alpha1.UpsertSlotPin(list, + spiceboxv1alpha1.SlotPin{ResourceType: "github_repo", ResourceID: "owner/repo-b"}) + require.Len(t, list, 1, "same-instance upsert must not duplicate") + assert.Equal(t, "phase-key-1", list[0].MovedBy, "same-instance re-grant must preserve MovedBy") + require.NotNil(t, list[0].MovedAt, "same-instance re-grant must preserve MovedAt") + assert.True(t, movedAt.Time.Equal(list[0].MovedAt.Time)) + + // A DIFFERENT instance without move metadata still replaces wholesale — + // the pin genuinely moved and the old provenance describes a move onto an + // instance no longer pinned, so keeping it would attribute the wrong move. + list = spiceboxv1alpha1.UpsertSlotPin(list, + spiceboxv1alpha1.SlotPin{ResourceType: "github_repo", ResourceID: "owner/repo-c"}) + require.Len(t, list, 1) + assert.Equal(t, "owner/repo-c", list[0].ResourceID) + assert.Empty(t, list[0].MovedBy, "a new instance with unknown provenance must not inherit the old move's MovedBy") + assert.Nil(t, list[0].MovedAt) + + // A distinct ResourceType appends independently. + pin2 := spiceboxv1alpha1.SlotPin{ResourceType: "github_pr", ResourceID: "owner/repo#42"} + list = spiceboxv1alpha1.UpsertSlotPin(list, pin2) + require.Len(t, list, 2, "second distinct resource type produces a second entry") + assert.Equal(t, "github_pr", list[1].ResourceType) + assert.Equal(t, "owner/repo#42", list[1].ResourceID) + + // The first entry (github_repo) must be unchanged by the second insert. + assert.Equal(t, "owner/repo-c", list[0].ResourceID, "unrelated insert must not disturb the existing entry") +} diff --git a/pkg/apis/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/v1alpha1/zz_generated.deepcopy.go index dcb66ad0..5c291c8d 100644 --- a/pkg/apis/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/v1alpha1/zz_generated.deepcopy.go @@ -887,6 +887,13 @@ func (in *AgentSessionStatus) DeepCopyInto(out *AgentSessionStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.SlotPins != nil { + in, out := &in.SlotPins, &out.SlotPins + *out = make([]SlotPin, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.CredentialAuthFailures != nil { in, out := &in.CredentialAuthFailures, &out.CredentialAuthFailures *out = make([]CredentialAuthFailure, len(*in)) @@ -6539,6 +6546,25 @@ func (in *SlackOutputDefaults) DeepCopy() *SlackOutputDefaults { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SlotPin) DeepCopyInto(out *SlotPin) { + *out = *in + if in.MovedAt != nil { + in, out := &in.MovedAt, &out.MovedAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SlotPin. +func (in *SlotPin) DeepCopy() *SlotPin { + if in == nil { + return nil + } + out := new(SlotPin) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SlotPrecondition) DeepCopyInto(out *SlotPrecondition) { *out = *in diff --git a/pkg/authz/bind.go b/pkg/authz/bind.go index 5a1af44d..66c5d597 100644 --- a/pkg/authz/bind.go +++ b/pkg/authz/bind.go @@ -48,6 +48,19 @@ type slotCandidate struct { // NoGrantRelation mirrors SlotBinding.NoGrantRelation: the type has no // slot_grant relation to write, so this candidate narrows scope only. NoGrantRelation bool + // Occupancy and Rebind mirror SlotBinding's fields, copied from the + // candidate's BoundEntitySpec so bindSlots can stamp them onto the + // SlotBinding the GrantSlots pinning gate reads. Empty Occupancy is single. + Occupancy string + Rebind string + + // PriorID mirrors SlotBinding.PriorID: when non-zero, this candidate MOVES a + // filled single-occupancy slot from PriorID to ResourceID. Carried through + // checkPreconditions so the move executes only for a candidate that actually + // survived the gate — a held instance must not have the pin moved to it, and + // the prior instance's grants must not be revoked, for a bind that never + // happens. + PriorID ObjectID } // admissibleCandidates is the one filter chain every fill source runs its @@ -365,7 +378,13 @@ func bindSlots( if c.NoGrantRelation { continue } - bindings = append(bindings, SlotBinding{ResourceType: c.ResourceType, ResourceID: c.ResourceID, Permission: c.Permission}) + bindings = append(bindings, SlotBinding{ + ResourceType: c.ResourceType, + ResourceID: c.ResourceID, + Permission: c.Permission, + Occupancy: c.Occupancy, + Rebind: c.Rebind, + }) } var scopeErr error @@ -522,6 +541,9 @@ func BindApproved( RawID: b.RawID, Requires: b.Requires, NoGrantRelation: b.NoGrantRelation, + Occupancy: b.Occupancy, + Rebind: b.Rebind, + PriorID: b.PriorID, }) } if policy == EnforcePreconditions { @@ -530,5 +552,154 @@ func BindApproved( return nil } } - return bindSlots(ctx, mem, memScope, sess, w, scope.SourceApproved, logger, now, expiresAt, bound) + // The approved MOVE runs BEFORE bindSlots, over the candidates that survived + // the precondition gate: it repoints each filled single-occupancy slot off + // its old instance and revokes that instance's grants, so the subsequent + // GrantSlots finds the pin already on the new instance (a same-instance + // bind) rather than a different one (which it would refuse). A move failure + // propagates — the human's approval produced nothing, and bindSlots must not + // then write the new grant. + moved, err := moveApprovedPins(ctx, w, sess, bound, logger) + if err != nil { + return err + } + if err := bindSlots(ctx, mem, memScope, sess, w, scope.SourceApproved, logger, now, expiresAt, bound); err != nil { + if moved { + // The move is not rolled back: the approver saw and approved it, and + // the pin already names the new instance, so a later per-call + // approval of that instance is a same-instance bind. What must not + // happen is reporting "nothing was granted" when access was revoked. + return fmt.Errorf("%w: %w", ErrSlotMoveCommitted, err) + } + return err + } + return nil +} + +// moveApprovedPins executes the human-approved pin MOVE for every +// single-occupancy candidate that carries a PriorID — the only way a filled +// single-occupancy slot legitimately changes instance. +// +// One MovePin per moving type: it repoints the pin fromID (PriorID) -> toID +// (ResourceID) and revokes the prior instance's grants in the SAME request, so +// the slot is never momentarily held by two instances and the displaced +// instance keeps no residual authority. The revoke set is the prior instance's +// ACTUAL grants, read back via ListGrantsFor — not rebuilt from the moving +// approval's permission list, which would leave behind any grant the new +// approval does not re-name (the asymmetric-permission leak). +// +// Nil writer: today's no-op. A nil binder grants nothing and moves nothing; the +// later bindSlots nil-writer branch logs that the approval narrowed scope with +// no grant written. Fail closed ONLY on a non-nil writer that cannot express the +// pin, and ONLY when a move is actually needed: a writer that is not a +// SlotPinner cannot repoint a pin, so routing the move through it would silently +// drop the single-occupancy guarantee — refuse instead. +// +// # The ListGrantsFor → MovePin window is inherent and bounded +// +// Each move reads the displaced instance's current grants (ListGrantsFor) and +// then revokes exactly those in the MovePin request. A grant written to the +// displaced instance BETWEEN that read and that write survives the move — the +// revoke set was computed before it existed. This is inherent to a non-atomic +// read-then-revoke and is not closed here: the only writers of a displaced +// instance's grants are this session's own bind paths, a straggler is rare, and +// every slot grant carries a mandatory expiry (SlotGrantExpiry), so a survivor +// lapses rather than lingering forever. The pin itself moves atomically (the +// MUST_MATCH guards that), so the single-occupancy identity is never in doubt; +// only a stray expiring grant on the old instance can briefly outlive the move. +func moveApprovedPins(ctx context.Context, w RelWriter, sess SessionRef, bound []slotCandidate, logger logr.Logger) (moved bool, err error) { + type pinMove struct { + fromID string + toID ObjectID + } + moves := make(map[string]pinMove) + order := make([]string, 0) + for _, c := range bound { + if c.PriorID.IsZero() || occupancyOf(SlotBinding{Occupancy: c.Occupancy}) == SlotOccupancyMulti { + // No move: an empty PriorID is a first-fill, and a multi-occupancy + // slot holds no pin to move (PriorID is only ever set for single). + continue + } + if c.PriorID.String() == c.ResourceID.String() { + // fromID == toID is not a move: the pin already names this instance. + // Executing a MovePin would DELETE then re-TOUCH the same pin and + // revoke the instance's own grants for nothing — the subsequent + // same-instance GrantSlots re-binds it. Skip it. + continue + } + if c.Rebind == SlotRebindNever { + // rebind: never — the class declared this slot unmovable, so no + // approval may retarget it. The card does not offer the move + // (stampMovedFrom skips these types); this refuses it here too, so + // a PriorID that arrives anyway cannot revoke the pinned instance. + return false, fmt.Errorf("%w: this session is pinned to %s:%s for its lifetime; start a new session to target %s:%s", + ErrSlotPinned, c.ResourceType, c.PriorID.String(), c.ResourceType, c.ResourceID.String()) + } + if existing, ok := moves[c.ResourceType]; ok { + if existing.toID.String() != c.ResourceID.String() { + // Two DISTINCT move targets for one single-occupancy type in one + // approval cannot both occupy the slot. Refuse the WHOLE move set + // BEFORE any MovePin runs — executing even the first would commit + // the slot to whichever target happened to be ordered first, an + // instance the approver was not necessarily shown as THE move. + // errors.Is(…, ErrSlotPinned) so the plan-gate failure path treats + // it as a pin refusal, not a store error. + return false, fmt.Errorf("%w: slot type %s has two distinct move targets in one approval (%s and %s); it is single-occupancy", + ErrSlotPinned, c.ResourceType, existing.toID.String(), c.ResourceID.String()) + } + // Same target named twice (a second permission on the moving + // instance) — one move covers both. + continue + } + moves[c.ResourceType] = pinMove{fromID: c.PriorID.String(), toID: c.ResourceID} + order = append(order, c.ResourceType) + } + if len(moves) == 0 { + return false, nil + } + // Refuse, BEFORE any MovePin, a moving type that this same approval also + // binds to a DIFFERENT instance (typically a constant such as + // git_repo:workspace, which carries no PriorID). GrantSlots would refuse + // that type for holding two distinct instances, but only after the move had + // already repointed the pin and revoked the prior instance, leaving the + // session with access to neither. + for _, c := range bound { + mv, ok := moves[c.ResourceType] + if !ok || occupancyOf(SlotBinding{Occupancy: c.Occupancy}) == SlotOccupancyMulti { + continue + } + if c.ResourceID.String() != mv.toID.String() { + return false, fmt.Errorf("%w: slot type %s would hold both %s and %s after this approval; it is single-occupancy", + ErrSlotPinned, c.ResourceType, mv.toID.String(), c.ResourceID.String()) + } + } + if w == nil { + return false, nil + } + pinner, ok := w.(SlotPinner) + if !ok { + return false, fmt.Errorf("authz: moving a filled single-occupancy slot needs a SlotPinner-capable writer, got %T", w) + } + for _, rt := range order { + mv := moves[rt] + revoke, err := pinner.ListGrantsFor(ctx, rt, mv.fromID, sess) + if err != nil { + return moved, fmt.Errorf("authz: list prior grants for %s:%s to revoke on move: %w", rt, mv.fromID, err) + } + logger.Info("approved slot-pin move: repointing a filled single-occupancy slot and revoking the prior instance's grants", + "session", sess.String(), "slotType", rt, "from", mv.fromID, "to", mv.toID.String(), "revokeGrants", len(revoke)) + if err := pinner.MovePin(ctx, rt, mv.fromID, mv.toID.String(), revoke, sess); err != nil { + if errors.Is(err, ErrSlotPinned) { + err = fmt.Errorf("%w: %w", ErrSlotMoveDrifted, err) + } + err = fmt.Errorf("authz: execute approved slot move for %s: %w", rt, err) + if moved { + // An earlier type in this approval already moved. + return true, fmt.Errorf("%w: %w", ErrSlotMoveCommitted, err) + } + return false, err + } + moved = true + } + return moved, nil } diff --git a/pkg/authz/bind_approved_test.go b/pkg/authz/bind_approved_test.go index b8a4f9e5..ea21d0ec 100644 --- a/pkg/authz/bind_approved_test.go +++ b/pkg/authz/bind_approved_test.go @@ -3,6 +3,8 @@ package authz import ( "context" "errors" + "fmt" + "slices" "testing" "time" @@ -25,15 +27,89 @@ type conflictingScopeMemory struct { putErr error } -type approvedRecordingWriter struct{ wrote []Relation } +// approvedRecordingWriter implements RelWriter AND SlotPinner: BindApproved +// binds single-occupancy slots, which the gate routes through the pinner, so a +// plain RelWriter would be refused. It records grants from either write path in +// `wrote` and keeps one pin per (scope,type). +// +// It also models the approved-MOVE path: `grantsFor` seeds a prior instance's +// held grants so ListGrantsFor returns them as the revoke set, `moves` records +// every MovePin call in order, and `ops` logs the operation sequence so a test +// can assert the move landed BEFORE the new instance's grant write. +type approvedRecordingWriter struct { + wrote []Relation + pins map[string]string + grantsFor map[string][]Relation // (type\x00id) -> the instance's held grants, served by ListGrantsFor + moves []moveCall // every MovePin, in order + ops []string // ordered op log: "move", "grant" + moveErr error // injected into MovePin + pinErr map[string]error // per resource type, injected into EnsurePin + grantErr error // injected into WriteGrantsPinned +} + +type moveCall struct { + resourceType, fromID, toID string + revoke []Relation +} + +func grantStoreKey(resourceType, resourceID string) string { + return resourceType + "\x00" + resourceID +} func (w *approvedRecordingWriter) WriteRelationships(_ context.Context, rels []Relation) error { + w.ops = append(w.ops, "grant") w.wrote = append(w.wrote, rels...) return nil } func (*approvedRecordingWriter) DeleteRelationships(context.Context, []Relation) error { return nil } +func (w *approvedRecordingWriter) EnsurePin(_ context.Context, resourceType, resourceID string, scope SessionRef) (bool, string, error) { + if err := w.pinErr[resourceType]; err != nil { + return false, "", err + } + if w.pins == nil { + w.pins = map[string]string{} + } + key := scope.String() + "\x00" + resourceType + if cur, ok := w.pins[key]; ok { + return true, cur, nil + } + w.pins[key] = resourceID + return false, resourceID, nil +} + +func (w *approvedRecordingWriter) WriteGrantsPinned(_ context.Context, rels []Relation, _, _ string, _ SessionRef) error { + if w.grantErr != nil { + return w.grantErr + } + w.ops = append(w.ops, "grant") + w.wrote = append(w.wrote, rels...) + return nil +} + +func (w *approvedRecordingWriter) MovePin(_ context.Context, resourceType, fromID, toID string, revoke []Relation, scope SessionRef) error { + if w.moveErr != nil { + return w.moveErr + } + if w.pins == nil { + w.pins = map[string]string{} + } + w.ops = append(w.ops, "move") + w.moves = append(w.moves, moveCall{resourceType: resourceType, fromID: fromID, toID: toID, revoke: revoke}) + w.pins[scope.String()+"\x00"+resourceType] = toID + delete(w.grantsFor, grantStoreKey(resourceType, fromID)) + return nil +} + +func (w *approvedRecordingWriter) ReadPin(_ context.Context, resourceType string, scope SessionRef) (string, error) { + return w.pins[scope.String()+"\x00"+resourceType], nil +} + +func (w *approvedRecordingWriter) ListGrantsFor(_ context.Context, resourceType, resourceID string, _ SessionRef) ([]Relation, error) { + return w.grantsFor[grantStoreKey(resourceType, resourceID)], nil +} + func (m *conflictingScopeMemory) Query(ctx context.Context, q memory.Query) (memory.QueryResult, error) { m.queryCalls++ // bindSlots reads once to compute its delta, then PutIfVersion reads again @@ -174,6 +250,208 @@ func TestBindApproved_exhaustsScopeVersionConflictsWithoutGranting(t *testing.T) assert.Empty(t, w.wrote, "a scope write that never wins must never create a grant") } +// The approved MOVE — the only way a filled single-occupancy slot legitimately +// changes instance. A binding carrying PriorID repoints the pin repoA -> repoB, +// revokes repoA's held grants in the SAME request, and only then writes repoB's +// grant through the normal gated path. Order matters: the revoke-and-move lands +// before the new grant, so the slot is never momentarily held by two instances. +func TestBindApproved_approvedMoveRepointsPinRevokesPriorGrantsThenWritesNew(t *testing.T) { + ctx := memory.WithSystemApproval(context.Background(), "test") + mem := memory.NewLocal(inmem.NewBackend()) + memScope := memory.Scope{Kind: "session", ID: "ns/s"} + sess := SessionRef{Namespace: "ns", Name: "s"} + now := func() time.Time { return time.Unix(1700000000, 0).UTC() } + + priorGrant := SlotGrantRelation("git_repo", "repoA", "push", sess) + w := &approvedRecordingWriter{ + pins: map[string]string{sess.String() + "\x00git_repo": "repoA"}, + grantsFor: map[string][]Relation{grantStoreKey("git_repo", "repoA"): {priorGrant}}, + } + + err := BindApproved(ctx, mem, memScope, sess, w, + []SlotBinding{{ + ResourceType: "git_repo", ResourceID: TrustedObjectID("repoB"), + Permission: "push", PriorID: TrustedObjectID("repoA"), + }}, + EnforcePreconditions, now().Add(time.Hour), logr.Discard(), now) + require.NoError(t, err) + + // Exactly one move, repointing repoA -> repoB, revoking repoA's held grant. + require.Len(t, w.moves, 1, "one move per moving type") + assert.Equal(t, "git_repo", w.moves[0].resourceType) + assert.Equal(t, "repoA", w.moves[0].fromID) + assert.Equal(t, "repoB", w.moves[0].toID) + require.Len(t, w.moves[0].revoke, 1, "the revoke set is the prior instance's actual grants") + assert.Equal(t, priorGrant, w.moves[0].revoke[0]) + + // The move lands BEFORE the new instance's grant write. + require.GreaterOrEqual(t, len(w.ops), 2) + assert.Equal(t, "move", w.ops[0], "the move-and-revoke happens first") + assert.Equal(t, "grant", w.ops[len(w.ops)-1], "the new grant is written after the move") + + // repoB is now pinned and holds the new grant. + assert.Equal(t, "repoB", w.pins[sess.String()+"\x00git_repo"]) + newGrant := SlotGrantRelation("git_repo", "repoB", "push", sess) + assert.True(t, slices.ContainsFunc(w.wrote, func(r Relation) bool { + return r.ResourceType == newGrant.ResourceType && r.ResourceID == newGrant.ResourceID && r.Relation == newGrant.Relation + }), "the new instance's grant must be written after the move") +} + +// A move whose MUST_MATCH no longer holds — the pin drifted since the card was +// shown — fails. BindApproved must PROPAGATE that failure (errors.Is-able as +// ErrSlotPinned), never swallow it: the human's approval produced nothing, and +// nothing may be written for the new instance either. +func TestBindApproved_moveFailurePropagatesWrappingErrSlotPinned(t *testing.T) { + ctx := memory.WithSystemApproval(context.Background(), "test") + mem := memory.NewLocal(inmem.NewBackend()) + memScope := memory.Scope{Kind: "session", ID: "ns/s"} + sess := SessionRef{Namespace: "ns", Name: "s"} + now := func() time.Time { return time.Unix(1700000000, 0).UTC() } + + w := &approvedRecordingWriter{ + pins: map[string]string{sess.String() + "\x00git_repo": "repoA"}, + moveErr: fmt.Errorf("move pin git_repo:repoA -> git_repo:repoB: %w", ErrSlotPinned), + } + + err := BindApproved(ctx, mem, memScope, sess, w, + []SlotBinding{{ + ResourceType: "git_repo", ResourceID: TrustedObjectID("repoB"), + Permission: "push", PriorID: TrustedObjectID("repoA"), + }}, + EnforcePreconditions, now().Add(time.Hour), logr.Discard(), now) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrSlotPinned, "a failed move must propagate, not be swallowed") + assert.ErrorIs(t, err, ErrSlotMoveDrifted, "a MUST_MATCH failure is a drifted move, which the approver may retry") + assert.NotErrorIs(t, err, ErrSlotMoveCommitted, "nothing moved, so nothing was committed") + assert.Empty(t, w.wrote, "a move that failed must not go on to write the new instance's grant") +} + +// The move refusals that must happen BEFORE anything is repointed: each is a +// deterministic answer that a MovePin followed by a failed grant would turn +// into "the old instance is revoked and the new one is not granted". +func TestBindApproved_moveRefusedBeforeAnyMove(t *testing.T) { + sess := SessionRef{Namespace: "ns", Name: "s"} + cases := []struct { + name string + bindings []SlotBinding + }{ + { + name: "rebind never: refused with the new-session route, pin untouched", + bindings: []SlotBinding{{ + ResourceType: "git_repo", ResourceID: TrustedObjectID("repoB"), Permission: "push", + PriorID: TrustedObjectID("repoA"), Rebind: SlotRebindNever, + }}, + }, + { + name: "same approval also binds a constant instance of the moving type: refused, pin untouched", + bindings: []SlotBinding{ + {ResourceType: "git_repo", ResourceID: TrustedObjectID("repoB"), Permission: "push", PriorID: TrustedObjectID("repoA")}, + {ResourceType: "git_repo", ResourceID: TrustedObjectID("workspace"), Permission: "read"}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := memory.WithSystemApproval(context.Background(), "test") + mem := memory.NewLocal(inmem.NewBackend()) + now := func() time.Time { return time.Unix(1700000000, 0).UTC() } + priorGrant := SlotGrantRelation("git_repo", "repoA", "push", sess) + w := &approvedRecordingWriter{ + pins: map[string]string{sess.String() + "\x00git_repo": "repoA"}, + grantsFor: map[string][]Relation{grantStoreKey("git_repo", "repoA"): {priorGrant}}, + } + + err := BindApproved(ctx, mem, memory.Scope{Kind: "session", ID: "ns/s"}, sess, w, + tc.bindings, EnforcePreconditions, now().Add(time.Hour), logr.Discard(), now) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrSlotPinned) + assert.NotErrorIs(t, err, ErrSlotMoveDrifted, "a deterministic refusal is not a retryable drift") + assert.Empty(t, w.moves, "no MovePin may run") + assert.Equal(t, "repoA", w.pins[sess.String()+"\x00git_repo"], "the pin stays on the prior instance") + assert.NotEmpty(t, w.grantsFor[grantStoreKey("git_repo", "repoA")], "the prior instance keeps its grants") + }) + } +} + +// A grant failure AFTER the move landed must say so: the displaced instance's +// grants are already revoked, so an error that reads as "nothing happened" +// would have the approver told nothing was granted while access was removed. +func TestBindApproved_grantFailureAfterMoveReportsCommittedMove(t *testing.T) { + ctx := memory.WithSystemApproval(context.Background(), "test") + mem := memory.NewLocal(inmem.NewBackend()) + sess := SessionRef{Namespace: "ns", Name: "s"} + now := func() time.Time { return time.Unix(1700000000, 0).UTC() } + w := &approvedRecordingWriter{ + pins: map[string]string{sess.String() + "\x00git_repo": "repoA"}, + grantErr: errors.New("spicedb unavailable"), + } + + err := BindApproved(ctx, mem, memory.Scope{Kind: "session", ID: "ns/s"}, sess, w, + []SlotBinding{{ + ResourceType: "git_repo", ResourceID: TrustedObjectID("repoB"), + Permission: "push", PriorID: TrustedObjectID("repoA"), + }}, + EnforcePreconditions, now().Add(time.Hour), logr.Discard(), now) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrSlotMoveCommitted) + assert.Len(t, w.moves, 1, "the move itself landed") + assert.Equal(t, "repoB", w.pins[sess.String()+"\x00git_repo"]) +} + +// Two DISTINCT move targets for ONE single-occupancy type in one approval +// cannot both occupy the slot. moveApprovedPins must refuse the whole set +// BEFORE any MovePin runs — executing even the first would commit the slot to +// whichever target happened to be ordered first, an instance the approver was +// not necessarily shown as THE move. +func TestBindApproved_twoDistinctMoveTargetsRefusedBeforeAnyMove(t *testing.T) { + ctx := memory.WithSystemApproval(context.Background(), "test") + mem := memory.NewLocal(inmem.NewBackend()) + memScope := memory.Scope{Kind: "session", ID: "ns/s"} + sess := SessionRef{Namespace: "ns", Name: "s"} + now := func() time.Time { return time.Unix(1700000000, 0).UTC() } + w := &approvedRecordingWriter{pins: map[string]string{sess.String() + "\x00git_repo": "repoA"}} + + err := BindApproved(ctx, mem, memScope, sess, w, + []SlotBinding{ + {ResourceType: "git_repo", ResourceID: TrustedObjectID("repoB"), Permission: "push", PriorID: TrustedObjectID("repoA")}, + {ResourceType: "git_repo", ResourceID: TrustedObjectID("repoC"), Permission: "push", PriorID: TrustedObjectID("repoA")}, + }, + EnforcePreconditions, now().Add(time.Hour), logr.Discard(), now) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrSlotPinned, "two distinct move targets is a single-occupancy refusal") + assert.Empty(t, w.moves, "no MovePin may run when the move set is self-contradictory") + assert.Empty(t, w.wrote, "a refused move set grants nothing") +} + +// A binding whose PriorID equals its ResourceID is not a move: the pin already +// names that instance. moveApprovedPins must SKIP it (no MovePin, no needless +// revoke of the instance's own grants); the subsequent same-instance GrantSlots +// re-binds it through the normal pinned path. +func TestBindApproved_moveToSameInstanceIsSkippedNotMoved(t *testing.T) { + ctx := memory.WithSystemApproval(context.Background(), "test") + mem := memory.NewLocal(inmem.NewBackend()) + memScope := memory.Scope{Kind: "session", ID: "ns/s"} + sess := SessionRef{Namespace: "ns", Name: "s"} + now := func() time.Time { return time.Unix(1700000000, 0).UTC() } + w := &approvedRecordingWriter{pins: map[string]string{sess.String() + "\x00git_repo": "repoA"}} + + err := BindApproved(ctx, mem, memScope, sess, w, + []SlotBinding{{ + ResourceType: "git_repo", ResourceID: TrustedObjectID("repoA"), + Permission: "push", PriorID: TrustedObjectID("repoA"), + }}, + EnforcePreconditions, now().Add(time.Hour), logr.Discard(), now) + + require.NoError(t, err) + assert.Empty(t, w.moves, "a move to the SAME instance is a no-op, never a MovePin") + assert.Equal(t, "repoA", w.pins[sess.String()+"\x00git_repo"], "the pin stays put") + assert.NotEmpty(t, w.wrote, "the same-instance re-grant still binds through the normal pinned path") +} + func TestBindApproved_doesNotRetryUnrelatedScopeWriteError(t *testing.T) { ctx := memory.WithSystemApproval(context.Background(), "test") memScope := memory.Scope{Kind: "session", ID: "ns/s"} diff --git a/pkg/authz/bind_defaults.go b/pkg/authz/bind_defaults.go index 04fabb13..3663792c 100644 --- a/pkg/authz/bind_defaults.go +++ b/pkg/authz/bind_defaults.go @@ -79,6 +79,14 @@ type BoundEntitySpec struct { // constructs a BoundEntitySpec, and why a guard test refuses a second // construction site. Requires []precondition.Rule + + // Occupancy and Rebind carry the slot's single-vs-multi commitment and its + // rebind policy (AgentClass slot `occupancy`/`rebind`) through to the + // SlotBinding each fill source emits, so the GrantSlots pinning gate sees + // them. Empty Occupancy reads as single — the fail-closed default every + // reader agrees on (see SlotBinding / occupancyOf). + Occupancy string + Rebind string } // AutoFillArgSpec mirrors v1alpha1.EntityAutoFillArg for FillToolArgs. @@ -182,6 +190,8 @@ func BindClassDefaults( ResourceID: id, RawID: def, Requires: et.Requires, + Occupancy: et.Occupancy, + Rebind: et.Rebind, }) } } diff --git a/pkg/authz/bind_defaults_test.go b/pkg/authz/bind_defaults_test.go index aa5105c7..0b7efb29 100644 --- a/pkg/authz/bind_defaults_test.go +++ b/pkg/authz/bind_defaults_test.go @@ -38,8 +38,15 @@ func systemCtx() context.Context { func TestBindClassDefaults_AllowedDefaultsGrantSlotsAndWriteSessionScope(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/n"} - entities := entitiesFor("github_repo", "read", "demo-org/demo-repo", "demo-org/internal") - w := &recordingRelWriter{} + // Two single-occupancy TYPES, one default each: each pins its own slot, so + // both allowed defaults become grants and both narrow scope. Two IDs of ONE + // single-occupancy type would instead be a multi-arrival refusal — that is a + // multi-occupancy scenario, exercised where occupancy can be set directly. + entities := []authz.BoundEntitySpec{ + {ResourceType: "github_repo", Permission: "read", Defaults: []string{"demo-org/demo-repo"}}, + {ResourceType: "github_org", Permission: "read", Defaults: []string{"demo-org"}}, + } + w := &pinningFake{} now := func() time.Time { return time.Unix(1700000000, 0).UTC() } require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", now, 0)) @@ -48,7 +55,6 @@ func TestBindClassDefaults_AllowedDefaultsGrantSlotsAndWriteSessionScope(t *test // resource at the session. require.Len(t, w.wrote, 2, "each allowed default must bind a slot grant") for _, rel := range w.wrote { - assert.Equal(t, "github_repo", rel.ResourceType) assert.Equal(t, authz.SlotGrantRelationName("read"), rel.Relation, "the relation carries the permission, so a read grant cannot satisfy write") assert.Equal(t, "agentsession", rel.SubjectType) @@ -61,11 +67,49 @@ func TestBindClassDefaults_AllowedDefaultsGrantSlotsAndWriteSessionScope(t *test require.NoError(t, err) require.True(t, ok, "session_scope entry must exist after BindClassDefaults") - require.Len(t, sc.Resources, 1, "one ScopeResource entry for github_repo") - r := sc.Resources[0] - assert.Equal(t, "github_repo", r.ResourceType) - assert.ElementsMatch(t, []string{"demo-org/demo-repo", "demo-org/internal"}, r.IDs) - assert.Equal(t, "default", string(r.Source)) + require.Len(t, sc.Resources, 2, "one ScopeResource entry per bound type") + byType := map[string][]string{} + for _, r := range sc.Resources { + assert.Equal(t, "default", string(r.Source), "a class default is recorded as its own source") + byType[r.ResourceType] = r.IDs + } + assert.ElementsMatch(t, []string{"demo-org/demo-repo"}, byType["github_repo"]) + assert.ElementsMatch(t, []string{"demo-org"}, byType["github_org"]) +} + +// TestBindClassDefaults_CarriesOccupancyThroughToTheGate proves the field +// reaches GrantSlots' pinning gate: a single-occupancy default (empty, the +// default) binds THROUGH the pin, while a multi-occupancy default takes the +// plain unpinned write. If BoundEntitySpec.Occupancy stopped propagating onto +// the SlotBinding, the multi default would be gated as single and pin — caught +// here by the pin's presence, not merely by the grant landing. +func TestBindClassDefaults_CarriesOccupancyThroughToTheGate(t *testing.T) { + now := func() time.Time { return time.Unix(1700000000, 0).UTC() } + + t.Run("single-occupancy default binds through the pin", func(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + memScope := memory.Scope{Kind: "session", ID: "ns/n-occ-single"} + entities := entitiesFor("github_repo", "read", "demo-org/demo-repo") // Occupancy unset == single + w := &pinningFake{} + require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", now, 0)) + require.Len(t, w.wrote, 1) + assert.NotEmpty(t, w.pinned, "a single-occupancy default must route through the pinned write") + assert.Equal(t, "demo-org/demo-repo", w.pins[pinKey(bindSession(), "github_repo")], "and must pin the slot") + assert.Empty(t, w.plain, "nothing may take the unpinned plain path for a single type") + }) + + t.Run("multi-occupancy default binds without a pin", func(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + memScope := memory.Scope{Kind: "session", ID: "ns/n-occ-multi"} + entities := entitiesFor("label", "apply", "bug") + entities[0].Occupancy = authz.SlotOccupancyMulti + w := &pinningFake{} + require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", now, 0)) + require.Len(t, w.wrote, 1) + assert.Empty(t, w.pinned, "a multi-occupancy default must NOT route through the pinned write") + assert.Empty(t, w.pins, "multi occupancy writes no pin") + assert.Len(t, w.plain, 1, "the multi grant takes the plain batched write") + }) } // The ScopeResource write is load-bearing, and this test exists because an @@ -85,7 +129,7 @@ func TestBindClassDefaults_ScopeWriteIsLoadBearing(t *testing.T) { require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entitiesFor("github_repo", "read", "demo-org/demo-repo"), - allowAll(), &recordingRelWriter{}, "alice", now, 0)) + allowAll(), &pinningFake{}, "alice", now, 0)) // A second source binds a DIFFERENT instance of the SAME type, exactly as a // query or ask fill would. @@ -125,7 +169,7 @@ func TestBindClassDefaults_DeniedDefaultsGrantNothing(t *testing.T) { deny := authz.CheckerFunc(func(_ context.Context, _ authz.Permission, _ authz.Inputs) authz.Result { return authz.Result{Outcome: authz.OutcomeDenied, Message: "no access"} }) - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, deny, w, "alice", time.Now, 0)) @@ -149,7 +193,7 @@ func TestBindClassDefaults_GrantFailureStillNarrowsScope(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/n-grantfail"} entities := entitiesFor("github_repo", "read", "demo-org/demo-repo") - w := &recordingRelWriter{err: errors.New("relation slot_grant not found under definition github_repo")} + w := &pinningFake{writeErr: errors.New("relation slot_grant not found under definition github_repo")} err := authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", time.Now, 0) require.Error(t, err, "a failed grant write must reach the caller") @@ -165,7 +209,7 @@ func TestBindClassDefaults_Idempotent(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/n3"} entities := entitiesFor("github_repo", "read", "demo-org/demo-repo") - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", time.Now, 0)) require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", time.Now, 0)) @@ -193,7 +237,7 @@ func TestBindClassDefaults_NoOpInputs(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - w := &recordingRelWriter{} + w := &pinningFake{} err := authz.BindClassDefaults(systemCtx(), mem, memory.Scope{Kind: "session", ID: "ns/noop"}, bindSession(), tc.entities, tc.chk, w, tc.subject, time.Now, 0) assert.NoError(t, err) @@ -227,7 +271,7 @@ func TestBindClassDefaults_FillFromExcludingDefault_BindsNothing(t *testing.T) { memScope := memory.Scope{Kind: "session", ID: "ns/n-nofill"} entities := entitiesFor("github_repo", "read", "demo-org/demo-repo") entities[0].FillFrom = []string{"ask"} - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", time.Now, 0)) @@ -254,7 +298,7 @@ func TestBindClassDefaults_FillFromIncludingDefault_StillBinds(t *testing.T) { memScope := memory.Scope{Kind: "session", ID: "ns/n-" + tc.name} entities := entitiesFor("github_repo", "read", "demo-org/demo-repo") entities[0].FillFrom = tc.fillFrom - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", time.Now, 0)) @@ -277,7 +321,7 @@ func TestBindClassDefaults_DefaultRunsThroughTheDeclaredTransformChain(t *testin memScope := memory.Scope{Kind: "session", ID: "ns/n-transform"} entities := entitiesFor("git_repo", "push", "https://github.com/acme/app") entities[0].ValueTransforms = []string{"normalize_url", "spicedb_escape"} - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", time.Now, 0)) @@ -294,7 +338,7 @@ func TestBindClassDefaults_UndrivableDefaultIsSkippedNotBoundRaw(t *testing.T) { memScope := memory.Scope{Kind: "session", ID: "ns/n-baddefault"} entities := entitiesFor("git_repo", "push", "https://github.com/acme/app") entities[0].ValueTransforms = []string{"no_such_transform"} - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", time.Now, 0)) @@ -310,7 +354,7 @@ func TestBindClassDefaults_GatesPerSlot_NotPerClass(t *testing.T) { {ResourceType: "github_repo", Permission: "read", Defaults: []string{"demo-org/demo-repo"}, FillFrom: []string{"default"}}, {ResourceType: "http_target", Permission: "reachable", Defaults: []string{"hash-abc"}, FillFrom: []string{"ask"}}, } - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), entities, allowAll(), w, "alice", time.Now, 0)) diff --git a/pkg/authz/bind_precondition_test.go b/pkg/authz/bind_precondition_test.go index 2cdd79ac..7f85f9dc 100644 --- a/pkg/authz/bind_precondition_test.go +++ b/pkg/authz/bind_precondition_test.go @@ -224,7 +224,7 @@ func TestBindPrecondition_HoldsACandidateOutOfItsSlot(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/" + tc.name} tc.seed(t, mem, memScope) - w := &recordingRelWriter{} + w := &pinningFake{} // Built once: the row under test is a single slot, and calling the // factory again to read a field back would recompile its predicates @@ -285,7 +285,7 @@ func TestBindPrecondition_LooksFactsUpByTheRawID(t *testing.T) { require.NotEqual(t, "demo-org/demo-repo#6", derived.String(), "the fixture is only meaningful while the transform actually changes the id") - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{slot}, allowAll(), w, "alice", time.Now, 0)) @@ -313,7 +313,7 @@ func TestBindPrecondition_GatesEveryFillSource(t *testing.T) { Defaults: []string{"ba03f5969a"}, Requires: []precondition.Rule{mustCompile(t, `facts.observed.is_cross_repository == false`)}, } - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{gated}, allowAll(), w, "alice", time.Now, 0)) assert.Empty(t, w.wrote, "a class-pinned default is a candidate like any other and its precondition governs it") @@ -322,7 +322,7 @@ func TestBindPrecondition_GatesEveryFillSource(t *testing.T) { // path binds nothing at all: the same default, ungated, binds. ungated := gated ungated.Requires = nil - w2 := &recordingRelWriter{} + w2 := &pinningFake{} require.NoError(t, authz.BindClassDefaults(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{ungated}, allowAll(), w2, "alice", time.Now, 0)) require.Len(t, w2.wrote, 1, "without the precondition the very same default binds") @@ -347,7 +347,7 @@ func TestBindPrecondition_ApprovalWaivesTheGate(t *testing.T) { memScope := memory.Scope{Kind: "session", ID: "ns/approval-waives"} recordObserved(t, mem, memScope, "git_commit", "ba03f5969a", true) // a fork: Refused - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.BindApproved(systemCtx(), mem, memScope, bindSession(), w, []authz.SlotBinding{{ ResourceType: "git_commit", @@ -403,7 +403,7 @@ func TestBindApproved_planGatePolicyEnforcesPreconditions(t *testing.T) { memScope := memory.Scope{Kind: "session", ID: "ns/plangate-enforces-" + tc.name} recordObserved(t, mem, memScope, "git_commit", "ba03f5969a", tc.crossRepo) - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.BindApproved(systemCtx(), mem, memScope, bindSession(), w, []authz.SlotBinding{{ ResourceType: "git_commit", diff --git a/pkg/authz/guardian/schema/slot_pin_internal_test.go b/pkg/authz/guardian/schema/slot_pin_internal_test.go new file mode 100644 index 00000000..7f71bc94 --- /dev/null +++ b/pkg/authz/guardian/schema/slot_pin_internal_test.go @@ -0,0 +1,35 @@ +package schema + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func baseSchemaFor(rt string) string { + return "definition agentsession {}\n\ndefinition " + rt + " {\n relation viewer: agentsession\n}\n" +} + +func TestComposeOneSlot_EmitsNonExpiringSlotPinRelation(t *testing.T) { + for _, rt := range []string{"git_repo", "crm_company", "github_pull_request"} { + t.Run(rt, func(t *testing.T) { + out, changed, skip, err := composeOneSlot(baseSchemaFor(rt), SlotPair{ResourceType: rt, Permission: "read"}) + require.NoError(t, err) + require.False(t, skip, "the base schema defines %s; the slot must not be skipped", rt) + require.True(t, changed) + assert.Equal(t, 1, strings.Count(out, "relation slot_pin: agentsession")) + assert.NotContains(t, out, "slot_pin: agentsession with expiration", + "the pin must never carry an expiration") + }) + } +} + +func TestComposeOneSlot_SecondPermissionDoesNotDuplicatePin(t *testing.T) { + out, _, _, err := composeOneSlot(baseSchemaFor("git_repo"), SlotPair{ResourceType: "git_repo", Permission: "push"}) + require.NoError(t, err) + out2, _, _, err := composeOneSlot(out, SlotPair{ResourceType: "git_repo", Permission: "read"}) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(out2, "relation slot_pin: agentsession")) +} diff --git a/pkg/authz/guardian/schema/slots.go b/pkg/authz/guardian/schema/slots.go index 1be54cdc..0f45fba7 100644 --- a/pkg/authz/guardian/schema/slots.go +++ b/pkg/authz/guardian/schema/slots.go @@ -223,8 +223,15 @@ func composeOneSlot(src string, s SlotPair) (string, bool, bool, error) { wantRel := " relation " + slotGrantRelationDecl(s.Permission) + // The pin relation is per resource TYPE, not per permission: every + // permission of a single-occupancy slot shares one pin. Emitted for every + // slot resource (multi included) because its presence enforces nothing — + // enforcement is entirely write-time — and composing it unconditionally + // keeps occupancy out of the cross-class slot-pair union. + wantPin := " relation " + authz.SlotPinRelationName + ": agentsession" + var kept []string - hasRel, hasPerm := false, false + hasRel, hasPerm, hasPin := false, false, false permLine := regexp.MustCompile(`^\s*permission\s+` + regexp.QuoteMeta(s.Permission) + `\s*=`) // The composer is about to unconditionally overwrite the line permLine @@ -260,6 +267,9 @@ func composeOneSlot(src string, s SlotPair) (string, bool, bool, error) { case trimmed == strings.TrimSpace(wantRel): hasRel = true kept = append(kept, wantRel) + case trimmed == strings.TrimSpace(wantPin): + hasPin = true + kept = append(kept, wantPin) case permLine.MatchString(line): // The composer owns this line: whatever the author wrote is // replaced, which is what removes a wildcard leaf. @@ -270,8 +280,15 @@ func composeOneSlot(src string, s SlotPair) (string, bool, bool, error) { } } + var toInsertAfterHeader []string if !hasRel { - kept = insertAfterDefinitionHeader(kept, wantRel) + toInsertAfterHeader = append(toInsertAfterHeader, wantRel) + } + if !hasPin { + toInsertAfterHeader = append(toInsertAfterHeader, wantPin) + } + if len(toInsertAfterHeader) > 0 { + kept = insertAfterDefinitionHeader(kept, toInsertAfterHeader...) } if !hasPerm { // BEFORE the closing brace. Appending to the block's lines put the @@ -289,19 +306,22 @@ func composeOneSlot(src string, s SlotPair) (string, bool, bool, error) { return src[:start] + rebuilt + src[end:], true, false, nil } -// insertAfterDefinitionHeader places a relation immediately after the -// `definition X {` line, so relations stay grouped above permissions the way a -// hand-written schema reads. -func insertAfterDefinitionHeader(lines []string, relation string) []string { +// insertAfterDefinitionHeader places one or more relations immediately after +// the `definition X {` line, so relations stay grouped above permissions the +// way a hand-written schema reads. Variadic so a caller inserting more than +// one missing relation in the same pass (the grant relation and the pin) gets +// them placed together, in the order given, rather than one push reordering +// the other. +func insertAfterDefinitionHeader(lines []string, relations ...string) []string { for i, l := range lines { if strings.HasSuffix(strings.TrimSpace(l), "{") { - out := make([]string, 0, len(lines)+1) + out := make([]string, 0, len(lines)+len(relations)) out = append(out, lines[:i+1]...) - out = append(out, relation) + out = append(out, relations...) return append(out, lines[i+1:]...) } } - return append(lines, relation) + return append(lines, relations...) } // definitionBlockBounds returns the [start,end) offsets of `definition { diff --git a/pkg/authz/hooks/plangate.go b/pkg/authz/hooks/plangate.go index e47906c3..17e5c8d2 100644 --- a/pkg/authz/hooks/plangate.go +++ b/pkg/authz/hooks/plangate.go @@ -148,6 +148,22 @@ type PlanGateDeps struct { Recorder PlanGateRecorder Logger PlanGateLogger + // SlotPinner reads the session's current pin so a card that names a + // DIFFERENT instance of a filled single-occupancy slot can record the move + // it is proposing (SlotRef.MovedFrom). OPTIONAL and ADVISORY: the MUST_MATCH + // preconditions on the eventual MovePin — not this read — are what make the + // move safe, so a nil pinner (the unit fixtures, any binary that wires no + // SlotBinder) simply never records a MovedFrom and every card reads as a + // first-fill. A read failure does the same. It is the runner's SlotBinder, + // type-asserted to authz.SlotPinner; absent when that assertion fails. + SlotPinner authz.SlotPinner + + // SlotRebind maps each declared slot resource type to its rebind mode + // (AuthzSlot.Rebind). A type whose mode is authz.SlotRebindNever is never + // stamped with a MovedFrom: the slot cannot move by approval, so the card + // must not offer a move. Absent means the default ("approval"). + SlotRebind map[string]string + // Now is injectable so records are deterministic in tests. Nil ⇒ // time.Now. Stamped ONCE per record and reused on retry: the memory // facade's append-only idempotency compares marshaled bytes, so a @@ -872,8 +888,26 @@ func (h *PlanGate) requestPhaseApproval( // and the card states the full ceiling, which is correct: all of it is new. added, addedSlots := h.addedSinceApproved(plan, activePhase) + // Record, on each covered phase's slot refs, any MOVE this card proposes: + // where the session is already pinned to a DIFFERENT instance of a + // single-occupancy slot, approving this card repoints it. Advisory, at + // card-build time, so the approval executes exactly the move shown. Mutates + // the covered records in place before they ride the payload below. + // + // Stamped BEFORE the card is built so the stamped MovedFrom can be threaded + // onto the card's added slots — an amendment card renders the delta + // (AddedSlots), and a re-point arrives as exactly such a delta, so without + // this the card would show the new instance as a plain first-fill while the + // approval it records moves the pin. + sess := authz.SessionRef{Namespace: in.Session.Namespace, Name: in.Session.Name} + h.stampMovedFrom(ctx, sess, covered) + addedSlots = threadMovedFrom(addedSlots, covered) + cardIn := plangate.CardInput{ - Plan: plan, PhaseIndex: activePhase, Severity: sev, + // The plan's own slots carry the move too: a first approval or a + // whole-plan card renders phase slots, not AddedSlots, and the move the + // covered records now carry executes on approval either way. + Plan: planWithMovedFrom(plan, covered), PhaseIndex: activePhase, Severity: sev, Surface: h.deps.Surface, Approvers: h.deps.Approvers, MaxSingleCardHandles: h.deps.MaxSingleCardHandles, AddedHandles: added, @@ -1124,6 +1158,131 @@ func slotValuesOf(plan plangate.Plan, index int) map[string]string { return out } +// stampMovedFrom records, on each slot ref a covered phase names, the instance +// the session is CURRENTLY pinned to when that differs from the instance the +// card shows — the one case where approving this card MOVES a filled +// single-occupancy slot rather than filling an empty one. +// +// It is the record half of the approved-move: planGateBindings later turns a +// stamped ref into a PriorID-bearing binding, and BindApproved executes the +// move the human saw. Done here, at card-build, off ONE advisory read per type, +// so a card parked for days still executes exactly the move it displayed. +// +// Advisory and best-effort by design: +// - a nil pinner (no SlotBinder wired) records nothing — every card is a +// first-fill, which is the pre-move behaviour; +// - a multi-occupancy slot holds no pin, so ReadPin returns "" and nothing is +// stamped; +// - a `rebind: never` slot (SlotRebind) is never stamped: it cannot move by +// approval, so the approval binds as a plain request and GrantSlots refuses +// it with the new-session route; +// - a read failure, or a value that will not derive to an object id, leaves +// MovedFrom empty and logs — a first-fill card, never an error at card time. +// +// The ReadPin answer is a DERIVED object id (the pin stores derived ids), so the +// card's raw slot value is run through the type's transform chain before the +// two are compared; the stamped MovedFrom is the derived prior id, ready for +// TrustedObjectID at decision time. +func (h *PlanGate) stampMovedFrom(ctx context.Context, sess authz.SessionRef, recs []plangateaudit.Content) { + if h.deps.SlotPinner == nil { + return + } + pinCache := map[string]string{} + currentPin := func(slotType string) (string, bool) { + if v, ok := pinCache[slotType]; ok { + return v, true + } + v, err := h.deps.SlotPinner.ReadPin(ctx, slotType, sess) + if err != nil { + h.logf("plan_gate: reading the current pin to render a slot move failed; the card shows a first-fill", + "session", sess.String(), "slotType", slotType, "err", err.Error()) + return "", false + } + pinCache[slotType] = v + return v, true + } + for ri := range recs { + for si := range recs[ri].SlotRefs { + ref := &recs[ri].SlotRefs[si] + if ref.ID == "" { + // A deferred target names no instance, so there is nothing to + // compare and no move to record. + continue + } + if h.deps.SlotRebind[ref.Type] == authz.SlotRebindNever { + // rebind: never — the pin is for the session's life. Offering a + // move here would have the approval retarget a slot the class + // declared unmovable; the bind is refused instead, with the + // "start a new session" route. + continue + } + pinned, ok := currentPin(ref.Type) + if !ok || pinned == "" { + continue + } + objID, err := authz.NewObjectID(ref.ID, h.deps.SlotValueTransforms[ref.Type]) + if err != nil { + h.logf("plan_gate: could not derive the card's slot instance to compare against the current pin; no move recorded", + "session", sess.String(), "slotType", ref.Type, "err", err.Error()) + continue + } + if pinned != objID.String() { + ref.MovedFrom = pinned + } + } + } +} + +// threadMovedFrom copies the MovedFrom that stampMovedFrom recorded on the +// covered records onto the card's added slots, matched by (type, id), so the +// amendment card renders a re-point as a move rather than a first-fill. +// +// The two carry the same instance by different routes: a covered record's +// SlotRef names the phase's slot, and addedSlots is the delta DiffPhase found — +// a re-point is a changed slot value, so the newly-pointed instance appears in +// both. Returns a copy; the input delta is never mutated, and an addedSlot with +// no matching stamped move is left a first-fill. A no-op when nothing moved. +func threadMovedFrom(slots []plangate.Slot, covered []plangateaudit.Content) []plangate.Slot { + if len(slots) == 0 { + return slots + } + moved := map[string]string{} + for _, rec := range covered { + for _, ref := range rec.SlotRefs { + if ref.MovedFrom != "" && ref.ID != "" { + moved[ref.Type+"\x00"+ref.ID] = ref.MovedFrom + } + } + } + if len(moved) == 0 { + return slots + } + out := append([]plangate.Slot(nil), slots...) + for i := range out { + if mf, ok := moved[out[i].Type+"\x00"+out[i].ID]; ok { + out[i].MovedFrom = mf + } + } + return out +} + +// planWithMovedFrom returns plan with every phase's slots run through +// threadMovedFrom, so a card rendered from the plan itself (a first approval, +// or a whole-plan card) shows a re-point as a move exactly as an amendment +// card does. Copies the phase and slot slices; the input plan is never +// mutated. MovedFrom is outside the digest and AuthorityKey, so threading it +// cannot re-key the plan. +func planWithMovedFrom(plan plangate.Plan, covered []plangateaudit.Content) plangate.Plan { + if len(plan.Phases) == 0 { + return plan + } + out := plangate.Plan{Phases: append([]plangate.Phase(nil), plan.Phases...)} + for i := range out.Phases { + out.Phases[i].Slots = threadMovedFrom(out.Phases[i].Slots, covered) + } + return out +} + // budgetReason is what the agent is told when a phase runs out of budget. // // The remedy differs from every other denial this gate issues, and getting it diff --git a/pkg/authz/hooks/plangate_move_test.go b/pkg/authz/hooks/plangate_move_test.go new file mode 100644 index 00000000..cdc33abe --- /dev/null +++ b/pkg/authz/hooks/plangate_move_test.go @@ -0,0 +1,205 @@ +package hooks + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/authz/permsurface" + "github.com/authzed/openagentprimitives/pkg/authz/plangate" + "github.com/authzed/openagentprimitives/pkg/memory/kinds/plangateaudit" + "github.com/authzed/openagentprimitives/pkg/platform/pipeline" +) + +// fakePinner is a SlotPinner whose only live method is ReadPin — the one the +// card build uses. The write methods are inert: the card never pins or moves, +// it only reads to RENDER a move. +type fakePinner struct { + pins map[string]string // resourceType -> currently pinned (derived) id + err error // injected into ReadPin +} + +func (fakePinner) EnsurePin(context.Context, string, string, authz.SessionRef) (bool, string, error) { + return false, "", nil +} +func (fakePinner) WriteGrantsPinned(context.Context, []authz.Relation, string, string, authz.SessionRef) error { + return nil +} +func (fakePinner) MovePin(context.Context, string, string, string, []authz.Relation, authz.SessionRef) error { + return nil +} +func (f fakePinner) ReadPin(_ context.Context, resourceType string, _ authz.SessionRef) (string, error) { + if f.err != nil { + return "", f.err + } + return f.pins[resourceType], nil +} +func (fakePinner) ListGrantsFor(context.Context, string, string, authz.SessionRef) ([]authz.Relation, error) { + return nil, nil +} + +// enforcingSlotGate is an enforcing gate whose one declared phase names a +// single-occupancy git_repo instance (repoB). Transforms are left nil, so the +// card's raw slot value is its own object id and the ReadPin comparison is a +// plain string match. +func enforcingSlotGate(t *testing.T, pinner authz.SlotPinner) *PlanGate { + t.Helper() + h, err := permsurface.NewPermHandle("push", "git_repo") + require.NoError(t, err) + plan := plangate.Plan{Phases: []plangate.Phase{{ + Label: "work", + Permissions: []permsurface.Handle{h}, + Max: plangate.MaxSpec{Count: 1}, + Slots: []plangate.Slot{{Type: "git_repo", ID: "repoB"}}, + }}} + return NewPlanGate(PlanGateDeps{ + Mode: "enforcing", + Plan: plan, + CurrentPlan: func() (plangate.Plan, bool) { return plan, true }, + Surface: []permsurface.Descriptor{permDesc(t, "push", "git_repo", "push_tool", authz.External)}, + SlotPinner: pinner, + Recorder: &fakeRecorder{}, + Logger: &fakeLogger{}, + }) +} + +// coveredFromPhaseAsk drives requestPhaseApproval and returns the covered-phase +// records carried on the resulting approval ask — the records a yes writes and +// planGateBindings later reads. +func coveredFromPhaseAsk(t *testing.T, h *PlanGate) []plangateaudit.Content { + t.Helper() + in := pipeline.Input{ + Point: pipeline.PreToolCall, + Session: pipeline.SessionRef{Namespace: "ns", Name: "s"}, + Tool: &pipeline.ToolCallInfo{Name: "push_tool", UseID: "u1"}, + } + d := h.requestPhaseApproval(context.Background(), in, plangateaudit.Content{Mode: "enforcing"}, 0, false) + require.NotNil(t, d.Approval, "an un-denied enforcing phase must raise an approval ask") + cov, ok := d.Approval.Payload["covered"].([]plangateaudit.Content) + require.True(t, ok, "the ask must carry the covered-phase records") + return cov +} + +func firstSlotRef(t *testing.T, recs []plangateaudit.Content) plangateaudit.SlotRef { + t.Helper() + require.NotEmpty(t, recs) + require.NotEmpty(t, recs[0].SlotRefs, "the covered phase must name its slot") + return recs[0].SlotRefs[0] +} + +// The MOVE case: the session is already pinned to a DIFFERENT instance than the +// card names, so the card records the displaced instance — approving it MOVES +// the pin. +func TestRequestPhaseApproval_recordsMovedFromWhenPinDiffers(t *testing.T) { + h := enforcingSlotGate(t, fakePinner{pins: map[string]string{"git_repo": "repoA"}}) + ref := firstSlotRef(t, coveredFromPhaseAsk(t, h)) + assert.Equal(t, "repoB", ref.ID) + assert.Equal(t, "repoA", ref.MovedFrom, + "a card naming a different instance of a filled single-occupancy slot records the move") +} + +// The pin is ALREADY the named instance: no move, MovedFrom empty. +func TestRequestPhaseApproval_noMovedFromWhenPinMatches(t *testing.T) { + h := enforcingSlotGate(t, fakePinner{pins: map[string]string{"git_repo": "repoB"}}) + ref := firstSlotRef(t, coveredFromPhaseAsk(t, h)) + assert.Empty(t, ref.MovedFrom, "the card names the already-pinned instance, so nothing moves") +} + +// An EMPTY slot (nothing pinned yet): a first-fill, MovedFrom empty. +func TestRequestPhaseApproval_noMovedFromOnFirstFill(t *testing.T) { + h := enforcingSlotGate(t, fakePinner{pins: map[string]string{}}) + ref := firstSlotRef(t, coveredFromPhaseAsk(t, h)) + assert.Empty(t, ref.MovedFrom, "an empty slot is a first-fill, not a move") +} + +// cardFromPhaseAsk drives requestPhaseApproval and returns the card the ask +// carries — what the approver is actually shown. +func cardFromPhaseAsk(t *testing.T, h *PlanGate) plangate.Card { + t.Helper() + in := pipeline.Input{ + Point: pipeline.PreToolCall, + Session: pipeline.SessionRef{Namespace: "ns", Name: "s"}, + Tool: &pipeline.ToolCallInfo{Name: "push_tool", UseID: "u1"}, + } + d := h.requestPhaseApproval(context.Background(), in, plangateaudit.Content{Mode: "enforcing"}, 0, false) + require.NotNil(t, d.Approval) + card, ok := d.Approval.Payload["card"].(plangate.Card) + require.True(t, ok, "the ask must carry the card") + return card +} + +// A FIRST approval (no earlier approved phase, so no AddedSlots delta) must +// still SHOW the move it records. The card used to render the plan's own +// slots, which never carried MovedFrom, so the approver saw a plain first-fill +// while approving repointed the pin and revoked the old instance. +func TestRequestPhaseApproval_firstApprovalCardShowsTheMove(t *testing.T) { + h := enforcingSlotGate(t, fakePinner{pins: map[string]string{"git_repo": "repoA"}}) + card := cardFromPhaseAsk(t, h) + assert.Contains(t, card.What, "repoA → repoB", "the card must show the move it records") + assert.Contains(t, card.What, "revokes this session's access to repoA", + "the card must show the revocation approving it carries out") +} + +// A `rebind: never` slot cannot move by approval, so the card records no move +// and shows none, even when the pin names a different instance. The bind is +// then refused by GrantSlots with the new-session route. +func TestRequestPhaseApproval_rebindNeverRecordsNoMove(t *testing.T) { + h := enforcingSlotGate(t, fakePinner{pins: map[string]string{"git_repo": "repoA"}}) + h.deps.SlotRebind = map[string]string{"git_repo": authz.SlotRebindNever} + ref := firstSlotRef(t, coveredFromPhaseAsk(t, h)) + assert.Empty(t, ref.MovedFrom, "a never-rebind slot must not be offered as a move") + assert.NotContains(t, cardFromPhaseAsk(t, h).What, "→", "and the card must not show one") +} + +// No pinner wired (the unit fixtures, any binary with no SlotBinder): ReadPin is +// never called, so every card is a first-fill — never an error at card time. +func TestRequestPhaseApproval_nilPinnerLeavesMovedFromEmpty(t *testing.T) { + h := enforcingSlotGate(t, nil) + ref := firstSlotRef(t, coveredFromPhaseAsk(t, h)) + assert.Empty(t, ref.MovedFrom, "a nil pinner records no move") +} + +// A ReadPin failure is advisory: MovedFrom stays empty (a first-fill card) and +// the card still builds, rather than failing at card time. +func TestRequestPhaseApproval_readPinErrorLeavesMovedFromEmpty(t *testing.T) { + h := enforcingSlotGate(t, fakePinner{err: errors.New("spicedb unreachable")}) + ref := firstSlotRef(t, coveredFromPhaseAsk(t, h)) + assert.Empty(t, ref.MovedFrom, "an advisory read failure must not record a move, nor fail the card") +} + +// threadMovedFrom carries a stamped move from the covered records onto the +// card's added slots by (type, id), so an amendment card renders a re-point as +// a move. A delta slot with no matching stamped move stays a first-fill, and +// the input delta is never mutated. +func TestThreadMovedFrom_copiesByTypeAndID(t *testing.T) { + covered := []plangateaudit.Content{{SlotRefs: []plangateaudit.SlotRef{ + {Type: "crm_company", ID: "4299", MovedFrom: "4210"}, + {Type: "crm_company", ID: "7777"}, // no move recorded + }}} + in := []plangate.Slot{ + {Type: "crm_company", ID: "4299"}, + {Type: "crm_company", ID: "7777"}, + } + + out := threadMovedFrom(in, covered) + + require.Len(t, out, 2) + assert.Equal(t, "4210", out[0].MovedFrom, "the re-pointed slot carries the stamped move") + assert.Empty(t, out[1].MovedFrom, "a slot with no stamped move stays a first-fill") + assert.Empty(t, in[0].MovedFrom, "the input delta must not be mutated") +} + +// With nothing moved, threadMovedFrom returns the slots untouched — the common +// first-fill path pays nothing. +func TestThreadMovedFrom_noMoveIsNoOp(t *testing.T) { + in := []plangate.Slot{{Type: "crm_company", ID: "4299"}} + out := threadMovedFrom(in, []plangateaudit.Content{{SlotRefs: []plangateaudit.SlotRef{ + {Type: "crm_company", ID: "4299"}, + }}}) + require.Len(t, out, 1) + assert.Empty(t, out[0].MovedFrom) +} diff --git a/pkg/authz/plangate/card.go b/pkg/authz/plangate/card.go index 83137c73..497ae52f 100644 --- a/pkg/authz/plangate/card.go +++ b/pkg/authz/plangate/card.go @@ -117,6 +117,14 @@ type CardLine struct { // than by convention. https only; a non-URL, non-https, or ineligible // Detail leaves this empty and Detail renders as plain text. Href string `json:"href,omitempty"` + + // MovedFrom, on a RESOURCE line, is the display label of the instance this + // approval displaces: the session is pinned to it on a single-occupancy + // slot, and approving moves the pin to Detail and revokes this session's + // access to MovedFrom. Structural rather than folded into Detail so Detail + // stays the instance itself (and Href stays byte-identical to it), and so a + // surface renders the revocation as its own line instead of losing it. + MovedFrom string `json:"movedFrom,omitempty"` } func (c Card) JSON() (string, error) { @@ -495,8 +503,21 @@ func buildProjectedPlanWhat( // explicit rather than relying on that fallthrough. href = "" } - b.WriteString("\n reaches " + text + " — " + detail) - out.Resources = append(out.Resources, CardLine{Text: text, Detail: detail, Icon: icon, Href: href}) + line := CardLine{Text: text, Detail: detail, Icon: icon, Href: href} + if shown != "" && s.MovedFrom != "" { + // A MOVE, rendered exactly as the single-phase and amendment + // cards render it (buildSlotSection): both instances, and the + // revocation the approver must not miss. Without this a whole-plan + // card showed a re-point as a plain first-fill while the approval + // it recorded repointed the pin. + displaced := displacedLabel(resourceDisplays, s.Type, s.MovedFrom) + line.MovedFrom = displaced + b.WriteString("\n reaches " + text + " — " + displaced + " → " + detail) + b.WriteString("\n Approving moves the pin and revokes this session's access to " + displaced + ".") + } else { + b.WriteString("\n reaches " + text + " — " + detail) + } + out.Resources = append(out.Resources, line) } // Instances the CEILING names on EVERY call, which no slot declared. @@ -710,10 +731,19 @@ func buildSlotSection( // The declared value, not the object id. See the freeze site: the chain // that turns one into the other is injective, so this value is the only // one that can spend the approval. - if s.ID == "" { + // + // A MOVE (MovedFrom set on a named slot) renders both instances as + // " → ": the session is already pinned to a different + // instance of this single-occupancy slot, and approving repoints it. A + // first-fill (MovedFrom empty) renders the proposed instance alone, + // byte-identically to the pre-move card. + switch { + case s.ID == "": unnamed++ b.WriteString(unnamedTarget) - } else { + case s.MovedFrom != "": + b.WriteString(displacedLabel(displays, s.Type, s.MovedFrom) + " → " + s.ID) + default: b.WriteString(s.ID) } perm := permission[s.Type] @@ -721,6 +751,13 @@ func buildSlotSection( perm = p } b.WriteString("\n " + slotSentence(standing[s.Type], perm)) + // The revocation is the half of a move an approver must not miss: the + // displaced instance loses this session's access when the pin moves. A + // card naming only the new target would describe the grant and hide what + // it costs the current one. + if s.ID != "" && s.MovedFrom != "" { + b.WriteString("\n Approving moves the pin and revokes this session's access to " + displacedLabel(displays, s.Type, s.MovedFrom) + ".") + } } // The coverage state is the actual question being answered: does saying yes @@ -736,6 +773,27 @@ func buildSlotSection( return b.String() } +// displacedLabel renders the instance a move DISPLACES — Slot.MovedFrom, which +// is a DERIVED object id read off the pin, not the raw declared value the rest +// of the slot line shows. Rendered naively, a move reads as " → ": two spellings of the resource in one arrow, in front +// of the approver who most needs to recognise what they are repointing. +// +// So it runs MovedFrom through the SAME display path the proposed instance uses +// (resolveResourceLine): a type that declared a Label deriver gets a per-instance +// label, recovering the readable form where the derivation happens to be +// recoverable. When no label is recoverable (resolveResourceLine can only return +// the bare type name), it falls back to the derived id itself rather than the +// type name — the approver must still see WHICH instance is being displaced, not +// merely its kind. +func displacedLabel(displays map[string]ResourceDisplay, resourceType, movedFrom string) string { + text, _, _ := resolveResourceLine(displays, resourceType, movedFrom) + if text == resourceType { + return movedFrom + } + return text +} + // isRequiredStanding reports whether standing names the `required` mode. Any // other value — including absence — is session-only, matching the runner's // own default: an unresolved status must never be misread as `required`. diff --git a/pkg/authz/plangate/card_resource_display_test.go b/pkg/authz/plangate/card_resource_display_test.go index d2b3dd96..c27514ac 100644 --- a/pkg/authz/plangate/card_resource_display_test.go +++ b/pkg/authz/plangate/card_resource_display_test.go @@ -355,3 +355,21 @@ func TestBuildCard_AmendmentSlotSentenceNamesTheAddedPermission(t *testing.T) { assert.Contains(t, card.What, "read", "the sentence must name the permission actually being added") } + +// A whole-plan card renders a slot MOVE exactly as the single-phase card does: +// both instances in the prose, the revocation stated, and the displaced +// instance carried structurally on the resource line so a surface can render +// the revocation as its own line. Detail stays the new instance alone. +func TestBuildCard_WholePlanResourceLineShowsAMove(t *testing.T) { + p := planWithPushPhaseAndRepo(t, "https://github.com/demo-org/new-repo") + p.Phases[0].Slots[0].MovedFrom = "demo-org/old-repo" + card := BuildCard(CardInput{Plan: p, WholePlan: true}) + + assert.Contains(t, card.What, "demo-org/old-repo → https://github.com/demo-org/new-repo") + assert.Contains(t, card.What, "revokes this session's access to demo-org/old-repo") + require.Len(t, card.Phases, 1) + require.Len(t, card.Phases[0].Resources, 1) + line := card.Phases[0].Resources[0] + assert.Equal(t, "demo-org/old-repo", line.MovedFrom) + assert.Equal(t, "https://github.com/demo-org/new-repo", line.Detail, "Detail stays the new instance") +} diff --git a/pkg/authz/plangate/card_test.go b/pkg/authz/plangate/card_test.go index 09a677c8..5e69e530 100644 --- a/pkg/authz/plangate/card_test.go +++ b/pkg/authz/plangate/card_test.go @@ -378,6 +378,105 @@ func TestBuildCard_aHandleOnlyWideningMentionsNoSlots(t *testing.T) { "the slot was already granted; only the handle is new") } +// A slot that carries MovedFrom is a MOVE: the session is already pinned to a +// different instance of this single-occupancy slot, and approving repoints it. +// The card must show BOTH the current occupant and the proposed one so the +// approver sees what one click changes, and must state plainly that the current +// instance loses this session's access — a card that named only the new target +// would describe a grant while hiding the revocation it comes with. +func TestBuildCard_slotMoveShowsBothInstancesAndRevocation(t *testing.T) { + h := handle(t, "read", "crm_contact") + card := BuildCard(CardInput{ + Plan: Plan{Phases: []Phase{{ + Permissions: []permsurface.Handle{h}, + Slots: []Slot{{Type: "crm_company", ID: "4210"}}, + }}}, + Severity: Elevated, + AddedSlots: []Slot{{Type: "crm_company", ID: "4299", MovedFrom: "4210"}}, + }) + + assert.Contains(t, card.What, "4210", + "the move must name the instance the session is currently pinned to") + assert.Contains(t, card.What, "4299", + "and the instance approving moves the pin to") + assert.Contains(t, card.What, "4210 → 4299", + "both instances render as a move, current → proposed") + assert.Contains(t, card.What, "revokes this session's access to 4210", + "the card must state the displaced instance loses access, not hide the revocation") +} + +// A first-fill slot — no MovedFrom — must render exactly as it did before the +// move feature: the proposed instance alone, no arrow, no revocation sentence. +// This is the byte-compatibility guard: the overwhelmingly common case must not +// grow move copy just because the field exists. +func TestBuildCard_slotFirstFillRendersNoMoveCopy(t *testing.T) { + h := handle(t, "read", "crm_contact") + card := BuildCard(CardInput{ + Plan: Plan{Phases: []Phase{{ + Permissions: []permsurface.Handle{h}, + Slots: []Slot{{Type: "crm_company", ID: "4210"}}, + }}}, + Severity: Elevated, + AddedSlots: []Slot{{Type: "crm_company", ID: "4299"}}, + }) + + assert.Contains(t, card.What, "4299", "a first-fill names the instance it binds") + assert.NotContains(t, card.What, "→", "a first-fill is not a move, so no arrow") + assert.NotContains(t, strings.ToLower(card.What), "moves the pin", + "a first-fill must not claim to move a pin") + assert.NotContains(t, card.What, "revokes this session's access", + "a first-fill revokes nothing") +} + +// A move whose displaced instance (MovedFrom) is a DERIVED object id must render +// that instance in the SAME display vocabulary as the proposed one — not as the +// raw derived id sitting against a declared value. A type declaring a Label +// deriver resolves the displaced instance to its label (last_segment of +// "acme/old-repo" is "old-repo"), so the move reads "old-repo → …" rather than +// mixing an escaped derived id with a human value. +func TestBuildCard_slotMoveRendersDisplacedInstanceThroughDisplay(t *testing.T) { + h := handle(t, "push", "git_repo") + card := BuildCard(CardInput{ + Plan: Plan{Phases: []Phase{{ + Permissions: []permsurface.Handle{h}, + Slots: []Slot{{Type: "git_repo", ID: "acme/new-repo"}}, + }}}, + Severity: Elevated, + AddedSlots: []Slot{{Type: "git_repo", ID: "acme/new-repo", MovedFrom: "acme/old-repo"}}, + ResourceDisplays: map[string]ResourceDisplay{ + "git_repo": {Label: "last_segment"}, + }, + }) + + assert.Contains(t, card.What, "old-repo → ", + "the displaced instance renders through ResourceDisplays, consistent with the proposed one") + assert.Contains(t, card.What, "revokes this session's access to old-repo", + "the revocation sentence uses the same display form for the displaced instance") + assert.NotContains(t, card.What, "acme/old-repo → ", + "the raw derived id must not stand in the arrow when a display label is recoverable") +} + +// The fallback half of the same rule: a type with no declared display (or whose +// label cannot be derived) must still show the displaced instance's derived id, +// never the bare type name — the approver has to see WHICH instance moves. +func TestBuildCard_slotMoveFallsBackToDerivedIDWhenNoLabel(t *testing.T) { + h := handle(t, "read", "crm_contact") + card := BuildCard(CardInput{ + Plan: Plan{Phases: []Phase{{ + Permissions: []permsurface.Handle{h}, + Slots: []Slot{{Type: "crm_company", ID: "4299"}}, + }}}, + Severity: Elevated, + AddedSlots: []Slot{{Type: "crm_company", ID: "4299", MovedFrom: "4210"}}, + // No ResourceDisplays entry for crm_company → no label recoverable. + }) + + assert.Contains(t, card.What, "4210 → 4299", + "with no display, the displaced instance falls back to its derived id, not the type name") + assert.NotContains(t, card.What, "crm_company → ", + "the bare type name must never stand in for the displaced instance") +} + // planWithPushPhase is a one-phase plan whose ceiling is a single external // permission, for tests that only care about how one CardLine renders. func planWithPushPhase(t *testing.T) Plan { diff --git a/pkg/authz/plangate/plan.go b/pkg/authz/plangate/plan.go index 22f81c02..09886b51 100644 --- a/pkg/authz/plangate/plan.go +++ b/pkg/authz/plangate/plan.go @@ -118,6 +118,20 @@ type Slot struct { // plan and silently discard an approval already given — and an agent could // vary it deliberately to escape a phase it had been refused. Why string + + // MovedFrom, when non-empty, is the instance this session is CURRENTLY + // pinned to on a single-occupancy slot, set ONLY when this card names a + // DIFFERENT instance — i.e. approving the card MOVES the pin rather than + // filling an empty slot. DISPLAY ONLY: it is threaded onto the card's slots + // at card-build time from the approval record's MovedFrom (what the card + // showed the approver), and the card renders " → " plus a + // sentence stating the move revokes this session's access to . + // + // Excluded from identity exactly like Why — the digest and AuthorityKey + // read only Type and ID (see Digest / AuthorityKey), so threading it here + // cannot re-key a plan or discard an approval. Empty is the common + // first-fill case and renders byte-identically to the pre-move card. + MovedFrom string } // AuthoredSlot is a slot request as the agent wrote it, before validation. diff --git a/pkg/authz/promote_extracted.go b/pkg/authz/promote_extracted.go index d136a08e..8acee377 100644 --- a/pkg/authz/promote_extracted.go +++ b/pkg/authz/promote_extracted.go @@ -105,8 +105,10 @@ func PromoteExtractedSlots( ResourceID: id, // The extractor's own pre-transform value: what a fact about this // instance would have been keyed by, had one been recorded. - RawID: f.ResourceID, - Requires: et.Requires, + RawID: f.ResourceID, + Requires: et.Requires, + Occupancy: et.Occupancy, + Rebind: et.Rebind, }) } diff --git a/pkg/authz/promote_extracted_test.go b/pkg/authz/promote_extracted_test.go index 55fd3644..aa9f2932 100644 --- a/pkg/authz/promote_extracted_test.go +++ b/pkg/authz/promote_extracted_test.go @@ -32,7 +32,7 @@ func TestPromoteExtractedSlots_BindsCheckedCandidates(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/promote"} recordCandidate(t, mem, memScope, 3, "github_repo", "demo-org/demo-repo") - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteExtractedSlots(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{querySlot("github_repo", "read")}, allowAll(), w, "alice", 3, time.Now, 0)) @@ -112,7 +112,7 @@ func TestPromoteExtractedSlots_DropsWhatItMustNotBind(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/" + tc.name} recordCandidate(t, mem, memScope, 1, tc.candType, tc.candID) - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteExtractedSlots(systemCtx(), mem, memScope, bindSession(), tc.declared, tc.chk, w, "alice", 1, time.Now, 0)) @@ -136,7 +136,7 @@ func TestPromoteExtractedSlots_OnlyThisTurn(t *testing.T) { memScope := memory.Scope{Kind: "session", ID: "ns/promote-turns"} recordCandidate(t, mem, memScope, 1, "github_repo", "demo-org/turn-one") recordCandidate(t, mem, memScope, 2, "github_repo", "demo-org/turn-two") - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteExtractedSlots(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{querySlot("github_repo", "read")}, allowAll(), w, "alice", 2, time.Now, 0)) @@ -149,7 +149,7 @@ func TestPromoteExtractedSlots_Idempotent(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/promote-idem"} recordCandidate(t, mem, memScope, 1, "github_repo", "demo-org/demo-repo") - w := &recordingRelWriter{} + w := &pinningFake{} specs := []authz.BoundEntitySpec{querySlot("github_repo", "read")} require.NoError(t, authz.PromoteExtractedSlots(systemCtx(), mem, memScope, bindSession(), specs, allowAll(), w, "alice", 1, time.Now, 0)) @@ -181,7 +181,7 @@ func TestPromoteExtractedSlots_NoOpInputs(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteExtractedSlots(systemCtx(), mem, memScope, bindSession(), tc.specs, tc.chk, w, tc.subject, tc.turn, time.Now, 0)) assert.Empty(t, w.wrote) @@ -198,7 +198,7 @@ func TestPromoteExtractedSlots_CandidateRunsThroughTheDeclaredTransformChain(t * mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/promote-transform"} recordCandidate(t, mem, memScope, 5, "git_repo", "https://github.com/acme/app") - w := &recordingRelWriter{} + w := &pinningFake{} slot := querySlot("git_repo", "push") slot.ValueTransforms = []string{"normalize_url", "spicedb_escape"} @@ -217,7 +217,7 @@ func TestPromoteExtractedSlots_UndrivableCandidateIsDroppedNotBoundRaw(t *testin mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/promote-badtransform"} recordCandidate(t, mem, memScope, 6, "git_repo", "https://github.com/acme/app") - w := &recordingRelWriter{} + w := &pinningFake{} slot := querySlot("git_repo", "push") slot.ValueTransforms = []string{"no_such_transform"} @@ -234,7 +234,7 @@ func TestPromoteExtractedSlots_AskSlotBindsTheAnsweredValue(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/ask"} recordCandidate(t, mem, memScope, 4, "cluster", "cluster-7") - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteExtractedSlots(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{{ResourceType: "cluster", Permission: "debug", FillFrom: []string{"ask"}}}, diff --git a/pkg/authz/promote_observed.go b/pkg/authz/promote_observed.go index 3d018bd3..d920ea53 100644 --- a/pkg/authz/promote_observed.go +++ b/pkg/authz/promote_observed.go @@ -182,8 +182,10 @@ func PromoteObservedSlots( // The RAW subject id, as Record wrote it — the key the slot's // preconditions read their facts back by. The transform chain ran // only for `id` above, for the SpiceDB call. - RawID: s.ResourceID, - Requires: et.Requires, + RawID: s.ResourceID, + Requires: et.Requires, + Occupancy: et.Occupancy, + Rebind: et.Rebind, }) } if len(cands) == 0 { diff --git a/pkg/authz/promote_observed_test.go b/pkg/authz/promote_observed_test.go index 8b9e8b13..d228b788 100644 --- a/pkg/authz/promote_observed_test.go +++ b/pkg/authz/promote_observed_test.go @@ -44,7 +44,7 @@ func TestPromoteObservedSlots_BindsCheckedCandidates(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/observed"} recordObserved(t, mem, memScope, "git_commit", "ba03f5969a", false) - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{observedSlot("git_commit", "read")}, allowAll(), w, "alice", time.Now, 0)) @@ -116,7 +116,7 @@ func TestPromoteObservedSlots_DropsWhatItMustNotBind(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/" + tc.name} recordObserved(t, mem, memScope, tc.factType, tc.factID, false) - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, bindSession(), tc.declared, tc.chk, w, "alice", time.Now, 0)) @@ -147,7 +147,7 @@ func TestPromoteObservedSlots_OnlyTheDeclaredTypeBinds(t *testing.T) { // One payload, two co-derived subjects — the design's own example. recordObserved(t, mem, memScope, "git_commit", "ba03f5969a", false) recordObserved(t, mem, memScope, "github_pr", "demo-org/demo-repo#6", false) - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{observedSlot("git_commit", "read")}, allowAll(), w, "alice", time.Now, 0)) @@ -163,7 +163,7 @@ func TestPromoteObservedSlots_Idempotent(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/observed-idem"} recordObserved(t, mem, memScope, "git_commit", "ba03f5969a", false) - w := &recordingRelWriter{} + w := &pinningFake{} specs := []authz.BoundEntitySpec{observedSlot("git_commit", "read")} require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, bindSession(), specs, allowAll(), w, "alice", time.Now, 0)) @@ -188,7 +188,7 @@ func TestPromoteObservedSlots_EnvelopeFactAlsoProposes(t *testing.T) { Facts: map[string]any{"head_is_fork": true}, Source: factcontent.Source{ChannelKind: "github", Event: "pull_request"}, })) - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{observedSlot("github_pr", "fetch")}, allowAll(), w, "alice", time.Now, 0)) @@ -208,7 +208,7 @@ func TestPromoteObservedSlots_SameSubjectInBothKindsBindsOnce(t *testing.T) { Facts: map[string]any{"head_is_fork": true}, })) recordObserved(t, mem, memScope, "github_pr", "demo-org/demo-repo#6", true) - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{observedSlot("github_pr", "fetch")}, allowAll(), w, "alice", time.Now, 0)) @@ -224,7 +224,7 @@ func TestPromoteObservedSlots_RawSubjectRunsThroughTheDeclaredTransformChain(t * mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/observed-transform"} recordObserved(t, mem, memScope, "github_pr", "demo-org/demo-repo#6", false) - w := &recordingRelWriter{} + w := &pinningFake{} slot := observedSlot("github_pr", "fetch") slot.ValueTransforms = []string{"spicedb_escape"} @@ -244,7 +244,7 @@ func TestPromoteObservedSlots_UndrivableSubjectIsDroppedNotBoundRaw(t *testing.T mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/observed-badtransform"} recordObserved(t, mem, memScope, "github_pr", "demo-org/demo-repo#6", false) - w := &recordingRelWriter{} + w := &pinningFake{} slot := observedSlot("github_pr", "fetch") slot.ValueTransforms = []string{"no_such_transform"} @@ -276,7 +276,7 @@ func TestPromoteObservedSlots_NoOpInputs(t *testing.T) { if tc.seed { recordObserved(t, mem, memScope, "git_commit", "ba03f5969a", false) } - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, bindSession(), tc.specs, tc.chk, w, tc.subject, time.Now, 0)) assert.Empty(t, w.wrote) @@ -315,7 +315,7 @@ func TestPromoteObservedSlots_DoesNotRetagAHumanApprovedScopeEntry(t *testing.T) // consecutive dispatch rounds would. recordObserved(t, mem, memScope, "git_commit", "ba03f5969a", false) specs := []authz.BoundEntitySpec{observedSlot("git_commit", "read")} - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, sess, specs, allowAll(), w, "alice", now, 0)) require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, sess, specs, allowAll(), w, "alice", now, 0)) @@ -343,7 +343,7 @@ func TestPromoteObservedSlots_TagsAnEntryItCreated(t *testing.T) { mem := memory.NewLocal(inmem.NewBackend()) memScope := memory.Scope{Kind: "session", ID: "ns/observed-newtag"} recordObserved(t, mem, memScope, "git_commit", "ba03f5969a", false) - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.PromoteObservedSlots(systemCtx(), mem, memScope, bindSession(), []authz.BoundEntitySpec{observedSlot("git_commit", "read")}, allowAll(), w, "alice", time.Now, 0)) diff --git a/pkg/authz/relwrites/reserved_types.go b/pkg/authz/relwrites/reserved_types.go index 327fb324..cef5f703 100644 --- a/pkg/authz/relwrites/reserved_types.go +++ b/pkg/authz/relwrites/reserved_types.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/authzed/openagentprimitives/pkg/authz" guardianschema "github.com/authzed/openagentprimitives/pkg/authz/guardian/schema" ) @@ -198,6 +199,18 @@ func ValidateResolvedTuple(t ResolvedTuple) error { return fmt.Errorf("tuple would grant %q a relation on the reserved type %q: a tool may not hand a principal authority over a platform object (resource=%q, relation=%q)", t.Subject, resType, t.Resource, t.Relation) } + + // Platform mechanism relations are never tool-writable. slot_pin is the + // single-occupancy commitment the pinning gate writes with an atomic + // precondition; slot_grant_* are the session's slot grants. A toolspec + // writing either directly would bypass the gate that makes them mean + // anything. (slot_grant_* is also rejected by SpiceDB's mandatory + // expiration today; refusing it here makes the protection a rule rather + // than an accident.) + if t.Relation == authz.SlotPinRelationName || strings.HasPrefix(t.Relation, authz.SlotGrantRelationPrefix) { + return fmt.Errorf("relation %q is a platform slot mechanism relation and cannot be written by a tool", t.Relation) + } + return nil } diff --git a/pkg/authz/relwrites/reserved_types_test.go b/pkg/authz/relwrites/reserved_types_test.go index 1c21b8a1..89fe4853 100644 --- a/pkg/authz/relwrites/reserved_types_test.go +++ b/pkg/authz/relwrites/reserved_types_test.go @@ -125,3 +125,29 @@ func TestAttestedForgeIdentityRefusedAsPrincipalOnScaffoldResource(t *testing.T) require.Error(t, err, "an attested forge identity must not be handed authority over a platform object") assert.Contains(t, err.Error(), "a tool may not hand a principal authority over a platform object") } + +// slot_pin and slot_grant_* are platform slot mechanisms that must not be +// writable by tools. slot_pin is the single-occupancy commitment the pinning +// gate writes with an atomic precondition; slot_grant_* are the session's slot +// grants. A toolspec writing either would bypass the gate that makes them mean +// anything. +func TestValidateResolvedTuple_RefusesPlatformSlotRelations(t *testing.T) { + cases := []struct { + name string + relation string + }{ + {"slot_pin is the pinning mechanism's own relation", "slot_pin"}, + {"slot_grant_push is a grant relation", "slot_grant_push"}, + {"slot_grant_read is a grant relation", "slot_grant_read"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := relwrites.ValidateResolvedTuple(relwrites.ResolvedTuple{ + Resource: "git_repo:acme/widgets", Relation: tc.relation, + Subject: "agentsession:ns/s", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.relation) + }) + } +} diff --git a/pkg/authz/slot_grant.go b/pkg/authz/slot_grant.go index 995ff1e4..09314f33 100644 --- a/pkg/authz/slot_grant.go +++ b/pkg/authz/slot_grant.go @@ -2,6 +2,7 @@ package authz import ( "context" + "errors" "fmt" "time" @@ -125,14 +126,78 @@ type SlotBinding struct { // gate, recorded through PreconditionsWaived — and for every binding whose // type declares no precondition, where there is nothing to evaluate. Requires []precondition.Rule + + // Occupancy and Rebind are copied from the slot declaration the binding + // came from. Empty Occupancy means "single" (the field's default): a + // binding whose producer predates these fields is gated, never un-gated, + // by omission. + Occupancy string + Rebind string + + // PriorID, when non-zero, means this approved binding MOVES a filled + // single-occupancy slot from PriorID to ResourceID — atomically, with + // PriorID's grants revoked in the same request. It is derived ONLY from + // the approval record's MovedFrom (what the card showed the approver), + // never from a live pin read at decision time: a card can sit parked for + // days, and the human must get exactly the move they were shown. If the + // pin is no longer PriorID at execution, the move's MUST_MATCH fails and + // the approval fails loudly rather than moving something unseen. + PriorID ObjectID +} + +// SlotOccupancySingle and SlotOccupancyMulti name the two occupancy modes a +// slot declares. They mirror the AgentClass CRD's `occupancy` enum +// (v1alpha1.AuthzSlotOccupancyDefault); pkg/authz cannot import v1alpha1 (that +// package imports this one), so the contract strings are restated here. +// +// Empty reads as single — see occupancyOf. A binding produced before these +// fields existed is therefore GATED, never silently un-gated, by omission. +const ( + SlotOccupancySingle = "single" + SlotOccupancyMulti = "multi" +) + +// SlotRebindNever names the one rebind mode whose refusal message routes to a +// new session rather than to a plan amendment. The default ("approval", and the +// empty value) is handled by the switch default in GrantSlots, so it needs no +// constant of its own. +const SlotRebindNever = "never" + +// occupancyOf reports a binding's occupancy, defaulting empty to single. The +// fail-closed default lives here so every reader agrees on it. +func occupancyOf(b SlotBinding) string { + if b.Occupancy == "" { + return SlotOccupancySingle + } + return b.Occupancy } -// GrantSlots writes a slot grant per binding. +// GrantSlots writes a slot grant per binding, enforcing single-occupancy +// pinning PER RESOURCE TYPE. +// +// Partitioned by type, not all-or-nothing across the whole call. A +// single-occupancy type binds at most one instance for the session's life: a +// second DISTINCT instance of that type is refused (ErrSlotPinned), and the +// refusal names the route out. But a refusal on ONE type must not strand +// another type's bindings in the same call — those are independent grants a +// human may already have approved — so each type is resolved on its own: // -// It is deliberately all-or-nothing per call rather than best-effort per -// binding: these are authorization grants, and a partially-applied set would -// leave the agent believing it holds reach it does not, which surfaces later as -// an inexplicable denial mid-task rather than as a failure at bind time. +// - single-occupancy types go through the SlotPinner (EnsurePin + +// WriteGrantsPinned), which records the one pinned instance and guards the +// grant write against a concurrent move; +// - multi-occupancy types keep the plain batched TOUCH write, unpinned, each +// addition gated as before. +// +// Within a single type the write is still atomic (its grants land together or +// not at all); across types, a refused type leaves the others untouched. Input +// validation (malformed binding, missing expiry) still fails the WHOLE call +// before anything is written — a caller that handed a nonsense binding has a +// bug, not a partial set to salvage. +// +// A non-nil RelWriter that does not implement SlotPinner cannot bind a +// single-occupancy type: the gate refuses that type rather than writing it +// through unpinned as if it were unmarked. The nil-writer early return stays +// first — nil grants nothing, so there is nothing to protect. func GrantSlots(ctx context.Context, g RelWriter, scope SessionRef, bindings []SlotBinding, expiresAt time.Time) error { if g == nil || len(bindings) == 0 { return nil @@ -144,21 +209,128 @@ func GrantSlots(ctx context.Context, g RelWriter, scope SessionRef, bindings []S // to anchor to should pass SlotGrantExpiry(now, 0). return fmt.Errorf("authz: slot grant needs an expiry (schema requires one); use SlotGrantExpiry") } - rels := make([]Relation, 0, len(bindings)) + + // One group per resource type, preserving first-appearance order so writes + // and refusals are deterministic. All validation runs in this pass, before + // any write, so a malformed binding fails the whole call rather than leaving + // a partial set behind. + type typeGroup struct { + rels []Relation + ids map[string]struct{} + firstID string + single bool + rebind string + } + order := make([]string, 0, len(bindings)) + groups := make(map[string]*typeGroup, len(bindings)) for _, b := range bindings { if b.ResourceType == "" || b.ResourceID.IsZero() || b.Permission == "" { // An empty id names no instance; an empty permission names no // relation, so the tuple would grant nothing and be unrevocable. return fmt.Errorf("authz: slot binding needs resourceType, resourceID and permission: %+v", b) } + grp := groups[b.ResourceType] + if grp == nil { + grp = &typeGroup{ids: make(map[string]struct{})} + groups[b.ResourceType] = grp + order = append(order, b.ResourceType) + } + // A type is single unless EVERY binding of it is multi: mixed occupancy + // within one call is a producer bug, and the single (pinned) treatment is + // the fail-closed one. + if occupancyOf(b) != SlotOccupancyMulti { + grp.single = true + } + if grp.rebind == "" { + grp.rebind = b.Rebind + } rel := SlotGrantRelation(b.ResourceType, b.ResourceID.String(), b.Permission, scope) rel.ExpiresAt = expiresAt - rels = append(rels, rel) + grp.rels = append(grp.rels, rel) + id := b.ResourceID.String() + if _, seen := grp.ids[id]; !seen { + grp.ids[id] = struct{}{} + if grp.firstID == "" { + grp.firstID = id + } + } } - if err := g.WriteRelationships(ctx, rels); err != nil { - return fmt.Errorf("authz: write slot grants: %w", err) + + // refusals accumulates EVERY per-type refusal and store failure (and, below, a failed plain + // write) so the remaining types still land and NO refusal is silently + // dropped. Keeping only the first would hide a second single-occupancy type's + // refusal behind the first — the model would be told about one blocked target + // and left to rediscover the next by trying it. errors.Join at the end keeps + // every errors.Is(…, ErrSlotPinned) reachable for the dispatchers that route + // on the sentinel. + var refusals []error + var plainRels []Relation + for _, rt := range order { + grp := groups[rt] + if !grp.single { + plainRels = append(plainRels, grp.rels...) + continue + } + // Single-occupancy type. More than one distinct instance arriving in one + // set cannot be reconciled to a single occupant, so it is refused outright + // — nothing is pinned and nothing is granted for this type. + if len(grp.ids) > 1 { + refusals = append(refusals, fmt.Errorf("%w: slot type %s cannot hold %d distinct instances at once; it is single-occupancy", + ErrSlotPinned, rt, len(grp.ids))) + continue + } + pinner, ok := g.(SlotPinner) + if !ok { + // A plain RelWriter cannot express the pin's precondition, so writing + // the grant through it would silently drop the single-occupancy + // guarantee. Refuse this type rather than write it unpinned. + refusals = append(refusals, fmt.Errorf("authz: single-occupancy slot type %s needs a SlotPinner-capable writer, got %T", rt, g)) + continue + } + id := grp.firstID + held, pinnedID, err := pinner.EnsurePin(ctx, rt, id, scope) + if err != nil { + // Collected, not returned: a store failure on THIS type must not + // discard refusals already gathered for others, nor skip the types + // and the multi-occupancy write still to come. + refusals = append(refusals, fmt.Errorf("authz: ensure pin for %s:%s: %w", rt, id, err)) + continue + } + if held && pinnedID != id { + // Already pinned to a DIFFERENT instance. The refusal text is + // user-visible — it becomes text the model reads — so it names the + // pinned instance and the route out, chosen by the slot's rebind mode. + switch grp.rebind { + case SlotRebindNever: + refusals = append(refusals, fmt.Errorf("%w: this session is pinned to %s:%s for its lifetime; start a new session to target %s:%s", + ErrSlotPinned, rt, pinnedID, rt, id)) + default: // "approval" and unset + refusals = append(refusals, fmt.Errorf("%w: this session is pinned to %s:%s; to work on %s:%s, propose an updated plan naming it — an approved plan moves the pin", + ErrSlotPinned, rt, pinnedID, rt, id)) + } + continue + } + // held && pinnedID == id: already pinned to this SAME instance (a second + // permission, or a re-grant after expiry) — not drift, so it still binds. + if err := pinner.WriteGrantsPinned(ctx, grp.rels, rt, id, scope); err != nil { + // Collected for the same reason as an EnsurePin failure. A pin that + // EnsurePin just wrote stays: it names the instance this call meant + // to bind, so a retry is a same-instance bind, not a refusal. + refusals = append(refusals, fmt.Errorf("authz: write pinned slot grants for %s:%s: %w", rt, id, err)) + continue + } } - return nil + + if len(plainRels) > 0 { + if err := g.WriteRelationships(ctx, plainRels); err != nil { + // JOIN, not return: a plain-write failure must not discard a pin + // refusal already accumulated above for another type in this call — + // the caller needs to see both the multi-occupancy write failure AND + // the single-occupancy refusal that stood beside it. + refusals = append(refusals, fmt.Errorf("authz: write slot grants: %w", err)) + } + } + return errors.Join(refusals...) } // RevokeSlots removes slot grants. @@ -184,16 +356,39 @@ func RevokeSlots(ctx context.Context, g RelWriter, scope SessionRef, bindings [] // bound instances onto its child. *spicedb.Client satisfies it. type SlotGrantCopier interface { ListSlotGrants(ctx context.Context, ns, name string) ([]SlotBinding, error) - GrantSlots(ctx context.Context, ns, name string, bindings []SlotBinding, expiresAt time.Time) error + // ListSlotPins returns every pin the parent holds, verbatim — resource and + // subject only, no derived occupancy. See SlotPinRelation. + ListSlotPins(ctx context.Context, ns, name string) ([]Relation, error) + // CopySlotTuples writes rels in one batched TOUCH — no gate, no expiry + // stamping of its own. The caller (CopySlotGrants) has already re-targeted + // each relation's SubjectID at the child and, for a grant, stamped the + // child's expiry; a pin tuple carries none, matching how EnsurePin writes + // it. + CopySlotTuples(ctx context.Context, rels []Relation) error } -// CopySlotGrants re-points the parent's slot grants at the child, returning how -// many were carried. +// CopySlotGrants re-points the parent's slot grants AND pins at the child, +// VERBATIM, returning how many grants were carried. +// +// VERBATIM, not re-gated through GrantSlots: a lifecycle clone is not a new +// arrival. GrantSlots enforces single-occupancy from a SlotBinding's declared +// Occupancy field, which a tuple read back out of SpiceDB cannot reconstruct — +// ListSlotGrants returns bindings with no Occupancy set, and occupancyOf reads +// that as "single" for every type, fail-closed. Routing the copy back through +// GrantSlots would therefore refuse outright any restart of a multi-occupancy +// slot currently holding two or more instances, and any legacy two-instance +// session recorded before single-occupancy pinning existed — copying NOTHING +// for that type on every such restart. The pin invariant still holds: a pin is +// listed from the parent and copied as-is onto the child, which starts with no +// pin of its own, so there is nothing for the copied pin to conflict with. // // A grant names the SESSION as its subject, so a child session inherits nothing // automatically — without this, a continuation would silently lose every // instance a human had approved and start re-asking for values the user already -// granted. +// granted. A pin is the same kind of tuple, naming the session as subject: left +// uncopied, the child starts unpinned and its first bind can land on a +// different instance than the one the parent had already committed to, with no +// error to say so. // // CALLERS MUST NOT CALL THIS FOR A takeover FORK. A takeover is a DIFFERENT user // continuing a terminal session, and they become the child's owner; copying the @@ -217,11 +412,28 @@ func CopySlotGrants(ctx context.Context, c SlotGrantCopier, parent, child Sessio if err != nil { return 0, fmt.Errorf("authz: list parent slot grants %s: %w", parent, err) } - if len(held) == 0 { + pins, err := c.ListSlotPins(ctx, parent.Namespace, parent.Name) + if err != nil { + return 0, fmt.Errorf("authz: list parent slot pins %s: %w", parent, err) + } + if len(held) == 0 && len(pins) == 0 { return 0, nil } - if err := c.GrantSlots(ctx, child.Namespace, child.Name, held, expiresAt); err != nil { - return 0, fmt.Errorf("authz: grant child slot grants %s: %w", child, err) + + rels := make([]Relation, 0, len(held)+len(pins)) + for _, b := range held { + rel := SlotGrantRelation(b.ResourceType, b.ResourceID.String(), b.Permission, child) + rel.ExpiresAt = expiresAt + rels = append(rels, rel) + } + for _, p := range pins { + // The pin is otherwise copied byte-for-byte; only the subject moves. + p.SubjectID = child.String() + rels = append(rels, p) + } + + if err := c.CopySlotTuples(ctx, rels); err != nil { + return 0, fmt.Errorf("authz: copy slot tuples to child %s: %w", child, err) } return len(held), nil } diff --git a/pkg/authz/slot_grant_guard_test.go b/pkg/authz/slot_grant_guard_test.go new file mode 100644 index 00000000..1303e279 --- /dev/null +++ b/pkg/authz/slot_grant_guard_test.go @@ -0,0 +1,166 @@ +//go:build !integration && !e2e + +package authz_test + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSlotWritesRouteThroughTheGate refuses any production .go file that +// hand-builds a slot_grant_/slot_pin tuple, except the three sanctioned +// writers. Every production slot write must route through GrantSlots (the +// single-occupancy gate) or the pinner — otherwise a caller that assembled its +// own slot tuple and wrote it would bypass the pin entirely, binding a second +// instance to a slot the gate would have refused, with nothing going red. +// +// It flags two shapes, the two ways the gate gets bypassed by hand: +// +// - a STRING LITERAL beginning with "slot_grant_" or "slot_pin" — a tuple +// relation name written out rather than taken from the builder; and +// - a call to the tuple builders authz.SlotGrantRelation / SlotPinRelation +// (exact name, so DataSlotGrantRelation, isSlotGrantRelation and the +// SlotGrantRelationName/Prefix read-side helpers are NOT flagged), which a +// caller could feed straight to a WriteRelationships bypassing the gate. +// +// Sanctioned sites, and why each is the gate rather than a bypass of it: +// +// - pkg/authz/slot_grant.go — defines SlotGrantRelation and is where +// GrantSlots/RevokeSlots build the grant tuples. +// - pkg/authz/slot_pin.go — defines SlotPinRelation and the pin relation +// name. +// - pkg/authz/spicedb/slot_pin.go — the SlotPinner implementation itself. +// The brief's own contract says writes route through "GrantSlots/the +// pinner"; this file IS the pinner (EnsurePin/MovePin build and write the +// pin tuple), so it is a sanctioned writer, not a bypass of one. +// +// Walked over pkg/, internal/ AND cmd/ deliberately: the server binaries live +// under internal/cmd and the user-facing CLI under cmd/oap, so a pkg/-only scan +// would guard nothing on the sides that actually reach SpiceDB in production. A +// tree-walking guard rooted short of cmd/ keeps passing while silently guarding +// nothing there (see the ship-gate note in CLAUDE.md). test/ is out of scope +// (its SlotGrantRelationName use only derives schema-wait pairs, and a test +// cannot ship a bypass to a cluster). +// +// # What this guard cannot catch +// +// It matches on the literal and on the NAME at the call site, so a rename of +// the builder, an alias type, or a relation string assembled from fragments +// evades it — the same limitation pkg/authz/slotspec/guard_test.go states for +// its own walk. It catches the shape a person writes by hand, which is the one +// that has actually happened. +func TestSlotWritesRouteThroughTheGate(t *testing.T) { + roots := []string{"../../pkg", "../../internal", "../../cmd"} + sanctioned := []string{ + "/pkg/authz/slot_grant.go", + "/pkg/authz/slot_pin.go", + "/pkg/authz/spicedb/slot_pin.go", + } + isSanctioned := func(path string) bool { + slash := filepath.ToSlash(path) + for _, s := range sanctioned { + if strings.HasSuffix(slash, s) { + return true + } + } + return false + } + + var offenders []string + sanctionedHits := map[string]bool{} + + for _, root := range roots { + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + fset := token.NewFileSet() + file, perr := parser.ParseFile(fset, path, nil, 0) + if perr != nil { + return nil // unparseable (generated, build-tagged oddity); not this test's business + } + var hit token.Pos = token.NoPos + ast.Inspect(file, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.BasicLit: + if x.Kind == token.STRING { + if v, uerr := strconv.Unquote(x.Value); uerr == nil && buildsSlotRelationString(v) { + hit = x.Pos() + } + } + case *ast.CallExpr: + if callsSlotTupleBuilder(x.Fun) { + hit = x.Pos() + } + } + return hit == token.NoPos + }) + if hit == token.NoPos { + return nil + } + if isSanctioned(path) { + sanctionedHits[filepath.ToSlash(path)] = true + return nil + } + offenders = append(offenders, fset.Position(hit).String()) + return nil + }) + require.NoError(t, err, "walking %s", root) + } + + assert.Empty(t, offenders, + "a slot_grant_/slot_pin tuple built outside GrantSlots or the pinner bypasses the single-occupancy "+ + "gate: route the write through authz.GrantSlots (or the SlotPinner) instead of assembling the tuple by hand") + + // A walk that stopped seeing the sanctioned writers would pass vacuously — + // offenders would be empty because nothing matched at all. Require the + // detector to have fired on the two builder-definition files it must always + // find, so "no offenders" means "the gate holds", not "the scan broke". + assert.True(t, sanctionedHitEndsWith(sanctionedHits, "/pkg/authz/slot_grant.go"), + "the detector must fire on slot_grant.go; if it does not, the walk is broken and proves nothing") + assert.True(t, sanctionedHitEndsWith(sanctionedHits, "/pkg/authz/slot_pin.go"), + "the detector must fire on slot_pin.go; if it does not, the walk is broken and proves nothing") +} + +// buildsSlotRelationString reports whether a string literal's value is a slot +// relation name written by hand. +func buildsSlotRelationString(v string) bool { + return strings.HasPrefix(v, "slot_grant_") || strings.HasPrefix(v, "slot_pin") +} + +// callsSlotTupleBuilder reports whether a call target is the SlotGrantRelation +// or SlotPinRelation tuple builder, by EXACT name (qualified or not) so the +// read-side helpers whose names merely share a prefix are left alone. +func callsSlotTupleBuilder(fun ast.Expr) bool { + var name string + switch f := fun.(type) { + case *ast.Ident: + name = f.Name + case *ast.SelectorExpr: + name = f.Sel.Name + default: + return false + } + return name == "SlotGrantRelation" || name == "SlotPinRelation" +} + +func sanctionedHitEndsWith(hits map[string]bool, suffix string) bool { + for p := range hits { + if strings.HasSuffix(p, suffix) { + return true + } + } + return false +} diff --git a/pkg/authz/slot_grant_test.go b/pkg/authz/slot_grant_test.go index 2a164f61..88b35367 100644 --- a/pkg/authz/slot_grant_test.go +++ b/pkg/authz/slot_grant_test.go @@ -35,6 +35,107 @@ func (r *recordingRelWriter) DeleteRelationships(_ context.Context, rels []authz return nil } +// pinningFake implements BOTH authz.RelWriter and authz.SlotPinner — the pair a +// single-occupancy grant needs, and what recordingRelWriter deliberately is not +// (recordingRelWriter is kept for the fail-closed "writer is not a pinner" +// case). Every grant it records lands in `wrote`, whether it arrived through the +// pinned write (single types) or the plain batched write (multi types), so a +// test that only inspects `wrote` reads the same whichever path a binding took. +// `pins` holds one pinned instance per (scope,type), surviving across calls so +// idempotent re-grants of the same instance behave as production does. +type pinningFake struct { + wrote []authz.Relation + deleted []authz.Relation + plain []authz.Relation // every WriteRelationships (multi) rel, in order + pinned []pinnedWrite // every WriteGrantsPinned call, in order + pins map[string]string + grantsFor map[string][]authz.Relation // seeded per type+"\x00"+id, served by ListGrantsFor + ensureErr error // injected into EnsurePin + writeErr error // injected into every write method +} + +type pinnedWrite struct { + resourceType string + pinnedID string + rels []authz.Relation +} + +func pinKey(scope authz.SessionRef, resourceType string) string { + return scope.String() + "\x00" + resourceType +} + +func (f *pinningFake) WriteRelationships(_ context.Context, rels []authz.Relation) error { + if f.writeErr != nil { + return f.writeErr + } + f.wrote = append(f.wrote, rels...) + f.plain = append(f.plain, rels...) + return nil +} + +func (f *pinningFake) DeleteRelationships(_ context.Context, rels []authz.Relation) error { + if f.writeErr != nil { + return f.writeErr + } + f.deleted = append(f.deleted, rels...) + return nil +} + +func (f *pinningFake) EnsurePin(_ context.Context, resourceType, resourceID string, scope authz.SessionRef) (bool, string, error) { + if f.ensureErr != nil { + return false, "", f.ensureErr + } + if f.pins == nil { + f.pins = map[string]string{} + } + key := pinKey(scope, resourceType) + if cur, ok := f.pins[key]; ok { + return true, cur, nil + } + f.pins[key] = resourceID + return false, resourceID, nil +} + +func (f *pinningFake) WriteGrantsPinned(_ context.Context, rels []authz.Relation, resourceType, pinnedID string, _ authz.SessionRef) error { + if f.writeErr != nil { + return f.writeErr + } + f.wrote = append(f.wrote, rels...) + f.pinned = append(f.pinned, pinnedWrite{resourceType: resourceType, pinnedID: pinnedID, rels: rels}) + return nil +} + +func (f *pinningFake) MovePin(_ context.Context, resourceType, _, toID string, revoke []authz.Relation, scope authz.SessionRef) error { + if f.pins == nil { + f.pins = map[string]string{} + } + f.pins[pinKey(scope, resourceType)] = toID + f.deleted = append(f.deleted, revoke...) + return nil +} + +func (f *pinningFake) ReadPin(_ context.Context, resourceType string, scope authz.SessionRef) (string, error) { + return f.pins[pinKey(scope, resourceType)], nil +} + +func (f *pinningFake) ListGrantsFor(_ context.Context, resourceType, resourceID string, _ authz.SessionRef) ([]authz.Relation, error) { + return f.grantsFor[resourceType+"\x00"+resourceID], nil +} + +// seedPin pre-fills a type's pin, for the tests that drive a second arrival +// against an already-occupied slot. +func (f *pinningFake) seedPin(scope authz.SessionRef, resourceType, resourceID string) { + if f.pins == nil { + f.pins = map[string]string{} + } + f.pins[pinKey(scope, resourceType)] = resourceID +} + +var ( + _ authz.RelWriter = (*pinningFake)(nil) + _ authz.SlotPinner = (*pinningFake)(nil) +) + func testScope() authz.SessionRef { return authz.SessionRef{Namespace: "default", Name: "sess-1"} } // testExpiry is any non-zero expiry; GrantSlots refuses a zero one because the @@ -60,7 +161,9 @@ func TestSlotGrantRelation_pointsFromResourceToSession(t *testing.T) { } func TestGrantSlots_writesOnePerBinding(t *testing.T) { - w := &recordingRelWriter{} + // Two DISTINCT single-occupancy types, so each pins its own instance and + // both grants land — the per-type path, not a multi-arrival. + w := &pinningFake{} require.NoError(t, authz.GrantSlots(context.Background(), w, testScope(), []authz.SlotBinding{ {ResourceType: "crm_company", ResourceID: authz.TrustedObjectID("acme"), Permission: "contact_access"}, {ResourceType: "tracker_issue", ResourceID: authz.TrustedObjectID("L-140"), Permission: "write"}, @@ -102,7 +205,10 @@ func TestGrantSlots_refusesAnEmptyIdentifierWithoutWritingAnything(t *testing.T) // believing it holds reach it does not, which shows up later as an inexplicable // mid-task denial rather than a failure at bind time. func TestGrantSlots_propagatesTheWriteError(t *testing.T) { - w := &recordingRelWriter{err: errors.New("spicedb unavailable")} + // A single-occupancy grant routes through the pinner, so the store failure is + // injected there; the point is unchanged — a dropped grant must not look like + // a success. + w := &pinningFake{writeErr: errors.New("spicedb unavailable")} err := authz.GrantSlots(context.Background(), w, testScope(), []authz.SlotBinding{{ResourceType: "crm_company", ResourceID: authz.TrustedObjectID("acme"), Permission: "contact_access"}}, testExpiry()) require.Error(t, err) @@ -112,7 +218,7 @@ func TestGrantSlots_propagatesTheWriteError(t *testing.T) { // Every grant that can be written must be revocable, or a binding is a one-way // door for the life of the session. func TestRevokeSlots_deletesTheSameTupleItWould(t *testing.T) { - w := &recordingRelWriter{} + w := &pinningFake{} binding := []authz.SlotBinding{{ResourceType: "crm_company", ResourceID: authz.TrustedObjectID("acme"), Permission: "contact_access"}} require.NoError(t, authz.GrantSlots(context.Background(), w, testScope(), binding, testExpiry())) @@ -159,7 +265,7 @@ func TestGrantSlots_RefusesAZeroExpiry(t *testing.T) { } func TestGrantSlots_StampsTheExpiryOnEveryTuple(t *testing.T) { - w := &recordingRelWriter{} + w := &pinningFake{} exp := testExpiry() require.NoError(t, authz.GrantSlots(context.Background(), w, testScope(), []authz.SlotBinding{ {ResourceType: "crm_company", ResourceID: authz.TrustedObjectID("acme"), Permission: "contact_access"}, @@ -189,7 +295,7 @@ func TestGrantSlots_WritesTheDerivedID(t *testing.T) { id, err := authz.NewObjectID("https://github.com/acme/app", []string{"normalize_url", "spicedb_escape"}) require.NoError(t, err) - w := &recordingRelWriter{} + w := &pinningFake{} require.NoError(t, authz.GrantSlots(context.Background(), w, authz.SessionRef{Namespace: "default", Name: "demo-session"}, []authz.SlotBinding{{ResourceType: "git_repo", ResourceID: id, Permission: "push"}}, @@ -201,6 +307,198 @@ func TestGrantSlots_WritesTheDerivedID(t *testing.T) { assert.Equal(t, "slot_grant_push", w.wrote[0].Relation) } +// --- single-occupancy pinning gate --- + +// The first instance to arrive for a single-occupancy type pins the slot and +// grants THROUGH the pin, not through the plain write — the pinned write is +// what guards the grant against a concurrent move. +func TestGrantSlots_SingleOccupancy_FirstInstancePinsThenGrants(t *testing.T) { + w := &pinningFake{} + scope := testScope() + require.NoError(t, authz.GrantSlots(context.Background(), w, scope, + []authz.SlotBinding{{ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("acme/app"), Permission: "push"}}, + testExpiry())) + + assert.Equal(t, "acme/app", w.pins[pinKey(scope, "git_repo")], "the first instance must pin the slot") + + require.Len(t, w.pinned, 1, "a single-occupancy grant must route through WriteGrantsPinned") + assert.Equal(t, "acme/app", w.pinned[0].pinnedID, "the grant is written against the pinned id") + assert.Empty(t, w.plain, "nothing may take the unpinned plain path for a single-occupancy type") + require.Len(t, w.wrote, 1) + assert.Equal(t, "acme/app", w.wrote[0].ResourceID) + assert.Equal(t, authz.SlotGrantRelationName("push"), w.wrote[0].Relation) +} + +// A second permission on the SAME pinned instance is not drift — it binds, and +// the pin is untouched. (Seeded pin == the slot already filled by this instance.) +func TestGrantSlots_SingleOccupancy_SameInstanceSecondPermissionBinds(t *testing.T) { + w := &pinningFake{} + scope := testScope() + w.seedPin(scope, "git_repo", "acme/app") + + require.NoError(t, authz.GrantSlots(context.Background(), w, scope, + []authz.SlotBinding{{ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("acme/app"), Permission: "read"}}, + testExpiry())) + + require.Len(t, w.wrote, 1, "a second permission on the same pinned instance still binds") + assert.Equal(t, authz.SlotGrantRelationName("read"), w.wrote[0].Relation) + assert.Equal(t, "acme/app", w.pins[pinKey(scope, "git_repo")], "the pin is unchanged") +} + +// A bind to a DIFFERENT instance of a filled single-occupancy slot is refused, +// errors.Is-ably, naming the pinned instance — and the route out depends on the +// slot's rebind mode. These two message texts are the spec's, implemented and +// asserted here. +func TestGrantSlots_SingleOccupancy_DifferentInstanceRefused(t *testing.T) { + scope := testScope() + cases := []struct { + name string + rebind string + wantMsg string + }{ + {name: "rebind=approval: refusal routes to a plan amendment", rebind: "approval", wantMsg: "plan"}, + {name: "rebind unset: defaults to the approval routing", rebind: "", wantMsg: "plan"}, + {name: "rebind=never: refusal routes to a new session", rebind: "never", wantMsg: "new session"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &pinningFake{} + w.seedPin(scope, "git_repo", "acme/app") // pinned to a DIFFERENT instance + + err := authz.GrantSlots(context.Background(), w, scope, + []authz.SlotBinding{{ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("other/repo"), Permission: "push", Rebind: tc.rebind}}, + testExpiry()) + + require.Error(t, err) + assert.ErrorIs(t, err, authz.ErrSlotPinned) + assert.Contains(t, err.Error(), "acme/app", "the refusal must name the pinned instance") + assert.Contains(t, err.Error(), tc.wantMsg) + assert.Empty(t, w.wrote, "a refused rebind must grant nothing") + assert.Equal(t, "acme/app", w.pins[pinKey(scope, "git_repo")], "the pin must not move") + }) + } +} + +// Two distinct instances of ONE single-occupancy type in one call cannot be +// reconciled to a single occupant: that type is refused, pinning and granting +// nothing — but a DIFFERENT type's binding in the same call still lands. This is +// the per-type partition: one refused type must not strand another's grant. +func TestGrantSlots_SingleOccupancy_MultiArrivalRefusedButOtherTypesLand(t *testing.T) { + w := &pinningFake{} + scope := testScope() + err := authz.GrantSlots(context.Background(), w, scope, []authz.SlotBinding{ + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("acme/app"), Permission: "push"}, + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("acme/other"), Permission: "push"}, + {ResourceType: "crm_company", ResourceID: authz.TrustedObjectID("acme"), Permission: "contact_access"}, + }, testExpiry()) + + require.Error(t, err) + assert.ErrorIs(t, err, authz.ErrSlotPinned) + assert.Contains(t, err.Error(), "git_repo", "the refusal must name the over-subscribed type") + + _, pinned := w.pins[pinKey(scope, "git_repo")] + assert.False(t, pinned, "a multi-arrival must pin nothing for that type") + for _, r := range w.wrote { + assert.NotEqual(t, "git_repo", r.ResourceType, "nothing may be granted for the refused type") + } + + require.Len(t, w.wrote, 1, "the independent type's grant must survive the refusal") + assert.Equal(t, "crm_company", w.wrote[0].ResourceType) + assert.Equal(t, "acme", w.pins[pinKey(scope, "crm_company")], "and it pins on its own") +} + +// Two DISTINCT single-occupancy types, each already pinned to a different +// instance, are BOTH refused in one call — and both refusals must surface. +// Keeping only the first (the pre-errors.Join behaviour) would tell the caller +// about one blocked type and leave the second to be rediscovered by trying it. +// errors.Join keeps errors.Is(ErrSlotPinned) reachable and both sentences +// present. +func TestGrantSlots_SingleOccupancy_EveryRefusalSurfaces(t *testing.T) { + w := &pinningFake{} + scope := testScope() + w.seedPin(scope, "git_repo", "acme/app") + w.seedPin(scope, "crm_company", "acme") + + err := authz.GrantSlots(context.Background(), w, scope, []authz.SlotBinding{ + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("other/repo"), Permission: "push"}, + {ResourceType: "crm_company", ResourceID: authz.TrustedObjectID("globex"), Permission: "contact_access"}, + }, testExpiry()) + + require.Error(t, err) + assert.ErrorIs(t, err, authz.ErrSlotPinned, "errors.Join must keep the sentinel reachable across both refusals") + assert.Contains(t, err.Error(), "git_repo:acme/app", "the FIRST type's refusal must be present") + assert.Contains(t, err.Error(), "crm_company:acme", "the SECOND type's refusal must not be dropped") + assert.Empty(t, w.wrote, "both types refused; nothing is granted") +} + +// A pin refusal AND a failed plain (multi) write in the SAME call must BOTH +// surface: the write error must join the pending refusal, not replace it. Before +// errors.Join the plain-write error path returned immediately and discarded a +// refusal already accumulated for another type. +func TestGrantSlots_RefusalAndPlainWriteErrorBothSurface(t *testing.T) { + w := &pinningFake{writeErr: errors.New("spicedb unavailable")} + scope := testScope() + w.seedPin(scope, "git_repo", "acme/app") // single type pinned elsewhere → refused, no write attempted + + err := authz.GrantSlots(context.Background(), w, scope, []authz.SlotBinding{ + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("other/repo"), Permission: "push"}, + {ResourceType: "label", ResourceID: authz.TrustedObjectID("bug"), Permission: "apply", Occupancy: "multi"}, + }, testExpiry()) + + require.Error(t, err) + assert.ErrorIs(t, err, authz.ErrSlotPinned, "the pin refusal must not be swallowed by the plain-write error") + assert.Contains(t, err.Error(), "spicedb unavailable", "the plain-write error must also surface") + assert.Contains(t, err.Error(), "git_repo:acme/app", "the refusal text must still be present alongside the write error") +} + +// A STORE failure on one single-occupancy type must not abort the call: the +// multi-occupancy write still lands and the failure still surfaces. Returning +// on the first EnsurePin error skipped every type after it — grants a human may +// already have approved. +func TestGrantSlots_PinStoreErrorDoesNotStrandOtherTypes(t *testing.T) { + w := &pinningFake{ensureErr: errors.New("spicedb unavailable")} + scope := testScope() + + err := authz.GrantSlots(context.Background(), w, scope, []authz.SlotBinding{ + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("acme/app"), Permission: "push"}, + {ResourceType: "label", ResourceID: authz.TrustedObjectID("bug"), Permission: "apply", Occupancy: "multi"}, + }, testExpiry()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "ensure pin for git_repo:acme/app", "the store failure must surface") + assert.NotErrorIs(t, err, authz.ErrSlotPinned, "a store failure is not a pin refusal") + require.Len(t, w.plain, 1, "the multi-occupancy type after it must still be written") + assert.Equal(t, "label", w.plain[0].ResourceType) +} + +// A plain RelWriter that is not a SlotPinner cannot express the pin's +// precondition. The gate refuses the single-occupancy type rather than writing +// the grant through unpinned as if it were unmarked. recordingRelWriter is the +// deliberately-non-pinner writer this case needs. +func TestGrantSlots_SingleOccupancy_NonPinnerWriterRefusesWithoutWriting(t *testing.T) { + w := &recordingRelWriter{} + err := authz.GrantSlots(context.Background(), w, testScope(), + []authz.SlotBinding{{ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("acme/app"), Permission: "push"}}, + testExpiry()) + require.Error(t, err, "a single-occupancy type cannot bind through a writer that is not a SlotPinner") + assert.Empty(t, w.wrote, "nothing may be written when the pin cannot be expressed") +} + +// A multi-occupancy slot binds a SET: every addition lands through the plain +// batched write, and NO pin is written. This is today's behavior, unchanged. +func TestGrantSlots_MultiOccupancy_BindsAsetWithoutPinning(t *testing.T) { + w := &pinningFake{} + require.NoError(t, authz.GrantSlots(context.Background(), w, testScope(), []authz.SlotBinding{ + {ResourceType: "label", ResourceID: authz.TrustedObjectID("bug"), Permission: "apply", Occupancy: "multi"}, + {ResourceType: "label", ResourceID: authz.TrustedObjectID("urgent"), Permission: "apply", Occupancy: "multi"}, + }, testExpiry())) + + require.Len(t, w.wrote, 2, "a multi-occupancy slot binds a set; each addition lands") + assert.Empty(t, w.pinned, "multi occupancy must NOT route through the pinned write") + assert.Empty(t, w.pins, "multi occupancy writes no pin") + assert.Len(t, w.plain, 2, "multi grants take the plain batched write") +} + // SlotGrantExpiry anchors to the session's WALL-CLOCK cap, and a session that // declares none must still get a bounded grant — zero is not "forever". func TestSlotGrantExpiry(t *testing.T) { @@ -213,14 +511,16 @@ func TestSlotGrantExpiry(t *testing.T) { "a negative cap is nonsense and must not produce a past expiry") } -// fakeSlotCopier records what a fork carried. +// fakeSlotCopier records what a fork carried. held/pins are the parent's +// state the fake hands back; copied is every relation CopySlotGrants asked it +// to write, verbatim — the single seam the gate-bypassing copy writes through. type fakeSlotCopier struct { - held []authz.SlotBinding - listErr error - grantErr error - grantedTo string - granted []authz.SlotBinding - expiry time.Time + held []authz.SlotBinding + pins []authz.Relation + listErr error + pinsErr error + copyErr error + copied []authz.Relation } func (f *fakeSlotCopier) ListSlotGrants(_ context.Context, ns, name string) ([]authz.SlotBinding, error) { @@ -230,13 +530,18 @@ func (f *fakeSlotCopier) ListSlotGrants(_ context.Context, ns, name string) ([]a return f.held, nil } -func (f *fakeSlotCopier) GrantSlots(_ context.Context, ns, name string, b []authz.SlotBinding, exp time.Time) error { - if f.grantErr != nil { - return f.grantErr +func (f *fakeSlotCopier) ListSlotPins(_ context.Context, ns, name string) ([]authz.Relation, error) { + if f.pinsErr != nil { + return nil, f.pinsErr + } + return f.pins, nil +} + +func (f *fakeSlotCopier) CopySlotTuples(_ context.Context, rels []authz.Relation) error { + if f.copyErr != nil { + return f.copyErr } - f.grantedTo = ns + "/" + name - f.granted = append(f.granted, b...) - f.expiry = exp + f.copied = append(f.copied, rels...) return nil } @@ -252,10 +557,13 @@ func TestCopySlotGrants_RePointsTheParentsGrantsAtTheChild(t *testing.T) { n, err := authz.CopySlotGrants(context.Background(), c, parent, childRef, exp) require.NoError(t, err) assert.Equal(t, 2, n) - assert.Equal(t, "ns/child", c.grantedTo, - "a grant names the SESSION as subject, so a continuation inherits nothing without re-pointing") - assert.ElementsMatch(t, c.held, c.granted) - assert.Equal(t, exp, c.expiry, "the child's grants get the child's expiry, not the parent's remaining time") + require.Len(t, c.copied, 2) + for _, rel := range c.copied { + assert.Equal(t, "ns/child", rel.SubjectID, + "a grant names the SESSION as subject, so a continuation inherits nothing without re-pointing") + assert.Equal(t, exp, rel.ExpiresAt, "the child's grants get the child's expiry, not the parent's remaining time") + } + assert.ElementsMatch(t, []string{"acme", "hash-1"}, []string{c.copied[0].ResourceID, c.copied[1].ResourceID}) } func TestCopySlotGrants_NoGrantsAndNilCopierAreNoOps(t *testing.T) { @@ -270,25 +578,101 @@ func TestCopySlotGrants_NoGrantsAndNilCopierAreNoOps(t *testing.T) { n, err = authz.CopySlotGrants(context.Background(), empty, parent, childRef, testExpiry()) require.NoError(t, err) assert.Zero(t, n) - assert.Empty(t, empty.granted, "nothing held means nothing written") + assert.Empty(t, empty.copied, "nothing held and no pin means nothing written") } -func TestCopySlotGrants_SurfacesBothFailures(t *testing.T) { +// TestCopySlotGrants_CopiesThePinVerbatim is brief step 1(a): a parent holding +// a pin must leave the child pinned to the SAME instance — copied byte for +// byte, re-targeted only at the subject, never re-derived from GrantSlots. +func TestCopySlotGrants_CopiesThePinVerbatim(t *testing.T) { parent := authz.SessionRef{Namespace: "ns", Name: "parent"} childRef := authz.SessionRef{Namespace: "ns", Name: "child"} + exp := testExpiry() - listFail := &fakeSlotCopier{listErr: errors.New("list boom")} - _, err := authz.CopySlotGrants(context.Background(), listFail, parent, childRef, testExpiry()) - require.Error(t, err) - assert.Contains(t, err.Error(), "list boom") + c := &fakeSlotCopier{ + held: []authz.SlotBinding{ + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("acme/app"), Permission: "push"}, + }, + pins: []authz.Relation{authz.SlotPinRelation("git_repo", "acme/app", parent)}, + } - grantFail := &fakeSlotCopier{ - held: []authz.SlotBinding{{ResourceType: "crm_company", ResourceID: authz.TrustedObjectID("acme"), Permission: "contact_access"}}, - grantErr: errors.New("grant boom"), + n, err := authz.CopySlotGrants(context.Background(), c, parent, childRef, exp) + require.NoError(t, err) + assert.Equal(t, 1, n) + require.Len(t, c.copied, 2, "the grant and the pin both copy in the same call") + + var gotPin, gotGrant *authz.Relation + for i := range c.copied { + rel := &c.copied[i] + if rel.Relation == authz.SlotPinRelationName { + gotPin = rel + } else { + gotGrant = rel + } + } + require.NotNil(t, gotPin, "the pin must be among the copied tuples") + require.NotNil(t, gotGrant, "the grant must be among the copied tuples") + + assert.Equal(t, "acme/app", gotPin.ResourceID) + assert.Equal(t, childRef.String(), gotPin.SubjectID, "the pin moves to the CHILD, verbatim, bypassing the gate") + assert.True(t, gotPin.ExpiresAt.IsZero(), "a pin never expires") + + assert.Equal(t, "acme/app", gotGrant.ResourceID) + assert.Equal(t, childRef.String(), gotGrant.SubjectID) + assert.Equal(t, exp, gotGrant.ExpiresAt, "the grant keeps the caller-stamped child expiry") +} + +// TestCopySlotGrants_TwoInstancesOfOneTypeCopyVerbatimWithoutGating is brief +// step 1(b): a parent holding TWO instances of one type — a multi slot's +// restart, or a legacy session recorded before single-occupancy pinning +// existed — must copy both grants and pin nothing. Routing this through +// GrantSlots instead would refuse the whole type (ErrSlotPinned: a +// single-occupancy type cannot hold 2 distinct instances), because a tuple +// read back from SpiceDB carries no Occupancy for the gate to read as "multi". +// That refusal, on a lifecycle clone rather than a new arrival, is the bug +// this verbatim copy fixes. +func TestCopySlotGrants_TwoInstancesOfOneTypeCopyVerbatimWithoutGating(t *testing.T) { + parent := authz.SessionRef{Namespace: "ns", Name: "parent"} + childRef := authz.SessionRef{Namespace: "ns", Name: "child"} + exp := testExpiry() + + c := &fakeSlotCopier{held: []authz.SlotBinding{ + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("acme/app"), Permission: "push"}, + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("acme/other"), Permission: "push"}, + }} + + n, err := authz.CopySlotGrants(context.Background(), c, parent, childRef, exp) + require.NoError(t, err, "a verbatim copy must not re-gate occupancy") + assert.Equal(t, 2, n) + require.Len(t, c.copied, 2) + for _, rel := range c.copied { + assert.NotEqual(t, authz.SlotPinRelationName, rel.Relation, "two legacy/multi instances must pin nothing") + assert.Equal(t, childRef.String(), rel.SubjectID) + } + assert.ElementsMatch(t, []string{"acme/app", "acme/other"}, []string{c.copied[0].ResourceID, c.copied[1].ResourceID}) +} + +func TestCopySlotGrants_SurfacesEveryFailure(t *testing.T) { + parent := authz.SessionRef{Namespace: "ns", Name: "parent"} + childRef := authz.SessionRef{Namespace: "ns", Name: "child"} + binding := []authz.SlotBinding{{ResourceType: "crm_company", ResourceID: authz.TrustedObjectID("acme"), Permission: "contact_access"}} + + cases := []struct { + name string + copier *fakeSlotCopier + wantMsg string + }{ + {name: "listing the parent's grants fails", copier: &fakeSlotCopier{listErr: errors.New("list boom")}, wantMsg: "list boom"}, + {name: "listing the parent's pins fails", copier: &fakeSlotCopier{held: binding, pinsErr: errors.New("pins boom")}, wantMsg: "pins boom"}, + {name: "the copy write fails", copier: &fakeSlotCopier{held: binding, copyErr: errors.New("copy boom")}, wantMsg: "copy boom"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := authz.CopySlotGrants(context.Background(), tc.copier, parent, childRef, testExpiry()) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantMsg) + }) } - _, err = authz.CopySlotGrants(context.Background(), grantFail, parent, childRef, testExpiry()) - require.Error(t, err) - assert.Contains(t, err.Error(), "grant boom") } // fakeRevokeChecker drives the two standing legs independently. diff --git a/pkg/authz/slot_pin.go b/pkg/authz/slot_pin.go new file mode 100644 index 00000000..885d6963 --- /dev/null +++ b/pkg/authz/slot_pin.go @@ -0,0 +1,93 @@ +package authz + +import ( + "context" + "errors" +) + +// SlotPinRelationName is the relation a single-occupancy slot writes to record +// the one instance that occupies it for a session's life. The tuple points +// resource -> session, the same direction as slot_grant: +// +// :#slot_pin@agentsession:/ +// +// Unlike slot_grant, it carries NO expiration: a grant carries authority and +// must expire; the pin carries the identity of the commitment and must not. +const SlotPinRelationName = "slot_pin" + +// SlotPinRelation builds the pin tuple binding one instance to one session. +func SlotPinRelation(resourceType, resourceID string, scope SessionRef) Relation { + return Relation{ + ResourceType: resourceType, + ResourceID: resourceID, + Relation: SlotPinRelationName, + SubjectType: "agentsession", + SubjectID: scope.String(), + } +} + +// ErrSlotPinned marks a refusal to bind a DIFFERENT instance to a filled +// single-occupancy slot. errors.Is-able so a caller can tell this +// authorization ruling from a store failure. Messages are user-visible — +// they become text the model reads — so they name the pinned instance and +// the route out. +var ErrSlotPinned = errors.New("slot already pinned to a different instance") + +// ErrSlotMoveDrifted marks an approved pin move whose MUST_MATCH failed: the +// pin no longer names the instance the card showed as displaced. Nothing moved +// and nothing was revoked; re-approving rebuilds the card from the current pin. +// Always wrapped together with ErrSlotPinned, so a caller that only knows the +// older sentinel still sees a pin refusal. +var ErrSlotMoveDrifted = errors.New("approved slot move found the pin had changed since the card was shown") + +// ErrSlotMoveCommitted marks a BindApproved failure that happened AFTER an +// approved pin move landed: the pin now names the new instance and the +// displaced instance's grants are revoked, but the new grant was not written. +// errors.Is-able so the approval path can tell the approver what actually +// changed rather than reporting that nothing was granted. +var ErrSlotMoveCommitted = errors.New("approved slot move committed but the new grant was not written") + +// SlotPinner is the precondition-capable surface the single-occupancy gate +// needs and plain RelWriter cannot express. A non-nil RelWriter that does not +// also implement it cannot bind a single-occupancy slot: the gate refuses +// rather than writing through as if unmarked. +type SlotPinner interface { + // EnsurePin atomically writes the pin for resourceID unless the session + // is already pinned to some instance of resourceType. + // held=false -> this call wrote the pin. + // held=true, pinnedID==resourceID -> already pinned to the SAME instance. + // held=true, pinnedID!=resourceID -> pinned to a DIFFERENT instance. + // The conflict read is fully consistent and fails closed (error) if it + // observes anything but exactly one pin. + EnsurePin(ctx context.Context, resourceType, resourceID string, scope SessionRef) (held bool, pinnedID string, err error) + + // WriteGrantsPinned writes rels in one RPC guarded by MUST_MATCH that + // pinnedID is still the pinned instance of resourceType for this session. + // This is what stops a same-instance bind racing an approved move from + // resurrecting the revoked instance's authority. A failed precondition + // returns an error wrapping ErrSlotPinned. + WriteGrantsPinned(ctx context.Context, rels []Relation, resourceType, pinnedID string, scope SessionRef) error + + // MovePin atomically repoints the pin fromID -> toID, guarded by + // MUST_MATCH that fromID is still pinned, deleting fromID's grant + // relations (revoke) in the same RPC. A failed precondition returns an + // error wrapping ErrSlotPinned. + MovePin(ctx context.Context, resourceType, fromID, toID string, revoke []Relation, scope SessionRef) error + + // ReadPin returns the currently pinned instance for resourceType, or "" + // when none. Advisory (card rendering, PriorID derivation): the MUST_MATCH + // preconditions above, not this read, are what make writes safe. + ReadPin(ctx context.Context, resourceType string, scope SessionRef) (string, error) + + // ListGrantsFor returns the session's slot-grant tuples on ONE instance — + // every slot_grant_ relation naming resourceType:resourceID with + // this session as subject — so a MovePin can revoke exactly the prior + // instance's grants in the same RPC that repoints the pin. Returned as the + // tuples themselves (read back, not rebuilt from a permission list), which + // is what keeps the revoke set the instance's ACTUAL grants regardless of + // which permissions the moving approval happens to name — the leak that a + // permission-list reconstruction would reopen when the two instances' grant + // sets differ. Returns no tuples (not an error) when the instance holds + // none. + ListGrantsFor(ctx context.Context, resourceType, resourceID string, scope SessionRef) ([]Relation, error) +} diff --git a/pkg/authz/slotspec/slotspec.go b/pkg/authz/slotspec/slotspec.go index 0b79f331..1789cc05 100644 --- a/pkg/authz/slotspec/slotspec.go +++ b/pkg/authz/slotspec/slotspec.go @@ -69,6 +69,11 @@ func FromSlots(slots []spiceboxv1alpha1.AuthzSlot, transforms map[string][]strin AutoFillArgs: afArgs, ValueTransforms: transforms[et.ResourceType], Requires: requires, + // Occupancy and Rebind are plain spec fields on AuthzSlot (not + // status-derived like ValueTransforms), so they are copied straight + // off the slot. Empty reads as single downstream. + Occupancy: et.Occupancy, + Rebind: et.Rebind, }) } return out, nil diff --git a/pkg/authz/slotspec/slotspec_test.go b/pkg/authz/slotspec/slotspec_test.go index bd903187..6494794d 100644 --- a/pkg/authz/slotspec/slotspec_test.go +++ b/pkg/authz/slotspec/slotspec_test.go @@ -45,6 +45,31 @@ func TestFromSlots_CarriesRequiresThrough(t *testing.T) { assert.Equal(t, "the head of this pull request lives on a fork", got[0].Requires[0].RefusalMessage) } +// TestFromSlots_CarriesOccupancyAndRebindThrough is the same silent-drop hazard +// as Requires: occupancy/rebind are plain spec fields on AuthzSlot, and a +// conversion that dropped them would hand pkg/authz a binding whose empty +// Occupancy reads as single — so a multi slot would be gated as single, its +// second instance refused, with nothing red. The assertion is on the spec the +// binder consumes, not merely on the call returning. +func TestFromSlots_CarriesOccupancyAndRebindThrough(t *testing.T) { + slots := []spiceboxv1alpha1.AuthzSlot{ + {ResourceType: "label", Permission: "apply", Occupancy: "multi", Rebind: "approval"}, + {ResourceType: "git_repo", Permission: "push", Occupancy: "single", Rebind: "never"}, + {ResourceType: "crm_company", Permission: "contact_access"}, // both unset + } + + got, err := slotspec.FromSlots(slots, nil) + require.NoError(t, err) + require.Len(t, got, 3) + + assert.Equal(t, "multi", got[0].Occupancy, "a multi slot's occupancy must reach the binder") + assert.Equal(t, "approval", got[0].Rebind) + assert.Equal(t, "single", got[1].Occupancy) + assert.Equal(t, "never", got[1].Rebind, "rebind must reach the gate, which quotes it in the refusal route") + assert.Empty(t, got[2].Occupancy, "unset stays empty — which pkg/authz reads as single downstream") + assert.Empty(t, got[2].Rebind) +} + // TestFromSlots_CarriesApproversThrough is the same silent-drop hazard as the // program and the two messages: compileRequires is the ONE seam that copies a // SlotPrecondition into the precondition.Rule pkg/authz gates on, and a field diff --git a/pkg/authz/spicedb/integration_test.go b/pkg/authz/spicedb/integration_test.go index 1bd92dab..51628ad5 100644 --- a/pkg/authz/spicedb/integration_test.go +++ b/pkg/authz/spicedb/integration_test.go @@ -566,6 +566,7 @@ const slotSchemaFragment = ` definition demo_target { relation slot_grant_reachable: agentsession with expiration relation slot_grant_mutate: agentsession with expiration + relation slot_pin: agentsession relation owner: user permission reachable = slot_grant_reachable->interact + owner permission mutate = slot_grant_mutate->interact + owner @@ -573,6 +574,7 @@ definition demo_target { definition demo_widget { relation slot_grant_usable: agentsession with expiration + relation slot_pin: agentsession relation owner: user permission usable = slot_grant_usable->interact + owner } @@ -780,9 +782,12 @@ func TestIntegration_RevokeSlot_IsSurgicalAndImmediate(t *testing.T) { require.NoError(t, c.TouchInteractParticipantUser(ctx, ns, sessName, member), "TouchInteractParticipantUser") sess := authz.SessionRef{Namespace: ns, Name: sessName} + // Occupancy multi: surgical revocation is only interesting when the session + // holds MORE THAN ONE instance of a type, which single-occupancy pinning + // forbids by construction. This is the explicit multi-set case. require.NoError(t, authz.GrantSlots(ctx, c.Relations(), sess, []authz.SlotBinding{ - {ResourceType: "demo_target", ResourceID: authz.TrustedObjectID(keepID), Permission: "reachable"}, - {ResourceType: "demo_target", ResourceID: authz.TrustedObjectID(dropID), Permission: "reachable"}, + {ResourceType: "demo_target", ResourceID: authz.TrustedObjectID(keepID), Permission: "reachable", Occupancy: "multi"}, + {ResourceType: "demo_target", ResourceID: authz.TrustedObjectID(dropID), Permission: "reachable", Occupancy: "multi"}, }, time.Now().Add(time.Hour)), "GrantSlots") reach := func(id string) bool { diff --git a/pkg/authz/spicedb/slot_grants.go b/pkg/authz/spicedb/slot_grants.go index be3b9cfe..f279965b 100644 --- a/pkg/authz/spicedb/slot_grants.go +++ b/pkg/authz/spicedb/slot_grants.go @@ -47,6 +47,13 @@ func isSlotGrantRelation(rel string) bool { return strings.HasPrefix(rel, authz.SlotGrantRelationPrefix) } +// isSlotPinRelation reports whether a relation name is the slot pin relation. +// Unlike slot grants there is only one name to match — a pin is per TYPE, not +// per permission. +func isSlotPinRelation(rel string) bool { + return rel == authz.SlotPinRelationName +} + // permissionOfSlotGrant recovers the permission a slot-grant relation carries. func permissionOfSlotGrant(rel string) string { return strings.TrimPrefix(rel, authz.SlotGrantRelationPrefix) @@ -95,31 +102,120 @@ func (c *Client) ListSlotGrants(ctx context.Context, ns, name string) ([]authz.S return out, nil } -// DeleteSlotGrants removes every slot grant held by this session. +// ListSlotPins returns every pin held by this session, verbatim. +// +// Mirrors ListSlotGrants's read shape exactly — one subject-only RPC, filtered +// client-side, for the same reason: a resource-type/relation filter cannot +// express "a slot_pin tuple naming agentsession:/ as its subject" +// any more precisely than the grant sweep can express its per-permission +// relation names server-side. Here the client-side predicate is +// isSlotPinRelation instead of the slot_grant_ prefix. +// +// Returned as plain Relation, not SlotBinding: a pin carries no permission and +// no occupancy to recover — it is the tuple itself that CopySlotGrants needs +// to copy byte-for-byte, re-targeting only the subject. +// +// Fully consistent, for the same reason as ListSlotGrants: a stale read here +// either hides a pin a write just moved or surfaces one that no longer holds. +func (c *Client) ListSlotPins(ctx context.Context, ns, name string) ([]authz.Relation, error) { + stream, err := c.cl.ReadRelationships(ctx, &v1.ReadRelationshipsRequest{ + Consistency: &v1.Consistency{Requirement: &v1.Consistency_FullyConsistent{FullyConsistent: true}}, + RelationshipFilter: slotGrantsOfSession(ns, name), + }) + if err != nil { + return nil, fmt.Errorf("read slot pins %s/%s: %w", ns, name, err) + } + var out []authz.Relation + for { + msg, rerr := stream.Recv() + if errors.Is(rerr, io.EOF) { + break + } + if rerr != nil { + return nil, fmt.Errorf("read slot pins stream %s/%s: %w", ns, name, rerr) + } + rel := msg.GetRelationship() + if !isSlotPinRelation(rel.GetRelation()) { + // The session is the subject of other relations too (slot grants, + // the agentsession relations themselves); only the pin belongs here. + continue + } + res := rel.GetResource() + subj := rel.GetSubject().GetObject() + out = append(out, authz.Relation{ + ResourceType: res.GetObjectType(), + ResourceID: res.GetObjectId(), + Relation: rel.GetRelation(), + SubjectType: subj.GetObjectType(), + SubjectID: subj.GetObjectId(), + }) + } + return out, nil +} + +// CopySlotTuples writes rels verbatim in one batched TOUCH — no gate, and no +// expiry stamping of its own. The caller (authz.CopySlotGrants) has already +// re-targeted every relation's SubjectID at the child and, for a grant, +// stamped the child's expiry; a pin tuple carries none, exactly as EnsurePin +// writes it, so this never adds one. +func (c *Client) CopySlotTuples(ctx context.Context, rels []authz.Relation) error { + if err := c.Relations().WriteRelationships(ctx, rels); err != nil { + return fmt.Errorf("copy slot tuples: %w", err) + } + return nil +} + +// DeleteSlotGrants removes every slot grant AND pin held by this session. // // The reciprocal of DeleteAgentSessionRelationships, and NOT covered by it: a -// slot grant has the session as its SUBJECT, while that call filters -// agentsession as the RESOURCE. Without this, every session that bound an -// instance leaves live authority behind on an external resource — the +// slot grant (and a pin) has the session as its SUBJECT, while that call +// filters agentsession as the RESOURCE. Without this, every session that bound +// an instance leaves live authority behind on an external resource — the // AgentSession CR is retained after completion, so slot_grant->interact keeps // resolving indefinitely. // -// Idempotent: succeeds when the session holds no grants. +// Pins are included deliberately: unlike a grant, a pin carries no +// expiration, so a dropped pin-delete here is not a grant that simply lapses +// later — it is a PERMANENT leak. A session name that is reused after this one +// tears down would otherwise inherit a pin it never earned and be refused its +// own first bind (see the admission sweep in pkg/controllers/agentsession, +// which calls this same method for exactly that leftover). +// +// Two delete calls, not one: the grant delete routes through authz.RevokeSlots +// so the grant tuple is built by the sanctioned SlotGrantRelation call site +// (see slot_grant_guard_test.go — a hand-built slot_grant_/slot_pin tuple +// outside GrantSlots/RevokeSlots/the pinner trips that guard), while pins are +// already fully-built Relations straight from ListSlotPins and need no +// builder call at all. Both failures are returned, never swallowed — a +// dropped pin delete is the permanent leak above. +// +// Idempotent: succeeds when the session holds no grants or pins. func (c *Client) DeleteSlotGrants(ctx context.Context, ns, name string) error { // Listed then deleted, rather than swept by one filter. A subject-only // filter would also match the session's NON-slot relations, and // per-permission relation names cannot be expressed as one server-side - // filter — so the read is what identifies the slot grants, and the delete - // names exactly those. + // filter — so the read is what identifies the slot grants and pins, and the + // delete names exactly those. held, err := c.ListSlotGrants(ctx, ns, name) if err != nil { return fmt.Errorf("delete slot grants %s/%s: %w", ns, name, err) } - if len(held) == 0 { + pins, err := c.ListSlotPins(ctx, ns, name) + if err != nil { + return fmt.Errorf("delete slot grants %s/%s: list pins: %w", ns, name, err) + } + if len(held) == 0 && len(pins) == 0 { return nil } - if err := authz.RevokeSlots(ctx, c.Relations(), authz.SessionRef{Namespace: ns, Name: name}, held); err != nil { - return fmt.Errorf("delete slot grants %s/%s: %w", ns, name, err) + if len(held) > 0 { + if err := authz.RevokeSlots(ctx, c.Relations(), authz.SessionRef{Namespace: ns, Name: name}, held); err != nil { + return fmt.Errorf("delete slot grants %s/%s: %w", ns, name, err) + } + } + if len(pins) > 0 { + if err := c.Relations().DeleteRelationships(ctx, pins); err != nil { + return fmt.Errorf("delete slot grants %s/%s: delete pins: %w", ns, name, err) + } } return nil } @@ -185,18 +281,32 @@ func (w *RelationWriter) WriteRelationships(ctx context.Context, rels []authz.Re if err != nil { return fmt.Errorf("write relationships: %w", err) } - // Advance the caller's freshness floor to this write. Only after success, and - // only with a real token — an empty one would be a no-op the callee ignores - // anyway, but not calling keeps the contract ("onWrite means a durable - // write") honest. - if w.onWrite != nil { - if tok := resp.GetWrittenAt().GetToken(); tok != "" { - w.onWrite(rels, tok) - } - } + w.advanceFloor(rels, resp) return nil } +// advanceFloor invokes the freshness-floor hook for a SUCCESSFUL write, when one +// is wired. Nil-safe, and only fires with a real WrittenAt token — an empty one +// is a no-op the callee ignores anyway, but not calling keeps the contract +// ("onWrite means a durable write") honest. +// +// Shared by EVERY write on this RelationWriter that must advance the floor — +// the plain batched WriteRelationships here AND the pinned-grant / move writes +// in slot_pin.go — so a single-occupancy grant cannot silently skip the +// read-your-writes floor advance the plain path gets (the approve-then-stale- +// denied race the floor closes applies to pinned grants exactly as it does to +// unpinned ones). The one write that deliberately does NOT call this is +// EnsurePin: nothing ever Checks the slot_pin relation, so there is no floor a +// later check could need advanced. +func (w *RelationWriter) advanceFloor(rels []authz.Relation, resp *v1.WriteRelationshipsResponse) { + if w.onWrite == nil { + return + } + if tok := resp.GetWrittenAt().GetToken(); tok != "" { + w.onWrite(rels, tok) + } +} + // DeleteRelationships removes exactly the named tuples in one atomic RPC. // // Per-tuple OPERATION_DELETE rather than a filter sweep: the caller named the @@ -239,3 +349,7 @@ func relationshipFor(r authz.Relation) *v1.Relationship { } var _ authz.RelWriter = (*RelationWriter)(nil) + +// *Client is the fork path's SlotGrantCopier: ListSlotGrants + ListSlotPins + +// CopySlotTuples. +var _ authz.SlotGrantCopier = (*Client)(nil) diff --git a/pkg/authz/spicedb/slot_grants_test.go b/pkg/authz/spicedb/slot_grants_test.go new file mode 100644 index 00000000..3c12d51f --- /dev/null +++ b/pkg/authz/spicedb/slot_grants_test.go @@ -0,0 +1,181 @@ +package spicedb + +// Coverage for the slot-grant/slot-pin client surface added alongside the +// verbatim lifecycle copy (slot_grants.go): ListSlotPins's client-side filter, +// CopySlotTuples's plain batched TOUCH, and DeleteSlotGrants's inclusion of +// pins in teardown. Uses the same in-process gRPC PermissionsService double as +// writer_test.go and slot_pin_test.go (newRecordingWriterClient) rather than +// mocking the delegate. + +import ( + "context" + "testing" + "time" + + v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/authz" +) + +// grantTuple builds a slot_grant_ relationship as +// ReadRelationships would stream it back, for scripting +// recordingWriteServer.readRels. Mirrors slot_pin_test.go's pinTuple. +func grantTuple(resourceType, resourceID, permission string, scope authz.SessionRef) *v1.Relationship { + return &v1.Relationship{ + Resource: &v1.ObjectReference{ObjectType: resourceType, ObjectId: resourceID}, + Relation: authz.SlotGrantRelationName(permission), + Subject: &v1.SubjectReference{Object: &v1.ObjectReference{ObjectType: "agentsession", ObjectId: scope.String()}}, + } +} + +// TestListSlotPins_FiltersOutGrantsAndOtherSubjectRelations: the subject-only +// filter (slotGrantsOfSession) returns every relation naming this session as +// subject, so ListSlotPins must keep only the slot_pin tuple and drop the +// slot_grant_* ones client-side — the mirror image of ListSlotGrants dropping +// the pin. +func TestListSlotPins_FiltersOutGrantsAndOtherSubjectRelations(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + c := &Client{cl: cl} + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + srv.readRels = []*v1.Relationship{ + grantTuple("crm_company", "acme", "contact_access", scope), + pinTuple("git_repo", "acme/app", scope), + } + + pins, err := c.ListSlotPins(context.Background(), "ns", "s") + require.NoError(t, err) + require.Len(t, pins, 1, "the grant must be filtered out") + assert.Equal(t, "git_repo", pins[0].ResourceType) + assert.Equal(t, "acme/app", pins[0].ResourceID) + assert.Equal(t, authz.SlotPinRelationName, pins[0].Relation) + assert.Equal(t, "agentsession", pins[0].SubjectType) + assert.Equal(t, "ns/s", pins[0].SubjectID) + + readReq := srv.lastRead() + require.NotNil(t, readReq) + _, fullyConsistent := readReq.GetConsistency().GetRequirement().(*v1.Consistency_FullyConsistent) + assert.True(t, fullyConsistent, "a stale read here would hide a pin a write just moved") +} + +// TestListSlotPins_NoneHeldReturnsEmpty is the ordinary case: a session with +// no single-occupancy slot has nothing to filter down to. +func TestListSlotPins_NoneHeldReturnsEmpty(t *testing.T) { + cl, _ := newRecordingWriterClient(t) + c := &Client{cl: cl} + + pins, err := c.ListSlotPins(context.Background(), "ns", "s") + require.NoError(t, err) + assert.Empty(t, pins) +} + +// TestCopySlotTuples_WritesEveryRelationAsOneBatchedTouch: CopySlotTuples +// itself applies no gate and stamps no expiry — it trusts the caller +// (authz.CopySlotGrants) to have already re-targeted the subject and, for a +// grant, stamped the child's expiry — so it must simply TOUCH every relation +// handed to it, verbatim, in one RPC. +func TestCopySlotTuples_WritesEveryRelationAsOneBatchedTouch(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + c := &Client{cl: cl} + exp := time.Unix(1700000000, 0).UTC() + rels := []authz.Relation{ + {ResourceType: "git_repo", ResourceID: "acme/app", Relation: authz.SlotPinRelationName, + SubjectType: "agentsession", SubjectID: "ns/child"}, + {ResourceType: "git_repo", ResourceID: "acme/app", Relation: "slot_grant_push", + SubjectType: "agentsession", SubjectID: "ns/child", ExpiresAt: exp}, + } + + require.NoError(t, c.CopySlotTuples(context.Background(), rels)) + + writes := srv.recordedWrites() + require.Len(t, writes, 1, "every relation copies in ONE batched RPC") + require.Len(t, writes[0].Updates, 2) + for _, u := range writes[0].Updates { + assert.Equal(t, v1.RelationshipUpdate_OPERATION_TOUCH, u.Operation) + } + + var sawPin, sawGrant bool + for _, u := range writes[0].Updates { + switch u.Relationship.GetRelation() { + case authz.SlotPinRelationName: + sawPin = true + assert.Nil(t, u.Relationship.OptionalExpiresAt, "a pin carries no expiry, even when copied") + case "slot_grant_push": + sawGrant = true + require.NotNil(t, u.Relationship.OptionalExpiresAt, "a grant keeps the caller-stamped expiry") + assert.Equal(t, exp.Unix(), u.Relationship.OptionalExpiresAt.AsTime().Unix()) + } + } + assert.True(t, sawPin) + assert.True(t, sawGrant) +} + +// TestDeleteSlotGrants_RemovesPinsAlongsideGrants is brief step 1(c): the +// teardown sweep must remove the pin, not just the grant. A dropped pin +// delete is a PERMANENT leak — pins carry no expiration — so this asserts the +// delete traffic actually names the pin relation, not merely that the call +// returns success. +func TestDeleteSlotGrants_RemovesPinsAlongsideGrants(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + c := &Client{cl: cl} + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + srv.readRels = []*v1.Relationship{ + grantTuple("crm_company", "acme", "contact_access", scope), + pinTuple("git_repo", "acme/app", scope), + } + + require.NoError(t, c.DeleteSlotGrants(context.Background(), "ns", "s")) + + var sawGrantDelete, sawPinDelete bool + for _, w := range srv.recordedWrites() { + for _, u := range w.Updates { + assert.Equal(t, v1.RelationshipUpdate_OPERATION_DELETE, u.Operation, + "every update issued by DeleteSlotGrants must be a delete") + switch u.Relationship.GetRelation() { + case "slot_grant_contact_access": + sawGrantDelete = true + assert.Equal(t, "acme", u.Relationship.GetResource().GetObjectId()) + case authz.SlotPinRelationName: + sawPinDelete = true + assert.Equal(t, "acme/app", u.Relationship.GetResource().GetObjectId()) + } + } + } + assert.True(t, sawGrantDelete, "the grant must still be deleted") + assert.True(t, sawPinDelete, + "the pin relation must be in the delete traffic — a dropped pin delete never expires and is a permanent leak") +} + +// TestDeleteSlotGrants_NoGrantsOrPinsIsANoOp: a session that never bound an +// instance must not issue any write traffic on teardown. +func TestDeleteSlotGrants_NoGrantsOrPinsIsANoOp(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + c := &Client{cl: cl} + + require.NoError(t, c.DeleteSlotGrants(context.Background(), "ns", "s")) + assert.Empty(t, srv.recordedWrites(), "nothing held and no pin means nothing to delete") +} + +// TestDeleteSlotGrants_PinOnlyStillDeletes covers a grant that already +// expired (and so no longer lists) while its pin — which never expires — +// remains. Teardown must still clean up the orphaned pin. +func TestDeleteSlotGrants_PinOnlyStillDeletes(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + c := &Client{cl: cl} + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + srv.readRels = []*v1.Relationship{pinTuple("git_repo", "acme/app", scope)} + + require.NoError(t, c.DeleteSlotGrants(context.Background(), "ns", "s")) + + var sawPinDelete bool + for _, w := range srv.recordedWrites() { + for _, u := range w.Updates { + if u.Relationship.GetRelation() == authz.SlotPinRelationName { + sawPinDelete = true + assert.Equal(t, v1.RelationshipUpdate_OPERATION_DELETE, u.Operation) + } + } + } + assert.True(t, sawPinDelete, "an orphaned pin with no live grant must still be deleted") +} diff --git a/pkg/authz/spicedb/slot_pin.go b/pkg/authz/spicedb/slot_pin.go new file mode 100644 index 00000000..e3ad1d03 --- /dev/null +++ b/pkg/authz/spicedb/slot_pin.go @@ -0,0 +1,290 @@ +package spicedb + +// RelationWriter's implementation of authz.SlotPinner — the +// precondition-capable surface the single-occupancy gate (authz.GrantSlots) +// needs and plain WriteRelationships cannot express: an atomic "first one in +// wins" bind, a grant write that refuses to land if the pin moved out from +// under it, and an atomic move that repoints the pin and revokes the old +// instance's grants in one RPC. +// +// EnsurePin deliberately bypasses RelationWriter.onWrite, the freshness-floor +// hook: it writes only the slot_pin tuple, and nothing ever Checks that +// relation (see authz.SlotPinRelation — it exists to be matched by this file's +// own MUST_MATCH / MUST_NOT_MATCH preconditions, not by a permission Check), so +// there is no floor for a later check to need advanced. +// +// WriteGrantsPinned and MovePin are the opposite case and DO advance the floor +// (via RelationWriter.advanceFloor), because they write — or revoke — the +// slot_grant_* tuples a permission Check DOES resolve. A single-occupancy grant +// that skipped the floor would reopen the approve-then-stale-denied race the +// floor exists to close, for the DEFAULT bind path, exactly as an unpinned grant +// written through plain WriteRelationships would. + +import ( + "context" + "errors" + "fmt" + "io" + + v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/authzed/openagentprimitives/pkg/authz" +) + +// pinSubjectType mirrors slotGrantSubjectType (slot_grants.go): the pin's +// subject, like the grant's, is always the session. +const pinSubjectType = "agentsession" + +// pinFilter builds the RelationshipFilter matching slot_pin tuples for +// resourceType held by scope. resourceID empty matches ANY instance of the +// type (used by EnsurePin's MUST_NOT_MATCH and the conflict/advisory read); +// non-empty narrows to exactly one (used by the MUST_MATCH guards). +func pinFilter(resourceType, resourceID string, scope authz.SessionRef) *v1.RelationshipFilter { + return &v1.RelationshipFilter{ + ResourceType: resourceType, + OptionalResourceId: resourceID, + OptionalRelation: authz.SlotPinRelationName, + OptionalSubjectFilter: &v1.SubjectFilter{ + SubjectType: pinSubjectType, + OptionalSubjectId: scope.String(), + }, + } +} + +// mustMatchPin is the MUST_MATCH precondition that resourceType:pinnedID is +// still the pin agentsession: holds. Shared by WriteGrantsPinned and +// MovePin: both guard against the pin having moved since the caller last +// read it. +func mustMatchPin(resourceType, pinnedID string, scope authz.SessionRef) *v1.Precondition { + return &v1.Precondition{ + Operation: v1.Precondition_OPERATION_MUST_MATCH, + Filter: pinFilter(resourceType, pinnedID, scope), + } +} + +// EnsurePin implements authz.SlotPinner. It issues one TOUCH of the pin +// tuple, guarded by a MUST_NOT_MATCH precondition that the session holds no +// slot_pin on ANY instance of resourceType yet (resource id left unset on +// the filter — "no instance", not "not this one"). SpiceDB evaluates the +// precondition and the write atomically, so whichever of two racing binds +// reaches SpiceDB first wins the slot and the other is refused by the +// server, not by a read-then-write race in this process. +func (w *RelationWriter) EnsurePin(ctx context.Context, resourceType, resourceID string, scope authz.SessionRef) (held bool, pinnedID string, err error) { + pin := authz.SlotPinRelation(resourceType, resourceID, scope) + _, err = w.c.cl.WriteRelationships(ctx, &v1.WriteRelationshipsRequest{ + Updates: []*v1.RelationshipUpdate{{ + Operation: v1.RelationshipUpdate_OPERATION_TOUCH, + Relationship: relationshipFor(pin), + }}, + OptionalPreconditions: []*v1.Precondition{{ + Operation: v1.Precondition_OPERATION_MUST_NOT_MATCH, + Filter: pinFilter(resourceType, "", scope), + }}, + }) + if err == nil { + return false, "", nil + } + if status.Code(err) != codes.FailedPrecondition { + return false, "", fmt.Errorf("ensure pin %s:%s for %s: %w", resourceType, resourceID, scope, err) + } + + // The precondition fired: some instance is already pinned. Read back + // which one, fully consistent — this process must resolve exactly what + // SpiceDB just told it is there, not an eventually-consistent guess. + found, rerr := w.ReadPin(ctx, resourceType, scope) + if rerr != nil { + return false, "", fmt.Errorf("ensure pin %s:%s for %s: read back pinned instance: %w", resourceType, resourceID, scope, rerr) + } + if found == "" { + // A reported conflict with no pin found on read-back is a state this + // code cannot explain (the schema says single-occupancy; the server + // just said something matched). Fail closed rather than silently + // report "unpinned" on the strength of a result that contradicts the + // write that produced it. + return false, "", fmt.Errorf("ensure pin %s:%s for %s: write reported a conflicting pin but the fully-consistent read found none", resourceType, resourceID, scope) + } + return true, found, nil +} + +// WriteGrantsPinned implements authz.SlotPinner. rels are TOUCHed in the +// same RPC as a MUST_MATCH precondition that resourceType:pinnedID is still +// the session's pin — the guard that stops a same-instance bind racing an +// approved MovePin from resurrecting the revoked instance's authority after +// the fact: if the pin moved between this caller deciding pinnedID and this +// write landing, the precondition fails and nothing is granted. +func (w *RelationWriter) WriteGrantsPinned(ctx context.Context, rels []authz.Relation, resourceType, pinnedID string, scope authz.SessionRef) error { + if len(rels) == 0 { + return nil + } + updates := make([]*v1.RelationshipUpdate, 0, len(rels)) + for _, r := range rels { + updates = append(updates, &v1.RelationshipUpdate{ + Operation: v1.RelationshipUpdate_OPERATION_TOUCH, + Relationship: relationshipFor(r), + }) + } + resp, err := w.c.cl.WriteRelationships(ctx, &v1.WriteRelationshipsRequest{ + Updates: updates, + OptionalPreconditions: []*v1.Precondition{mustMatchPin(resourceType, pinnedID, scope)}, + }) + if err == nil { + // Advance the session's freshness floor to this grant write, keyed by the + // granted rels' own resources, so the ToolCallAuthz check that immediately + // follows an in-process pinned grant reads at-least-as-fresh as the grant + // — the same read-your-writes guarantee the plain WriteRelationships path + // gives an unpinned grant. + w.advanceFloor(rels, resp) + return nil + } + if status.Code(err) == codes.FailedPrecondition { + return fmt.Errorf("write grants for %s:%s pinned by %s: %w", resourceType, pinnedID, scope, authz.ErrSlotPinned) + } + return fmt.Errorf("write grants for %s:%s pinned by %s: %w", resourceType, pinnedID, scope, err) +} + +// MovePin implements authz.SlotPinner. One RPC: a MUST_MATCH precondition +// that fromID is still pinned, a DELETE of the fromID pin, a TOUCH of the +// toID pin, and a DELETE of every relation in revoke — atomic, so an +// approved move either fully lands (old pin gone, new pin held, old grants +// revoked) or changes nothing at all. +func (w *RelationWriter) MovePin(ctx context.Context, resourceType, fromID, toID string, revoke []authz.Relation, scope authz.SessionRef) error { + fromPin := authz.SlotPinRelation(resourceType, fromID, scope) + toPin := authz.SlotPinRelation(resourceType, toID, scope) + updates := []*v1.RelationshipUpdate{ + { + Operation: v1.RelationshipUpdate_OPERATION_DELETE, + Relationship: relationshipFor(fromPin), + }, + { + Operation: v1.RelationshipUpdate_OPERATION_TOUCH, + Relationship: relationshipFor(toPin), + }, + } + for _, r := range revoke { + updates = append(updates, &v1.RelationshipUpdate{ + Operation: v1.RelationshipUpdate_OPERATION_DELETE, + Relationship: relationshipFor(r), + }) + } + resp, err := w.c.cl.WriteRelationships(ctx, &v1.WriteRelationshipsRequest{ + Updates: updates, + OptionalPreconditions: []*v1.Precondition{mustMatchPin(resourceType, fromID, scope)}, + }) + if err == nil { + // Advance the session's freshness floor for every resource this move + // touched: the new and old pin instances, and each revoked grant's + // resource. A Check on the displaced instance right after the move must + // read at-least-as-fresh as the revoke (its grants are gone), and one on + // the new instance must see the pin flip — the same read-your-writes floor + // WriteGrantsPinned advances for the grant it then writes on toID. The pin + // relation itself is never Checked, but its resource id IS the instance a + // later grant Check keys on, so advancing the floor for it is harmless and + // keeps the affected-resource set complete. + floored := make([]authz.Relation, 0, len(revoke)+2) + floored = append(floored, toPin, fromPin) + floored = append(floored, revoke...) + w.advanceFloor(floored, resp) + return nil + } + if status.Code(err) == codes.FailedPrecondition { + return fmt.Errorf("move pin %s:%s -> %s:%s for %s: %w", resourceType, fromID, resourceType, toID, scope, authz.ErrSlotPinned) + } + return fmt.Errorf("move pin %s:%s -> %s:%s for %s: %w", resourceType, fromID, resourceType, toID, scope, err) +} + +// ReadPin implements authz.SlotPinner. Fully consistent, since both of its +// callers need the current truth: EnsurePin's post-conflict resolution, and +// any caller using this directly for advisory purposes (card rendering, +// PriorID derivation) that would rather pay the consistency cost than render +// a pin that was just moved. Returns ("", nil) for zero pins — that is the +// ordinary "nothing bound yet" answer here, unlike EnsurePin's stricter +// zero-after-conflict case. Two or more pins is never valid for a +// single-occupancy slot; rather than pick one, this fails closed. +func (w *RelationWriter) ReadPin(ctx context.Context, resourceType string, scope authz.SessionRef) (string, error) { + stream, err := w.c.cl.ReadRelationships(ctx, &v1.ReadRelationshipsRequest{ + Consistency: &v1.Consistency{Requirement: &v1.Consistency_FullyConsistent{FullyConsistent: true}}, + RelationshipFilter: pinFilter(resourceType, "", scope), + }) + if err != nil { + return "", fmt.Errorf("read pin %s for %s: %w", resourceType, scope, err) + } + var ids []string + for { + msg, rerr := stream.Recv() + if errors.Is(rerr, io.EOF) { + break + } + if rerr != nil { + return "", fmt.Errorf("read pin %s for %s: %w", resourceType, scope, rerr) + } + ids = append(ids, msg.GetRelationship().GetResource().GetObjectId()) + } + switch len(ids) { + case 0: + return "", nil + case 1: + return ids[0], nil + default: + return "", fmt.Errorf("read pin %s for %s: found %d pins, expected at most one: %v", resourceType, scope, len(ids), ids) + } +} + +// ListGrantsFor implements authz.SlotPinner. It reads back the session's +// slot-grant tuples on ONE instance — a server-side filter on +// (resourceType, resourceID, subject=agentsession:), narrowed +// client-side to the slot_grant_ relations the way ListSlotGrants/ListSlotPins +// narrow their own subject-only sweeps (a per-permission relation name cannot +// be expressed server-side). The tuples are returned verbatim, not rebuilt +// from a permission list, so the caller revokes exactly what the instance +// holds. +// +// Fully consistent, for the same reason ReadPin is: its one caller is about to +// repoint a single-occupancy pin and revoke the displaced instance's grants, +// and a stale read here would either leave a just-written grant behind or +// delete one already gone. +func (w *RelationWriter) ListGrantsFor(ctx context.Context, resourceType, resourceID string, scope authz.SessionRef) ([]authz.Relation, error) { + stream, err := w.c.cl.ReadRelationships(ctx, &v1.ReadRelationshipsRequest{ + Consistency: &v1.Consistency{Requirement: &v1.Consistency_FullyConsistent{FullyConsistent: true}}, + RelationshipFilter: &v1.RelationshipFilter{ + ResourceType: resourceType, + OptionalResourceId: resourceID, + OptionalSubjectFilter: &v1.SubjectFilter{ + SubjectType: pinSubjectType, + OptionalSubjectId: scope.String(), + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("list grants for %s:%s pinned by %s: %w", resourceType, resourceID, scope, err) + } + var out []authz.Relation + for { + msg, rerr := stream.Recv() + if errors.Is(rerr, io.EOF) { + break + } + if rerr != nil { + return nil, fmt.Errorf("list grants for %s:%s pinned by %s: %w", resourceType, resourceID, scope, rerr) + } + rel := msg.GetRelationship() + if !isSlotGrantRelation(rel.GetRelation()) { + // The session is the subject of the instance's other relations too; + // only the per-permission slot grants are revocable here. + continue + } + res := rel.GetResource() + subj := rel.GetSubject().GetObject() + out = append(out, authz.Relation{ + ResourceType: res.GetObjectType(), + ResourceID: res.GetObjectId(), + Relation: rel.GetRelation(), + SubjectType: subj.GetObjectType(), + SubjectID: subj.GetObjectId(), + }) + } + return out, nil +} + +var _ authz.SlotPinner = (*RelationWriter)(nil) diff --git a/pkg/authz/spicedb/slot_pin_test.go b/pkg/authz/spicedb/slot_pin_test.go new file mode 100644 index 00000000..fb06a6c7 --- /dev/null +++ b/pkg/authz/spicedb/slot_pin_test.go @@ -0,0 +1,416 @@ +package spicedb + +// Coverage for RelationWriter's authz.SlotPinner implementation +// (slot_pin.go): EnsurePin's atomic first-bind, the fully-consistent +// conflict read, WriteGrantsPinned's MUST_MATCH guard, and MovePin's single +// atomic RPC. Uses the same in-process gRPC PermissionsService double as +// writer_test.go and slot_grants_floor_test.go — newRecordingWriterClient, +// extended there with a scriptable write error and a server-streaming +// ReadRelationships — rather than mocking the delegate. + +import ( + "context" + "testing" + + v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/authzed/openagentprimitives/pkg/authz" +) + +// pinTuple builds a slot_pin relationship as ReadRelationships would stream +// it back, for scripting recordingWriteServer.readRels. +func pinTuple(resourceType, resourceID string, scope authz.SessionRef) *v1.Relationship { + return &v1.Relationship{ + Resource: &v1.ObjectReference{ObjectType: resourceType, ObjectId: resourceID}, + Relation: authz.SlotPinRelationName, + Subject: &v1.SubjectReference{Object: &v1.ObjectReference{ObjectType: "agentsession", ObjectId: scope.String()}}, + } +} + +// TestEnsurePin_FirstBindAttachesMustNotMatchPrecondition pins the shape of +// the write a fresh bind issues: one TOUCH, guarded by a MUST_NOT_MATCH +// precondition that the session holds no slot_pin on ANY instance of the +// resource type yet (resource id unset — "any instance", not "this one"). +func TestEnsurePin_FirstBindAttachesMustNotMatchPrecondition(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + w := &RelationWriter{c: &Client{cl: cl}} + + held, pinned, err := w.EnsurePin(context.Background(), "git_repo", "repoA", + authz.SessionRef{Namespace: "ns", Name: "s"}) + require.NoError(t, err) + assert.False(t, held, "first bind: nothing was already pinned") + assert.Empty(t, pinned) + + req := srv.lastWrite() + require.NotNil(t, req, "the delegate must have received the write") + require.Len(t, req.OptionalPreconditions, 1) + pc := req.OptionalPreconditions[0] + assert.Equal(t, v1.Precondition_OPERATION_MUST_NOT_MATCH, pc.Operation) + assert.Equal(t, "git_repo", pc.Filter.ResourceType) + assert.Equal(t, "slot_pin", pc.Filter.OptionalRelation) + assert.Empty(t, pc.Filter.OptionalResourceId, "must match ANY instance of the type") + assert.Equal(t, "agentsession", pc.Filter.OptionalSubjectFilter.SubjectType) + assert.Equal(t, "ns/s", pc.Filter.OptionalSubjectFilter.OptionalSubjectId) + + require.Len(t, req.Updates, 1) + assert.Equal(t, v1.RelationshipUpdate_OPERATION_TOUCH, req.Updates[0].Operation) + assert.Equal(t, "repoA", req.Updates[0].Relationship.GetResource().GetObjectId()) + assert.Equal(t, "slot_pin", req.Updates[0].Relationship.GetRelation()) +} + +// TestEnsurePin_ConflictReadsBackPinnedInstance_FullyConsistent scripts a +// FAILED_PRECONDITION on the write (the MUST_NOT_MATCH fired — something is +// already pinned) and one pin tuple on the read. EnsurePin must read back +// which instance, fully consistently, and report it rather than erroring. +func TestEnsurePin_ConflictReadsBackPinnedInstance_FullyConsistent(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + srv.writeErr = status.Error(codes.FailedPrecondition, "already pinned") + srv.readRels = []*v1.Relationship{pinTuple("git_repo", "repoA", scope)} + w := &RelationWriter{c: &Client{cl: cl}} + + held, pinned, err := w.EnsurePin(context.Background(), "git_repo", "repoB", scope) + require.NoError(t, err) + assert.True(t, held) + assert.Equal(t, "repoA", pinned) + + readReq := srv.lastRead() + require.NotNil(t, readReq, "the conflict must trigger a read") + _, fullyConsistent := readReq.GetConsistency().GetRequirement().(*v1.Consistency_FullyConsistent) + assert.True(t, fullyConsistent, "the conflict read must be fully consistent") +} + +// TestEnsurePin_TwoPinsFailsClosed scripts the same write conflict but TWO +// pin tuples on the read-back — a state that should never occur, since the +// slot is single-occupancy. EnsurePin must refuse to guess which one is +// real and fail closed with an error. +func TestEnsurePin_TwoPinsFailsClosed(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + srv.writeErr = status.Error(codes.FailedPrecondition, "already pinned") + srv.readRels = []*v1.Relationship{ + pinTuple("git_repo", "repoA", scope), + pinTuple("git_repo", "repoB", scope), + } + w := &RelationWriter{c: &Client{cl: cl}} + + _, _, err := w.EnsurePin(context.Background(), "git_repo", "repoC", scope) + assert.Error(t, err, "two pins observed for a single-occupancy slot must never be resolved by guessing") +} + +// TestEnsurePin_ZeroPinsOnConflictFailsClosed covers the other "anything but +// exactly one" case: the write reported a conflict but the fully-consistent +// read-back found nothing. That combination should be impossible under a +// correct schema; EnsurePin must not silently report "unpinned" when the +// server just told it otherwise. +func TestEnsurePin_ZeroPinsOnConflictFailsClosed(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + srv.writeErr = status.Error(codes.FailedPrecondition, "already pinned") + // srv.readRels left empty. + w := &RelationWriter{c: &Client{cl: cl}} + + _, _, err := w.EnsurePin(context.Background(), "git_repo", "repoA", scope) + assert.Error(t, err, "a reported conflict with no pin found on read-back must fail closed, not report unpinned") +} + +// TestEnsurePin_NonPreconditionErrorIsReturnedVerbatim guards that an +// unrelated backend failure (not a precondition conflict) is surfaced as an +// error rather than being mistaken for "already pinned". +func TestEnsurePin_NonPreconditionErrorIsReturnedVerbatim(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + srv.writeErr = status.Error(codes.Unavailable, "backend down") + w := &RelationWriter{c: &Client{cl: cl}} + + _, _, err := w.EnsurePin(context.Background(), "git_repo", "repoA", authz.SessionRef{Namespace: "ns", Name: "s"}) + require.Error(t, err) + assert.False(t, status.Code(err) == codes.OK) +} + +// TestWriteGrantsPinned_AttachesMustMatchOnThePinnedInstance pins the shape +// of the guarded grant write: the grant TOUCHes plus a MUST_MATCH +// precondition naming exactly :#slot_pin@agentsession:. +func TestWriteGrantsPinned_AttachesMustMatchOnThePinnedInstance(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + w := &RelationWriter{c: &Client{cl: cl}} + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + grantA := authz.Relation{ + ResourceType: "git_repo", ResourceID: "repoA", Relation: "slot_grant_read", + SubjectType: "agentsession", SubjectID: scope.String(), + } + + err := w.WriteGrantsPinned(context.Background(), []authz.Relation{grantA}, "git_repo", "repoA", scope) + require.NoError(t, err) + + req := srv.lastWrite() + require.NotNil(t, req) + require.Len(t, req.OptionalPreconditions, 1) + pc := req.OptionalPreconditions[0] + assert.Equal(t, v1.Precondition_OPERATION_MUST_MATCH, pc.Operation) + assert.Equal(t, "git_repo", pc.Filter.ResourceType) + assert.Equal(t, "repoA", pc.Filter.OptionalResourceId) + assert.Equal(t, "slot_pin", pc.Filter.OptionalRelation) + assert.Equal(t, "agentsession", pc.Filter.OptionalSubjectFilter.SubjectType) + assert.Equal(t, "ns/s", pc.Filter.OptionalSubjectFilter.OptionalSubjectId) + + require.Len(t, req.Updates, 1) + assert.Equal(t, v1.RelationshipUpdate_OPERATION_TOUCH, req.Updates[0].Operation) + assert.Equal(t, "slot_grant_read", req.Updates[0].Relationship.GetRelation()) +} + +// TestWriteGrantsPinned_FailedPreconditionWrapsErrSlotPinned is the "same +// instance bind races an approved move" case: the pin moved out from under +// this write between read and write, SpiceDB reports FAILED_PRECONDITION, +// and the caller must get back an errors.Is-able authz.ErrSlotPinned rather +// than a bare status error. +func TestWriteGrantsPinned_FailedPreconditionWrapsErrSlotPinned(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + srv.writeErr = status.Error(codes.FailedPrecondition, "pin moved") + w := &RelationWriter{c: &Client{cl: cl}} + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + grantA := authz.Relation{ + ResourceType: "git_repo", ResourceID: "repoA", Relation: "slot_grant_read", + SubjectType: "agentsession", SubjectID: scope.String(), + } + + err := w.WriteGrantsPinned(context.Background(), []authz.Relation{grantA}, "git_repo", "repoA", scope) + require.Error(t, err) + assert.ErrorIs(t, err, authz.ErrSlotPinned) +} + +// TestWriteGrantsPinned_OtherErrorIsNotMistakenForSlotPinned guards that a +// non-precondition failure is NOT wrapped as ErrSlotPinned — that sentinel +// means specifically "the pin moved", not "SpiceDB is unreachable". +func TestWriteGrantsPinned_OtherErrorIsNotMistakenForSlotPinned(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + srv.writeErr = status.Error(codes.Unavailable, "backend down") + w := &RelationWriter{c: &Client{cl: cl}} + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + + err := w.WriteGrantsPinned(context.Background(), []authz.Relation{{ + ResourceType: "git_repo", ResourceID: "repoA", Relation: "slot_grant_read", + SubjectType: "agentsession", SubjectID: scope.String(), + }}, "git_repo", "repoA", scope) + require.Error(t, err) + assert.NotErrorIs(t, err, authz.ErrSlotPinned) +} + +// TestMovePin_OneRPCWithMustMatchDeleteTouchAndRevoke pins the shape of an +// approved move: ONE atomic RPC carrying a MUST_MATCH precondition that +// fromID is still pinned, a DELETE of the fromID pin, a TOUCH of the toID +// pin, and a DELETE of every revoked grant relation. +func TestMovePin_OneRPCWithMustMatchDeleteTouchAndRevoke(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + w := &RelationWriter{c: &Client{cl: cl}} + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + grantA := authz.Relation{ + ResourceType: "git_repo", ResourceID: "repoA", Relation: "slot_grant_read", + SubjectType: "agentsession", SubjectID: scope.String(), + } + + err := w.MovePin(context.Background(), "git_repo", "repoA", "repoB", []authz.Relation{grantA}, scope) + require.NoError(t, err) + + require.Len(t, srv.recordedWrites(), 1, "one atomic RPC") + req := srv.lastWrite() + require.Len(t, req.OptionalPreconditions, 1) + pc := req.OptionalPreconditions[0] + assert.Equal(t, v1.Precondition_OPERATION_MUST_MATCH, pc.Operation) + assert.Equal(t, "git_repo", pc.Filter.ResourceType) + assert.Equal(t, "repoA", pc.Filter.OptionalResourceId) + assert.Equal(t, "slot_pin", pc.Filter.OptionalRelation) + assert.Equal(t, "agentsession", pc.Filter.OptionalSubjectFilter.SubjectType) + assert.Equal(t, "ns/s", pc.Filter.OptionalSubjectFilter.OptionalSubjectId) + + require.Len(t, req.Updates, 3) + + del1 := req.Updates[0] + assert.Equal(t, v1.RelationshipUpdate_OPERATION_DELETE, del1.Operation) + assert.Equal(t, "slot_pin", del1.Relationship.GetRelation()) + assert.Equal(t, "repoA", del1.Relationship.GetResource().GetObjectId()) + + touch := req.Updates[1] + assert.Equal(t, v1.RelationshipUpdate_OPERATION_TOUCH, touch.Operation) + assert.Equal(t, "slot_pin", touch.Relationship.GetRelation()) + assert.Equal(t, "repoB", touch.Relationship.GetResource().GetObjectId()) + + del2 := req.Updates[2] + assert.Equal(t, v1.RelationshipUpdate_OPERATION_DELETE, del2.Operation) + assert.Equal(t, "slot_grant_read", del2.Relationship.GetRelation()) + assert.Equal(t, "repoA", del2.Relationship.GetResource().GetObjectId()) +} + +// TestMovePin_FailedPreconditionWrapsErrSlotPinned: the fromID pin moved (or +// was never there) between the caller's decision and this write landing; +// SpiceDB's FAILED_PRECONDITION must surface as an errors.Is-able +// authz.ErrSlotPinned. +func TestMovePin_FailedPreconditionWrapsErrSlotPinned(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + srv.writeErr = status.Error(codes.FailedPrecondition, "pin already moved") + w := &RelationWriter{c: &Client{cl: cl}} + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + + err := w.MovePin(context.Background(), "git_repo", "repoA", "repoB", nil, scope) + require.Error(t, err) + assert.ErrorIs(t, err, authz.ErrSlotPinned) +} + +// TestListGrantsFor_ReturnsOnlyTheInstancesGrants_FullyConsistent: the read +// used to build a MovePin's revoke set must filter to this instance under this +// session, keep only the slot_grant_ relations (the pin and any other relation +// the session subjects are dropped), and read fully consistently. +func TestListGrantsFor_ReturnsOnlyTheInstancesGrants_FullyConsistent(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + srv.readRels = []*v1.Relationship{ + grantTuple("git_repo", "repoA", "push", scope), + grantTuple("git_repo", "repoA", "read", scope), + // A non-grant relation the session is also the subject of must be dropped. + pinTuple("git_repo", "repoA", scope), + } + w := &RelationWriter{c: &Client{cl: cl}} + + got, err := w.ListGrantsFor(context.Background(), "git_repo", "repoA", scope) + require.NoError(t, err) + require.Len(t, got, 2, "only the slot_grant_ relations are revocable here") + for _, r := range got { + assert.Equal(t, "git_repo", r.ResourceType) + assert.Equal(t, "repoA", r.ResourceID) + assert.Equal(t, "ns/s", r.SubjectID) + assert.True(t, r.Relation == "slot_grant_push" || r.Relation == "slot_grant_read", "got %q", r.Relation) + } + + readReq := srv.lastRead() + require.NotNil(t, readReq) + assert.Equal(t, "git_repo", readReq.RelationshipFilter.ResourceType) + assert.Equal(t, "repoA", readReq.RelationshipFilter.OptionalResourceId) + assert.Equal(t, "agentsession", readReq.RelationshipFilter.OptionalSubjectFilter.SubjectType) + assert.Equal(t, "ns/s", readReq.RelationshipFilter.OptionalSubjectFilter.OptionalSubjectId) + _, fullyConsistent := readReq.GetConsistency().GetRequirement().(*v1.Consistency_FullyConsistent) + assert.True(t, fullyConsistent, "the revoke-set read decides what authority to remove; it must be current") +} + +// TestReadPin_ReturnsEmptyStringWhenNotPinned: zero pin tuples is the +// ordinary "nothing bound yet" case, not a fail-closed error — ReadPin is +// advisory, unlike EnsurePin's post-conflict read. +func TestReadPin_ReturnsEmptyStringWhenNotPinned(t *testing.T) { + cl, _ := newRecordingWriterClient(t) + w := &RelationWriter{c: &Client{cl: cl}} + + id, err := w.ReadPin(context.Background(), "git_repo", authz.SessionRef{Namespace: "ns", Name: "s"}) + require.NoError(t, err) + assert.Empty(t, id) +} + +// TestReadPin_ReturnsThePinnedInstance_FullyConsistent is ReadPin's happy +// path, and asserts the same fully-consistent requirement EnsurePin's +// conflict read depends on. +func TestReadPin_ReturnsThePinnedInstance_FullyConsistent(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + srv.readRels = []*v1.Relationship{pinTuple("git_repo", "repoA", scope)} + w := &RelationWriter{c: &Client{cl: cl}} + + id, err := w.ReadPin(context.Background(), "git_repo", scope) + require.NoError(t, err) + assert.Equal(t, "repoA", id) + + readReq := srv.lastRead() + require.NotNil(t, readReq) + _, fullyConsistent := readReq.GetConsistency().GetRequirement().(*v1.Consistency_FullyConsistent) + assert.True(t, fullyConsistent) +} + +// TestReadPin_TwoPinsFailsClosed: an impossible state for a single-occupancy +// slot. ReadPin must refuse to guess rather than returning either ID. +func TestReadPin_TwoPinsFailsClosed(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + srv.readRels = []*v1.Relationship{ + pinTuple("git_repo", "repoA", scope), + pinTuple("git_repo", "repoB", scope), + } + w := &RelationWriter{c: &Client{cl: cl}} + + _, err := w.ReadPin(context.Background(), "git_repo", scope) + assert.Error(t, err) +} + +// TestWriteGrantsPinned_AdvancesFreshnessFloor: a single-occupancy grant must +// advance the session's read-your-writes floor exactly as the plain +// WriteRelationships path does — skipping it reopens the approve-then-stale- +// denied race for the DEFAULT bind path. The floor hook gets the write's +// WrittenAt token, keyed by the granted rels' own resources. +func TestWriteGrantsPinned_AdvancesFreshnessFloor(t *testing.T) { + cl, rec := newRecordingWriterClient(t) + rec.writtenAt = "zt-after-pinned-grant" + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + + var gotRels []authz.Relation + var gotTok string + w := &RelationWriter{c: &Client{cl: cl}, onWrite: func(rels []authz.Relation, token string) { + gotRels, gotTok = rels, token + }} + + grant := authz.Relation{ + ResourceType: "git_repo", ResourceID: "repoA", Relation: "slot_grant_push", + SubjectType: "agentsession", SubjectID: scope.String(), + } + require.NoError(t, w.WriteGrantsPinned(context.Background(), []authz.Relation{grant}, "git_repo", "repoA", scope)) + + assert.Equal(t, "zt-after-pinned-grant", gotTok, "the floor hook must get the pinned grant's WrittenAt token") + require.Len(t, gotRels, 1, "the floor hook must get the granted rels") + assert.Equal(t, "git_repo", gotRels[0].ResourceType) + assert.Equal(t, "repoA", gotRels[0].ResourceID) +} + +// TestMovePin_AdvancesFreshnessFloorForTouchedResources: a move must advance the +// floor for every resource it touched — the new and old pin instances and each +// revoked grant — so a Check on the displaced instance right after the move +// reads at-least-as-fresh as the revoke that stripped its grants. +func TestMovePin_AdvancesFreshnessFloorForTouchedResources(t *testing.T) { + cl, rec := newRecordingWriterClient(t) + rec.writtenAt = "zt-after-move" + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + + var gotRels []authz.Relation + var gotTok string + w := &RelationWriter{c: &Client{cl: cl}, onWrite: func(rels []authz.Relation, token string) { + gotRels, gotTok = rels, token + }} + + revoke := []authz.Relation{{ + ResourceType: "git_repo", ResourceID: "repoA", Relation: "slot_grant_push", + SubjectType: "agentsession", SubjectID: scope.String(), + }} + require.NoError(t, w.MovePin(context.Background(), "git_repo", "repoA", "repoB", revoke, scope)) + + assert.Equal(t, "zt-after-move", gotTok, "the floor hook must get the move's WrittenAt token") + ids := map[string]bool{} + for _, r := range gotRels { + ids[r.ResourceID] = true + } + assert.True(t, ids["repoA"], "the floor must advance for the displaced instance (old pin + revoked grant)") + assert.True(t, ids["repoB"], "the floor must advance for the new pin instance") +} + +// TestEnsurePin_DoesNotAdvanceFreshnessFloor pins the other half of the +// contract the file header states: EnsurePin writes only the slot_pin tuple, +// which nothing ever Checks, so it must NOT fire the floor hook (there is no +// floor a later check could need advanced). +func TestEnsurePin_DoesNotAdvanceFreshnessFloor(t *testing.T) { + cl, rec := newRecordingWriterClient(t) + rec.writtenAt = "zt-after-pin" + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + + called := false + w := &RelationWriter{c: &Client{cl: cl}, onWrite: func([]authz.Relation, string) { called = true }} + + _, _, err := w.EnsurePin(context.Background(), "git_repo", "repoA", scope) + require.NoError(t, err) + assert.False(t, called, "nothing Checks the slot_pin relation, so EnsurePin must not advance the floor") +} diff --git a/pkg/authz/spicedb/writer_test.go b/pkg/authz/spicedb/writer_test.go index ba147117..c9b566bc 100644 --- a/pkg/authz/spicedb/writer_test.go +++ b/pkg/authz/spicedb/writer_test.go @@ -46,13 +46,31 @@ type recordingWriteServer struct { // WrittenAt ZedToken — real SpiceDB always returns one, and the freshness // floor advance under test depends on capturing it. writtenAt string + // writeErr, when non-nil, is returned once from the next + // WriteRelationships call instead of a success response, then cleared. + // Scripts a precondition failure (or any other write error) for the + // slot-pin tests without needing a schema that can actually produce one. + // The request is still recorded before the error is returned — a real + // SpiceDB receives and evaluates the request before it refuses it. + writeErr error + // readRels, when set, is served back verbatim by every ReadRelationships + // call, regardless of the request's filter — the slot-pin tests script + // exactly the relationships they want read back rather than simulating + // SpiceDB's filter evaluation. + readRels []*v1.Relationship + readReqs []*v1.ReadRelationshipsRequest } func (s *recordingWriteServer) WriteRelationships(_ context.Context, req *v1.WriteRelationshipsRequest) (*v1.WriteRelationshipsResponse, error) { s.mu.Lock() s.writeReqs = append(s.writeReqs, req) tok := s.writtenAt + err := s.writeErr + s.writeErr = nil s.mu.Unlock() + if err != nil { + return nil, err + } resp := &v1.WriteRelationshipsResponse{} if tok != "" { resp.WrittenAt = &v1.ZedToken{Token: tok} @@ -60,6 +78,23 @@ func (s *recordingWriteServer) WriteRelationships(_ context.Context, req *v1.Wri return resp, nil } +// ReadRelationships is a server-streaming RPC: it serves back s.readRels +// unconditionally and records the request so a test can assert on its +// Consistency (the slot-pin conflict read must be fully consistent). +func (s *recordingWriteServer) ReadRelationships(req *v1.ReadRelationshipsRequest, stream v1.PermissionsService_ReadRelationshipsServer) error { + s.mu.Lock() + s.readReqs = append(s.readReqs, req) + rels := make([]*v1.Relationship, len(s.readRels)) + copy(rels, s.readRels) + s.mu.Unlock() + for _, rel := range rels { + if err := stream.Send(&v1.ReadRelationshipsResponse{Relationship: rel}); err != nil { + return err + } + } + return nil +} + func (s *recordingWriteServer) DeleteRelationships(_ context.Context, req *v1.DeleteRelationshipsRequest) (*v1.DeleteRelationshipsResponse, error) { s.mu.Lock() s.deleteReqs = append(s.deleteReqs, req) @@ -82,6 +117,28 @@ func (s *recordingWriteServer) recordedWrites() []*v1.WriteRelationshipsRequest return out } +// lastWrite returns the most recently received WriteRelationshipsRequest, or +// nil if none has arrived yet. +func (s *recordingWriteServer) lastWrite() *v1.WriteRelationshipsRequest { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.writeReqs) == 0 { + return nil + } + return s.writeReqs[len(s.writeReqs)-1] +} + +// lastRead returns the most recently received ReadRelationshipsRequest, or +// nil if none has arrived yet. +func (s *recordingWriteServer) lastRead() *v1.ReadRelationshipsRequest { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.readReqs) == 0 { + return nil + } + return s.readReqs[len(s.readReqs)-1] +} + func (s *recordingWriteServer) recordedChecks() []*v1.CheckBulkPermissionsRequest { s.mu.Lock() defer s.mu.Unlock() diff --git a/pkg/authz/threadseed.go b/pkg/authz/threadseed.go index 23d6272f..f6de4d99 100644 --- a/pkg/authz/threadseed.go +++ b/pkg/authz/threadseed.go @@ -39,6 +39,11 @@ type ThreadSeedRequest struct { ValueTransforms []string // AutoGrantFrom is the slot's declared policy. Empty means owner. AutoGrantFrom []string + // Occupancy and Rebind are the slot's single-vs-multi commitment and rebind + // policy, carried onto every SlotBinding this seed emits so the GrantSlots + // pinning gate sees them. Empty Occupancy reads as single. + Occupancy string + Rebind string } // SeedFromThread selects the instances a thread may bind without an approval. @@ -89,7 +94,7 @@ func SeedFromThread( // Skip: a grant on "" cannot be revoked by id later. continue } - b := SlotBinding{ResourceType: req.ResourceType, ResourceID: id, Permission: req.Permission} + b := SlotBinding{ResourceType: req.ResourceType, ResourceID: id, Permission: req.Permission, Occupancy: req.Occupancy, Rebind: req.Rebind} dedupKey := b.ResourceType + "\x00" + b.ResourceID.String() + "\x00" + b.Permission if _, dup := seen[dedupKey]; dup { continue diff --git a/pkg/authz/threadseed_test.go b/pkg/authz/threadseed_test.go index 8a76ba4b..e06c55fc 100644 --- a/pkg/authz/threadseed_test.go +++ b/pkg/authz/threadseed_test.go @@ -83,6 +83,34 @@ func TestSeedFromThread_TrustPolicyDecidesWhoseValuesBind(t *testing.T) { } } +// TestSeedFromThread_CarriesOccupancyAndRebindOntoBindings: a thread seed must +// stamp the slot's occupancy/rebind onto every binding it emits, or a multi +// slot's thread-seeded instances arrive at GrantSlots reading as single — the +// second one refused, with nothing red. Empty on the request stays empty on the +// binding (which pkg/authz reads as single downstream). +func TestSeedFromThread_CarriesOccupancyAndRebindOntoBindings(t *testing.T) { + msgs := []authz.ThreadMessage{msg("owner@example.com", "https://owner.example/a and https://owner.example/b")} + + t.Run("multi/approval propagate", func(t *testing.T) { + req := urlSlot(nil) + req.Occupancy, req.Rebind = "multi", "approval" + got, _ := authz.SeedFromThread(msgs, "owner@example.com", []authz.ThreadSeedRequest{req}, 0) + require.NotEmpty(t, got) + for _, b := range got { + assert.Equal(t, "multi", b.Occupancy, "the slot's occupancy must ride onto every seeded binding") + assert.Equal(t, "approval", b.Rebind) + } + }) + t.Run("unset stays empty (reads as single)", func(t *testing.T) { + got, _ := authz.SeedFromThread(msgs, "owner@example.com", []authz.ThreadSeedRequest{urlSlot(nil)}, 0) + require.NotEmpty(t, got) + for _, b := range got { + assert.Empty(t, b.Occupancy) + assert.Empty(t, b.Rebind) + } + }) +} + // An unattributable author is never trusted, even under participants: an // unsigned message is unknown provenance, not "some participant". func TestSeedFromThread_UnattributableAuthorNeverBinds(t *testing.T) { diff --git a/pkg/channels/channelevents/tool_approval_details.go b/pkg/channels/channelevents/tool_approval_details.go index f54f96b5..805cc607 100644 --- a/pkg/channels/channelevents/tool_approval_details.go +++ b/pkg/channels/channelevents/tool_approval_details.go @@ -38,6 +38,18 @@ type ToolApprovalDetails struct { // It is set by the runner, which reads the class's slot list at raise time; // channelsd has no other route to the answer at decision time. NoSlotGrant bool `json:"noSlotGrant,omitempty"` + // Occupancy and Rebind are the slot's single-vs-multi commitment and rebind + // policy, resolved from the class slot at REQUEST-RECORD time (the runner + // holds the class; the channelsd decision handler does not) and read back + // when the approval binds, so the grant GrantSlots writes is pinned exactly + // as a non-approval bind of the same slot would be. + // + // Empty Occupancy reads as single downstream, so a record raised before + // these fields existed binds gated rather than un-gated — the same + // fail-closed default the SlotBinding fields carry. Persisted on the durable + // memapproval record alongside the rest of this payload. + Occupancy string `json:"occupancy,omitempty"` + Rebind string `json:"rebind,omitempty"` // ToolName is the tool's wire name. Machine-readable identity: the visible // "Tool" field carries the tool's DESCRIPTION, because a wire name has no // business on a surface a person decides from — so anything matching a diff --git a/pkg/channels/channelinteractions/categories/notices.go b/pkg/channels/channelinteractions/categories/notices.go index f3a001a1..14daf428 100644 --- a/pkg/channels/channelinteractions/categories/notices.go +++ b/pkg/channels/channelinteractions/categories/notices.go @@ -213,6 +213,19 @@ const ( // here — being told what was skipped without being told one can ask for it // is the shape of a notice a reader can do nothing with. CompletionRequirementBypassed = "completion_requirement_bypassed" + + // SlotBindRefused — a target named in the conversation (a thread value a + // trusted author put there, or an instance a verified webhook delivery + // carried) was NOT bound, because this session is committed to a single + // target for that slot and a different one is already pinned. The work + // continues on the committed target. + // + // Degraded rather than housekeeping: a reader who assumes the new target + // took effect will be wrong, and the reader has a real move (start a fresh + // conversation for the other target). A NOTICE, not a prompt — the bind was + // already refused at mint time and on a triggered session there is nobody to + // approve a change, so there is nothing here to click. + SlotBindRefused = "slot_bind_refused" ) // registerNotices registers every notice category. Kept out of init so a test @@ -301,6 +314,12 @@ func registerNotices() { // rather than housekeeping precisely because a reader who assumes they // got the whole result will be wrong. {Name: CompletionRequirementBypassed, Tone: channelinteractions.ToneDegraded}, + + // A different target wasn't bound because the session is pinned to one + // already. Degraded for the same reason as the row above: a reader who + // assumes the new target took effect will be wrong, and they have a real + // next step. + {Name: SlotBindRefused, Tone: channelinteractions.ToneDegraded}, } for _, c := range notices { c.Notice = true diff --git a/pkg/channels/channelinteractions/categories/slot_bind_refused_test.go b/pkg/channels/channelinteractions/categories/slot_bind_refused_test.go new file mode 100644 index 00000000..2c2a8311 --- /dev/null +++ b/pkg/channels/channelinteractions/categories/slot_bind_refused_test.go @@ -0,0 +1,25 @@ +package categories + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/channels/channelinteractions" +) + +// TestSlotBindRefusedIsARegisteredNotice pins the row this feature adds: a +// mint-time pin refusal has a notice category to publish, it is a NOTICE (no +// decision leg, so it touches no approval routing or autoApprove list), and its +// degraded tone obliges a next step — a reader told a target was dropped must +// be told what they can do instead. +func TestSlotBindRefusedIsARegisteredNotice(t *testing.T) { + c, ok := channelinteractions.Get(SlotBindRefused) + require.True(t, ok, "SlotBindRefused is not registered") + assert.True(t, c.Notice, "SlotBindRefused must be a notice, not a prompt") + assert.Equal(t, channelinteractions.ToneDegraded, c.Tone, + "a dropped target is degraded: the reader who assumed it took effect is wrong") + assert.True(t, c.Tone.RequiresNextStep(), + "the reader must be told what to do about the target that was not added") +} diff --git a/pkg/channels/channelsd/e2e/e2e_test.go b/pkg/channels/channelsd/e2e/e2e_test.go index 70f799bf..510272a0 100644 --- a/pkg/channels/channelsd/e2e/e2e_test.go +++ b/pkg/channels/channelsd/e2e/e2e_test.go @@ -93,6 +93,10 @@ func (s *stubAuthz) GrantSlots(_ context.Context, _, _ string, _ []authz.SlotBin return nil } +// DeleteSlotGrants satisfies pipeline.Authz: the mint admission sweep. A fresh +// e2e session has no stale predecessor tuples, so a successful no-op is faithful. +func (s *stubAuthz) DeleteSlotGrants(_ context.Context, _, _ string) error { return nil } + // Relations satisfies pipeline.Authz. Nil is the documented safe branch: // authz.BindApproved narrows session_scope and logs rather than granting // when its writer is nil, and these e2e scenarios don't assert on the diff --git a/pkg/channels/channelsd/pipeline/adoption_slotseed_test.go b/pkg/channels/channelsd/pipeline/adoption_slotseed_test.go index 44fb3dde..a181e8b3 100644 --- a/pkg/channels/channelsd/pipeline/adoption_slotseed_test.go +++ b/pkg/channels/channelsd/pipeline/adoption_slotseed_test.go @@ -146,6 +146,35 @@ func TestAdoption_noSlotsSeedsNothing(t *testing.T) { assert.Empty(t, az.grantedSlots) } +// TestMint_sweepsStaleSlotTuplesBeforeBinding: a channelsd mint must sweep +// whatever a dead predecessor left under this ns/name BEFORE it writes its own +// mint-time binds — otherwise the operator's own sweep (now suppressed for a +// channelsd mint by the pre-stamped finalizer) raced those binds and could +// delete the legitimate authority they just wrote. The sweep (DeleteSlotGrants) +// must run strictly before the first GrantSlots, and the fresh bind must +// survive it. +func TestMint_sweepsStaleSlotTuplesBeforeBinding(t *testing.T) { + ch := newChannel("c1") + p, az, _, _, _ := newPipeline(t, ch, classWithThreadSeededSlot(nil)) + p.ReadHistory = func(_ context.Context, _ *spiceboxv1alpha1.Channel, _ string, _ channelkinds.ReadHistoryOpts) (channelkinds.HistoryPage, error) { + return threadWithLinks(), nil + } + // A stale binding a dead predecessor with this name left behind. The mint + // sweep must wipe it before the fresh thread-seed bind lands. + az.grantedSlots = []authz.SlotBinding{{ResourceType: "http_target", ResourceID: authz.TrustedObjectID("stale")}} + + deliverAdoptionMention(t, p, ch) + + assert.Equal(t, 1, az.deleteSlotGrantsCalls, "the mint must sweep exactly once before binding") + require.GreaterOrEqual(t, len(az.slotCallOrder), 2, "both the sweep and the bind must have run") + assert.Equal(t, "sweep", az.slotCallOrder[0], "the sweep must run BEFORE the first bind") + assert.Equal(t, "grant", az.slotCallOrder[1], "the bind follows the sweep") + + require.Len(t, az.grantedSlots, 1, "the stale binding was swept; only the fresh thread-seed bind survives") + assert.Equal(t, seededID(t, "https://ci.example/job/7"), az.grantedSlots[0].ResourceID, + "the surviving bind is the summoner's link, not the swept stale tuple") +} + // A failed grant write must not fail the inbound: without the grants the agent // simply has to ask, which is the same place it would have been anyway. func TestAdoption_seedGrantFailureStillRoutesTheMessage(t *testing.T) { diff --git a/pkg/channels/channelsd/pipeline/backfill.go b/pkg/channels/channelsd/pipeline/backfill.go index 47527991..2d89a006 100644 --- a/pkg/channels/channelsd/pipeline/backfill.go +++ b/pkg/channels/channelsd/pipeline/backfill.go @@ -2,6 +2,7 @@ package pipeline import ( "context" + "errors" "strings" "time" @@ -10,7 +11,10 @@ import ( spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/channels/channelevents" + "github.com/authzed/openagentprimitives/pkg/channels/channelinteractions/categories" "github.com/authzed/openagentprimitives/pkg/channels/channelkinds" + "github.com/authzed/openagentprimitives/pkg/channels/notice" "github.com/authzed/openagentprimitives/pkg/platform/identity" ) @@ -184,6 +188,10 @@ func threadSeedRequestsFor(class *spiceboxv1alpha1.AgentClass) []authz.ThreadSee Permission: s.Permission, ValueTransforms: transforms[s.ResourceType], AutoGrantFrom: s.AutoGrantFrom, + // Occupancy/Rebind are plain spec fields on the slot, so they ride + // straight off it onto every binding this seed emits. + Occupancy: s.Occupancy, + Rebind: s.Rebind, }) } return out @@ -252,12 +260,88 @@ func (p *Pipeline) seedThreadSlots( // Not fatal: without the grants the agent simply has to ask. logger.Info("thread slot seeding: grant write failed; those values will need approval", "session", sess.Namespace+"/"+sess.Name, "bindings", len(bindings), "err", err.Error()) + // A single-occupancy pin refusal is an answer, not a transient failure: + // the thread named a target this session cannot switch to. Tell the + // people in it, beyond the operator log above. Pass the bindings so the + // notice routes its NextStep by the refused slot's rebind policy. + p.publishSlotBindRefusedNotice(ctx, sess, bindings, err) return } logger.Info("thread slot seeding: bound values a trusted author put in the thread", "session", sess.Namespace+"/"+sess.Name, "bindings", len(bindings)) } +// publishSlotBindRefusedNotice surfaces a mint-time slot-bind refusal to the +// session as a SlotBindRefused notice, so the people in the conversation learn +// a target they named was NOT bound because the session is committed to a +// different one — a fact that otherwise lived only in the operator log beside +// each call site. +// +// Scoped to the pin ruling: errors.Is(err, authz.ErrSlotPinned) also covers the +// multi-arrival "cannot hold N distinct instances at once" refusal, which wraps +// the same sentinel. A transient SpiceDB failure is NOT a user-actionable +// commitment and stays operator-only — the agent retries or asks, and announcing +// an outage as a pin would be a lie. +// +// Best-effort: the grants this explains were advisory (a refused thread-seed or +// trigger bind costs an approval, not the session), so a publish failure is +// logged (no-silent-errors) and never propagated. Shared by both mint-time +// paths — thread seed (above) and trigger bind (triggerslots.go). +func (p *Pipeline) publishSlotBindRefusedNotice(ctx context.Context, sess *spiceboxv1alpha1.AgentSession, bindings []authz.SlotBinding, err error) { + if err == nil || !errors.Is(err, authz.ErrSlotPinned) { + return + } + if p.NATS == nil { + return + } + ref := channelevents.SessionRef{Namespace: sess.Namespace, Name: sess.Name} + if perr := slotBindRefusedNotice(err.Error(), allRebindNever(bindings)).Publish(p.NATS.Publish, ref, mintRequestID()); perr != nil { + log.FromContext(ctx).Info("slot bind refused: publishing the user-facing notice failed; the refusal remains in the operator log only", + "session", sess.Namespace+"/"+sess.Name, "err", perr.Error()) + } +} + +// allRebindNever reports whether EVERY binding in the refused set declares +// rebind:never — the one case whose only route out is a new session. An empty +// Rebind reads as the default ("approval"), which DOES have a re-plan route, so +// any approval/unset binding flips this false. Empty bindings → false: default +// to naming the re-plan route, which also names the new-session fallback, rather +// than foreclosing it. +func allRebindNever(bindings []authz.SlotBinding) bool { + if len(bindings) == 0 { + return false + } + for _, b := range bindings { + if b.Rebind != authz.SlotRebindNever { + return false + } + } + return true +} + +// slotBindRefusedNotice builds the user-facing SlotBindRefused notice. The +// NextStep routes by the slot's rebind policy: a `never` slot can only be worked +// in a new session, while the default `approval` slot additionally has the +// re-plan route (an approved amendment moves the commitment). The exact +// per-type route the refusal names also rides the inert Excerpt (externally- +// derived content per the notice.Args contract), so the Excerpt and NextStep +// agree rather than the NextStep contradicting the route the Excerpt shows. +func slotBindRefusedNotice(refusal string, neverOnly bool) *notice.Notice { + nextStep := "To work on a different target, the agent can propose an updated plan naming it — " + + "approving that plan moves the commitment — or start a new conversation for it. The details below say more." + if neverOnly { + nextStep = "To work on a different target, start a new conversation for it — this session's commitment " + + "cannot be moved. The details below say more." + } + return notice.New(categories.SlotBindRefused, notice.Args{ + Lead: "A different target wasn't added to this session", + Body: "This session is committed to a single target for one of its slots, so another target named here was not added. The work continues on the target already committed.", + NextStep: nextStep, + Excerpt: &channelevents.InteractionExcerpt{Label: "Why", Content: refusal}, + Audience: participantsAudience(), + }) +} + func (p *Pipeline) applyAdoptionGrants( ctx context.Context, sess *spiceboxv1alpha1.AgentSession, diff --git a/pkg/channels/channelsd/pipeline/interaction_decision.go b/pkg/channels/channelsd/pipeline/interaction_decision.go index ac78f18f..bb7f9d82 100644 --- a/pkg/channels/channelsd/pipeline/interaction_decision.go +++ b/pkg/channels/channelsd/pipeline/interaction_decision.go @@ -3,6 +3,7 @@ package pipeline import ( "context" "encoding/json" + "errors" "fmt" "sigs.k8s.io/controller-runtime/pkg/client" @@ -225,7 +226,22 @@ func (p *Pipeline) HandleInteractionDecision(ctx context.Context, env channeleve // first so the click isn't silent, then return the wrapped error so the // relay's log + watchdog paths fire. The pending prompt is left intact — a // failed handler never resolves the card, so a retry can still act on it. - if pubErr := p.publishDecisionRejected(ctx, env.Session, pl, "handler_error", err.Error(), nil, ""); pubErr != nil { + // + // A pin refusal (ErrSlotPinned) is NOT a transient render error: the slot + // is committed elsewhere and, on the tool_approval path, the class has no + // plan route to move it (see toolApprovalHandler). Classify it separately + // so the clicker is told the real route — a new session — through the + // handler's own instance-naming message, rather than a generic + // "handler_error" the retry button cannot fix. + // NB: the class string deliberately avoids a "slot_pin"/"slot_grant_" + // prefix — the slot-write guard test (pkg/authz/slot_grant_guard_test.go) + // flags any such literal as a hand-built tuple, and this is a reject code, + // not a relation name. + class, reason := "handler_error", err.Error() + if errors.Is(err, authz.ErrSlotPinned) { + class = "pin_refused" + } + if pubErr := p.publishDecisionRejected(ctx, env.Session, pl, class, reason, nil, ""); pubErr != nil { logger.Info("interaction decision: publish handler-error rejection failed", "err", pubErr.Error()) } return fmt.Errorf("interaction decision: handler for %q errored (session %s): %w", pl.Category, ref, err) diff --git a/pkg/channels/channelsd/pipeline/pipeline.go b/pkg/channels/channelsd/pipeline/pipeline.go index f7da98fb..da5675a6 100644 --- a/pkg/channels/channelsd/pipeline/pipeline.go +++ b/pkg/channels/channelsd/pipeline/pipeline.go @@ -81,6 +81,16 @@ type Authz interface { // adoption to seed the values a trusted author already put in the thread, // so the agent may reach them without a prompt. Present on *spicedb.Client. GrantSlots(ctx context.Context, ns, name string, bindings []authz.SlotBinding, expiresAt time.Time) error + // DeleteSlotGrants removes every slot grant AND pin a session holds. The + // mint path calls it to sweep whatever a DEAD PREDECESSOR with this same + // ns/name left in SpiceDB, synchronously BEFORE its own first bind — + // because this path pre-stamps the operator's finalizer (see the Create + // site), which suppresses the operator's own admission sweep for a channelsd + // mint. A pin never expires, so a leftover would otherwise refuse the new + // session its own first bind and leak the predecessor's authority under the + // reused name. Present on *spicedb.Client (same method finalize + the + // operator's admission sweep use). + DeleteSlotGrants(ctx context.Context, ns, name string) error // Relations is the authz.RelWriter view of the same client, needed so an // approval can narrow scope through authz.BindApproved rather than writing // a grant alone. Present on *spicedb.Client. @@ -1504,6 +1514,21 @@ func (p *Pipeline) Deliver(ctx context.Context, ev channelkinds.InboundEvent) (c ObjectMeta: metav1.ObjectMeta{ Name: sessName, Namespace: ev.Channel.Namespace, + // Pre-stamp the operator's finalizer at Create. The operator's + // EnsureFinalizer runs its admission slot-tuple sweep ONLY on the + // reconcile that transitions the finalizer absent -> present (its + // added=true branch). That sweep races the mint-time slot binds this + // path performs right after the session exists (BindTriggerSlots, + // seedThreadSlots): the two are unordered, so the sweep can delete the + // legitimate authority — including the pin — those binds just wrote, + // deterministically when the operator is briefly backlogged. Stamping + // the finalizer here makes added=false on the operator's first + // reconcile, so its sweep never fires for a channelsd mint; this path + // sweeps stale tuples itself, synchronously BEFORE binding (see the + // DeleteSlotGrants call below). The operator's sweep still runs for + // every OTHER creation path (CLI, kubectl), which do not pre-stamp, and + // its fork-child exemption is untouched. + Finalizers: []string{spiceboxv1alpha1.FinalizerAgentSession}, Labels: map[string]string{ spiceboxv1alpha1.LabelChannelName: ev.Channel.Name, spiceboxv1alpha1.LabelChannelKind: ev.Channel.Spec.Kind, @@ -1597,6 +1622,47 @@ func (p *Pipeline) Deliver(ctx context.Context, ev channelkinds.InboundEvent) (c created = true } + // Admission sweep for a channelsd mint: wipe whatever a DEAD PREDECESSOR with + // this same ns/name left in SpiceDB BEFORE this session does anything with its + // slots. A never-expiring stale slot_pin would otherwise refuse the new + // session its own first bind, and a stale slot_grant_* would resolve + // slot_grant->interact for the NEW session (the pin/grant subject is ns/name, + // not UID) — a leaked authority under the reused name. + // + // This IS the operator's admission sweep, MOVED to the creator. This path + // pre-stamped the operator's finalizer (see the Create site), which makes the + // operator's EnsureFinalizer added=false and so suppresses its own sweep for a + // channelsd mint; doing it here runs it synchronously ahead of — rather than + // unordered against — the mint-time binds below (BindTriggerSlots, + // seedThreadSlots). + // + // Gated on `created`: only the replica that won the Create race owns the + // follow-up writes, so only it sweeps (the AlreadyExists-race loser must not). + // Runs BEFORE the startParked return below as well, so a parked session whose + // name was reused is swept too — the operator no longer will. p.Authz is + // nil-checked because a created session that binds nothing still reaches here. + // + // Fail CLOSED, like the started_by write just below: a leftover grant resolves + // for the new session, so proceeding past a failed sweep would run it over + // unverified authority. status.startFailure is the channelsd-owned signal the + // operator reads to drive Failed; the error still propagates. + if created && p.Authz != nil { + if err := p.Authz.DeleteSlotGrants(ctx, sess.Namespace, sess.Name); err != nil { + log.FromContext(ctx).Info("mint admission sweep: deleting stale slot grants/pins failed; failing the session rather than binding over unverified authority", + "session", sess.Namespace+"/"+sess.Name, "err", err.Error()) + failed := sess.DeepCopy() + failed.Status.StartFailure = &spiceboxv1alpha1.AgentSessionStartFailure{ + Reason: spiceboxv1alpha1.ReasonAgentSessionAuthzWriteFail, + Message: err.Error(), + } + if werr := applyApprovalStatus(ctx, p.K8s, failed, sess); werr != nil { + log.FromContext(ctx).Info("set startFailure signal after mint admission sweep failure", + "session", sess.Namespace+"/"+sess.Name, "err", werr.Error()) + } + return channelkinds.InboundDecision{Outcome: channelkinds.OutcomeInternalError}, err + } + } + // started_by is user-only: the schema declares `relation started_by: user` // and the write hardcodes ObjectType "user". A "service:" canonical // would land as `user:service:`, whose object_id embeds a colon and is diff --git a/pkg/channels/channelsd/pipeline/pipeline_test.go b/pkg/channels/channelsd/pipeline/pipeline_test.go index ebd3cb70..5c8664b5 100644 --- a/pkg/channels/channelsd/pipeline/pipeline_test.go +++ b/pkg/channels/channelsd/pipeline/pipeline_test.go @@ -46,6 +46,13 @@ type fakeAuthz struct { grantedSlotsSession string grantedSlotsExpiry time.Time grantSlotsErr error + // deleteSlotGrantsCalls / deleteSlotGrantsErr back DeleteSlotGrants — the + // mint admission sweep. slotCallOrder records "sweep"/"grant" in arrival + // order so a test can prove the sweep runs BEFORE the first bind rather than + // merely that both fired. + deleteSlotGrantsCalls int + deleteSlotGrantsErr error + slotCallOrder []string checkResult bool checkErr error checkInteractFn func(canonicalID string) (bool, error) @@ -236,6 +243,7 @@ func (f *fakeAuthz) CheckOnResource(ctx context.Context, resType, resID, _ strin // GrantSlots records the instances thread adoption seeded, so a test can // assert WHICH values bound rather than merely that something was written. func (f *fakeAuthz) GrantSlots(_ context.Context, ns, name string, bindings []authz.SlotBinding, expiresAt time.Time) error { + f.slotCallOrder = append(f.slotCallOrder, "grant") if f.grantSlotsErr != nil { return f.grantSlotsErr } @@ -245,6 +253,22 @@ func (f *fakeAuthz) GrantSlots(_ context.Context, ns, name string, bindings []au return nil } +// DeleteSlotGrants models the mint admission sweep: it clears whatever this +// name held (a stale predecessor's tuples), so a test seeding a stale binding +// can assert the sweep wiped it and left only the FRESH bind behind, and it +// records its order relative to GrantSlots to prove it ran first. A test mints +// exactly one session, so clearing is session-scoped in effect. +func (f *fakeAuthz) DeleteSlotGrants(_ context.Context, _, _ string) error { + f.deleteSlotGrantsCalls++ + f.slotCallOrder = append(f.slotCallOrder, "sweep") + if f.deleteSlotGrantsErr != nil { + return f.deleteSlotGrantsErr + } + f.grantedSlots = nil + f.grantedSlotsSession = "" + return nil +} + // Relations satisfies pipeline.Authz for tests that never approve a JIT tool // call (most of this package). A nil authz.RelWriter is the documented safe // branch: authz.BindApproved narrows session_scope and logs rather than diff --git a/pkg/channels/channelsd/pipeline/precondition_waiver_interaction.go b/pkg/channels/channelsd/pipeline/precondition_waiver_interaction.go index 80db78cc..505d01b4 100644 --- a/pkg/channels/channelsd/pipeline/precondition_waiver_interaction.go +++ b/pkg/channels/channelsd/pipeline/precondition_waiver_interaction.go @@ -97,6 +97,11 @@ func preconditionWaiverHandler(p *Pipeline) channelinteractions.DecisionHandler ResourceID: authz.TrustedObjectID(det.ResourceID), Permission: det.Permission, NoGrantRelation: det.NoSlotGrant, + // Resolved from the class slot at request-record time and + // carried in the details, so the waiver's grant is pinned + // exactly as any other bind of the same single-occupancy slot. + Occupancy: det.Occupancy, + Rebind: det.Rebind, }}, authz.PreconditionsWaived, expiry, logr.FromContextOrDiscard(ctx), p.Now); err != nil { return channelinteractions.Outcome{}, fmt.Errorf( "precondition waiver decision: bind %s on %s/%s (requestRef %q): %w", diff --git a/pkg/channels/channelsd/pipeline/slot_bind_refused_notice_test.go b/pkg/channels/channelsd/pipeline/slot_bind_refused_notice_test.go new file mode 100644 index 00000000..2dafc9cc --- /dev/null +++ b/pkg/channels/channelsd/pipeline/slot_bind_refused_notice_test.go @@ -0,0 +1,162 @@ +package pipeline + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/channels/channelevents" + "github.com/authzed/openagentprimitives/pkg/channels/channelinteractions/categories" + "github.com/authzed/openagentprimitives/pkg/channels/channelkinds" + "github.com/authzed/openagentprimitives/pkg/platform/identity" +) + +// slotBindRefusedNotices returns every SlotBindRefused interaction-request the +// recorder saw, decoded — the "recorded interactions" the pipeline produced. +func slotBindRefusedNotices(t *testing.T, rec *fakeNATS) []channelevents.InteractionRequestPayload { + t.Helper() + var out []channelevents.InteractionRequestPayload + for i, subj := range rec.subjects { + if !strings.HasSuffix(subj, ".out."+string(channelevents.KindInteractionRequest)) { + continue + } + var env channelevents.Envelope + if err := json.Unmarshal(rec.payloads[i], &env); err != nil { + continue + } + var pl channelevents.InteractionRequestPayload + if err := json.Unmarshal(env.Payload, &pl); err != nil { + continue + } + if pl.Category == categories.SlotBindRefused { + out = append(out, pl) + } + } + return out +} + +// TestBindTriggerSlots_PinRefusal_RecordsNotice is the surfacing proof for the +// trigger mint-time path: a GrantSlots refusal wrapping authz.ErrSlotPinned +// must record a session-visible SlotBindRefused notice carrying the refusal +// text, not merely log to the operator. +func TestBindTriggerSlots_PinRefusal_RecordsNotice(t *testing.T) { + const refusal = "slot already pinned to a different instance: this session is pinned to " + + "github_pull_request:acme/original; to work on github_pull_request:acme/widgets, " + + "propose an updated plan naming it — an approved plan moves the pin" + + class := classWithSlots(spiceboxv1alpha1.AuthzSlot{ + ResourceType: "github_pull_request", Permission: "write_memory", + FillFrom: []string{"trigger"}, + }) + reqs := TriggerSlotRequestsFor(context.Background(), class) + require.Len(t, reqs, 1) + + az := &fakeAuthz{grantSlotsErr: fmt.Errorf("%s: %w", refusal, authz.ErrSlotPinned)} + natsRec := &fakeNATS{} + p := &Pipeline{Authz: az, NATS: natsRec} + ch := newGitHubChannel("gh-in", "service:demo") + ev := channelkinds.InboundEvent{ + Channel: ch, DeliveryEvent: "pull_request", + RawDelivery: []byte(`{"action":"opened","number":7,"repository":{"full_name":"acme/widgets"},` + + `"pull_request":{"node_id":"PR_x"}}`), + } + + p.BindTriggerSlots(context.Background(), + triggerTestSession(), + ev, reqs, time.Now()) + + notices := slotBindRefusedNotices(t, natsRec) + require.Len(t, notices, 1, "a pin refusal must be explained to the session, not only logged") + assert.NotEmpty(t, notices[0].Lead, "the notice needs a headline") + require.NotNil(t, notices[0].Excerpt, "the refusal text must ride the inert excerpt") + assert.Contains(t, notices[0].Excerpt.Content, "github_pull_request:acme/original", + "the notice must carry the pinned instance the refusal named") + assert.Contains(t, notices[0].Excerpt.Content, "propose an updated plan", + "the notice must carry the route out the refusal named") +} + +// TestSeedThreadSlots_PinRefusal_RecordsNotice is the surfacing proof for the +// thread-seed mint-time path (seedThreadSlots): a trusted author's thread value +// whose GrantSlots is refused by a single-occupancy pin must record the same +// session-visible SlotBindRefused notice carrying the refusal text. +func TestSeedThreadSlots_PinRefusal_RecordsNotice(t *testing.T) { + const refusal = "slot already pinned to a different instance: this session is pinned to " + + "web_target:https/old.example; start a new session to target web_target:https/new.example" + + // The requester must canonicalise identically to the message author, so the + // default owner-only trust policy admits the value. Same FromExternal call + // seedThreadSlots itself makes for each author. + requester, err := identity.FromExternal("slack", "", "U1", "alice@example.com"). + AllowSynthetic().Canonical() + require.NoError(t, err, "canonicalise requester") + + az := &fakeAuthz{grantSlotsErr: fmt.Errorf("%s: %w", refusal, authz.ErrSlotPinned)} + natsRec := &fakeNATS{} + p := &Pipeline{Authz: az, NATS: natsRec} + + sess := triggerTestSession() + ev := channelkinds.InboundEvent{ + Channel: newChannel("c1"), + ExternalIDs: channelkinds.ExternalIdentity{Kind: "slack", ExternalID: "U1", Email: "alice@example.com"}, + } + page := channelkinds.HistoryPage{Messages: []channelkinds.HistoryMessage{{ + AuthorExternalID: "U1", + AuthorEmail: "alice@example.com", + Text: "please look at https://new.example/thing", + }}} + reqs := []authz.ThreadSeedRequest{{ + ResourceType: "web_target", + Permission: "read", + // normalize_url is what marks the slot's values as URLs — the shape the + // seeder extracts from thread text. + ValueTransforms: []string{"normalize_url"}, + }} + + p.seedThreadSlots(context.Background(), sess, ev, page, requester, reqs, time.Now().Add(time.Hour)) + + notices := slotBindRefusedNotices(t, natsRec) + require.Len(t, notices, 1, "a pin refusal on the seed path must be explained to the session") + require.NotNil(t, notices[0].Excerpt, "the refusal text must ride the inert excerpt") + assert.Contains(t, notices[0].Excerpt.Content, "web_target:https/old.example", + "the notice must carry the pinned instance the refusal named") + assert.Contains(t, notices[0].Excerpt.Content, "start a new session", + "the notice must carry the route out the refusal named") +} + +// TestBindTriggerSlots_TransientError_NoNotice guards the scope: a non-pin +// GrantSlots failure is a transient infrastructure problem, not a user-actionable +// commitment, and must stay in the operator log — never announced to the session +// as a pin ruling. +func TestBindTriggerSlots_TransientError_NoNotice(t *testing.T) { + class := classWithSlots(spiceboxv1alpha1.AuthzSlot{ + ResourceType: "github_pull_request", Permission: "write_memory", + FillFrom: []string{"trigger"}, + }) + reqs := TriggerSlotRequestsFor(context.Background(), class) + require.Len(t, reqs, 1) + + az := &fakeAuthz{grantSlotsErr: fmt.Errorf("spicedb unavailable")} + natsRec := &fakeNATS{} + p := &Pipeline{Authz: az, NATS: natsRec} + ch := newGitHubChannel("gh-in", "service:demo") + ev := channelkinds.InboundEvent{ + Channel: ch, DeliveryEvent: "pull_request", + RawDelivery: []byte(`{"action":"opened","number":7,"repository":{"full_name":"acme/widgets"},` + + `"pull_request":{"node_id":"PR_x"}}`), + } + + p.BindTriggerSlots(context.Background(), + triggerTestSession(), + ev, reqs, time.Now()) + + assert.Empty(t, slotBindRefusedNotices(t, natsRec), + "a transient failure is not a pin ruling and must not be announced as one") +} diff --git a/pkg/channels/channelsd/pipeline/tool_approval_interaction.go b/pkg/channels/channelsd/pipeline/tool_approval_interaction.go index 33343e37..bba825de 100644 --- a/pkg/channels/channelsd/pipeline/tool_approval_interaction.go +++ b/pkg/channels/channelsd/pipeline/tool_approval_interaction.go @@ -3,6 +3,7 @@ package pipeline import ( "context" "encoding/json" + "errors" "fmt" "time" @@ -154,7 +155,29 @@ func toolApprovalHandler(p *Pipeline) channelinteractions.DecisionHandler { // precondition_waiver card, never this tool_call one (see // toolcallauthz.explainPrecondition), so a tool_approval bind // can only ever cover a call whose gate was Satisfied or absent. + // + // Occupancy/Rebind were resolved from the class slot at + // request-record time (the runner held the class; this handler + // does not) and carried in the details, so GrantSlots pins this + // approval's grant exactly as a non-approval bind of the same + // slot would — a single-occupancy type binds through the pin, + // not the unpinned plain write. + Occupancy: det.Occupancy, + Rebind: det.Rebind, }}, authz.EnforcePreconditions, expiry, logr.FromContextOrDiscard(ctx), p.Now); err != nil { + if errors.Is(err, authz.ErrSlotPinned) { + // The tool_approval card is the escalation path for classes with + // NO plan gate, so there is no re-plan route to move a filled + // single-occupancy slot. The generic bind-error chain would hand + // the clicker GrantSlots' "propose an updated plan" advice — a + // dead end this class cannot follow. Return a clean refusal + // (still wrapping ErrSlotPinned so the decision pipe routes it as + // a pin refusal, not a render error) that names the target this + // approval was for and the only real route: a new session. + return channelinteractions.Outcome{}, fmt.Errorf( + "%w: this session is already committed to a different %s, and this agent cannot move that commitment (it uses no plan) — start a new session to work on %s", + authz.ErrSlotPinned, det.ResourceType, det.ResourceType+":"+det.ResourceID) + } return channelinteractions.Outcome{}, fmt.Errorf( "tool approval decision: bind %s on %s/%s (requestRef %q): %w", det.ResourceType+":"+det.ResourceID, d.Session.Namespace, d.Session.Name, diff --git a/pkg/channels/channelsd/pipeline/tool_approval_interaction_test.go b/pkg/channels/channelsd/pipeline/tool_approval_interaction_test.go index 79b69c2a..24c78599 100644 --- a/pkg/channels/channelsd/pipeline/tool_approval_interaction_test.go +++ b/pkg/channels/channelsd/pipeline/tool_approval_interaction_test.go @@ -95,10 +95,15 @@ type slotGrantRecorder struct { fakeAuthz fail bool relations []authz.Relation + pins map[string]string // keyed by "/\x00" } // Relations returns the recorder itself as the authz.RelWriter BindApproved -// writes through. +// writes through. The recorder also implements authz.SlotPinner: the approval +// binds a single-occupancy slot (empty occupancy defaults to single), which the +// GrantSlots gate refuses to write through a plain RelWriter. The pinned write +// records into the same `relations` slice as the plain path, so an assertion on +// the written tuple reads the same whichever path a binding took. func (s *slotGrantRecorder) Relations() authz.RelWriter { return s } func (s *slotGrantRecorder) WriteRelationships(_ context.Context, rels []authz.Relation) error { @@ -113,6 +118,46 @@ func (s *slotGrantRecorder) DeleteRelationships(_ context.Context, _ []authz.Rel return nil } +func slotGrantRecorderPinKey(scope authz.SessionRef, resourceType string) string { + return scope.String() + "\x00" + resourceType +} + +func (s *slotGrantRecorder) EnsurePin(_ context.Context, resourceType, resourceID string, scope authz.SessionRef) (bool, string, error) { + if s.pins == nil { + s.pins = map[string]string{} + } + key := slotGrantRecorderPinKey(scope, resourceType) + if cur, ok := s.pins[key]; ok { + return true, cur, nil + } + s.pins[key] = resourceID + return false, resourceID, nil +} + +func (s *slotGrantRecorder) WriteGrantsPinned(_ context.Context, rels []authz.Relation, _, _ string, _ authz.SessionRef) error { + if s.fail { + return errors.New("spicedb unavailable") + } + s.relations = append(s.relations, rels...) + return nil +} + +func (s *slotGrantRecorder) MovePin(_ context.Context, resourceType, _, toID string, _ []authz.Relation, scope authz.SessionRef) error { + if s.pins == nil { + s.pins = map[string]string{} + } + s.pins[slotGrantRecorderPinKey(scope, resourceType)] = toID + return nil +} + +func (s *slotGrantRecorder) ReadPin(_ context.Context, resourceType string, scope authz.SessionRef) (string, error) { + return s.pins[slotGrantRecorderPinKey(scope, resourceType)], nil +} + +func (s *slotGrantRecorder) ListGrantsFor(_ context.Context, _, _ string, _ authz.SessionRef) ([]authz.Relation, error) { + return nil, nil +} + // approvalPipeline builds the pipeline fixture the tool-approval tests share. // MemReader is a real in-process memory.Memory (not a fake): BindApproved // narrows session_scope through sessionscope.Get/Put, which needs a working @@ -220,6 +265,72 @@ func TestToolApprovalDecision_narrowsScopeToTheApprovedInstance(t *testing.T) { "approving this company must exclude the others, not merely permit this one") } +// TestToolApprovalDecision_CarriesOccupancyFromDetailsToTheGate proves the +// occupancy resolved at request-record time (ToolApprovalDetails.Occupancy) +// reaches the SlotBinding the handler binds, and so the GrantSlots gate: a +// "multi" record takes the plain unpinned write, while a single (empty) record +// pins the slot. If det.Occupancy stopped propagating onto the binding, the +// multi approval would pin — caught by the pin's presence, not by the grant +// merely landing. +func TestToolApprovalDecision_CarriesOccupancyFromDetailsToTheGate(t *testing.T) { + decisionWith := func(occupancy string) channelinteractions.Decision { + det, _ := json.Marshal(channelevents.ToolApprovalDetails{ + Permission: "apply", ResourceType: "label", ResourceID: "bug", ArgsHash: "h1", + StateImpact: "readonly", Occupancy: occupancy, + }) + return channelinteractions.Decision{ + Session: channelevents.SessionRef{Namespace: "default", Name: "demo-session"}, + Payload: channelevents.InteractionDecisionPayload{Category: "tool_approval", RequestRef: "req-occ", ActionID: "approve"}, + Request: &channelevents.InteractionRequestPayload{Category: "tool_approval", RequestRef: "req-occ", Details: det}, + } + } + + t.Run("multi: binds without pinning", func(t *testing.T) { + rec := &slotGrantRecorder{} + _, err := toolApprovalHandler(approvalPipeline(t, rec))(context.Background(), decisionWith("multi")) + require.NoError(t, err) + require.Len(t, rec.relations, 1) + assert.Empty(t, rec.pins, "a multi-occupancy approval must not pin the slot") + }) + t.Run("single (empty): pins the slot", func(t *testing.T) { + rec := &slotGrantRecorder{} + _, err := toolApprovalHandler(approvalPipeline(t, rec))(context.Background(), decisionWith("")) + require.NoError(t, err) + require.Len(t, rec.relations, 1) + assert.NotEmpty(t, rec.pins, "a single-occupancy approval (empty occupancy) must pin the slot") + }) +} + +// TestToolApprovalDecision_pinRefusedRoutesToNewSession: when an approved JIT +// bind hits a single-occupancy slot already committed to a DIFFERENT instance, +// the tool_approval card has NO plan route to move the pin (it is the escalation +// path for classes with no plan gate). The handler must refuse with a message +// that names the target and routes to a NEW SESSION — and keep wrapping +// ErrSlotPinned so the decision pipe classifies it as a pin refusal, not a +// generic handler error — rather than GrantSlots' "propose an updated plan" +// advice, which this class cannot follow. +func TestToolApprovalDecision_pinRefusedRoutesToNewSession(t *testing.T) { + rec := &slotGrantRecorder{} + p := approvalPipeline(t, rec) + // The slot's type is already pinned to a DIFFERENT instance for this session, + // so the approved different-instance bind is refused by the pin. + rec.pins = map[string]string{ + slotGrantRecorderPinKey(authz.SessionRef{Namespace: "default", Name: "demo-session"}, "repo"): "other-repo", + } + + _, err := toolApprovalHandler(p)(context.Background(), toolDecision("approve", "readwrite")) + require.Error(t, err) + assert.ErrorIs(t, err, authz.ErrSlotPinned, + "the refusal must stay errors.Is-able so the decision pipe routes it as a pin refusal, not a render error") + assert.Contains(t, err.Error(), "start a new session", + "a class with no plan gate can only move to a new target via a new session") + assert.Contains(t, err.Error(), "repo:r1", + "the refusal names the target this approval was for") + assert.NotContains(t, err.Error(), "propose an updated plan", + "a class with no plan gate must not be told to re-plan") + assert.Empty(t, rec.relations, "a refused bind grants nothing") +} + // TestToolApprovalHandler_ResourcelessCall pins the case observed live on // oap-desktop: the builder called workshop_apply — a sidecar tool with no // resource handle, so its approval details carry no permission, type or id diff --git a/pkg/channels/channelsd/pipeline/triggerslots.go b/pkg/channels/channelsd/pipeline/triggerslots.go index 0ab3ec46..553ad35f 100644 --- a/pkg/channels/channelsd/pipeline/triggerslots.go +++ b/pkg/channels/channelsd/pipeline/triggerslots.go @@ -20,6 +20,11 @@ type TriggerSlotRequest struct { // Expr is the slot's compiled triggerInstance, nil when the slot // declared none and relies on the kind's TriggerSlotProvider. Expr cel.Program + // Occupancy and Rebind are the slot's single-vs-multi commitment and rebind + // policy, carried onto every binding a verified delivery produces so the + // GrantSlots pinning gate sees them. Empty Occupancy reads as single. + Occupancy string + Rebind string } // maxTriggerBoundBindings caps how many instances one verified delivery may @@ -105,7 +110,7 @@ func TriggerSlotRequestsFor(ctx context.Context, class *spiceboxv1alpha1.AgentCl "class", class.Namespace+"/"+class.Name, "resourceType", s.ResourceType) continue } - req := TriggerSlotRequest{ResourceType: s.ResourceType, Permission: s.Permission} + req := TriggerSlotRequest{ResourceType: s.ResourceType, Permission: s.Permission, Occupancy: s.Occupancy, Rebind: s.Rebind} if s.TriggerInstance != "" { prg, err := authz.CompileTriggerInstanceExpr(s.TriggerInstance) if err != nil { @@ -236,7 +241,7 @@ func (p *Pipeline) BindTriggerSlots( "session", sess.Namespace+"/"+sess.Name, "kind", ev.Channel.Spec.Kind, "resourceType", req.ResourceType, "err", err.Error()) continue } - b := authz.SlotBinding{ResourceType: req.ResourceType, ResourceID: id, Permission: req.Permission} + b := authz.SlotBinding{ResourceType: req.ResourceType, ResourceID: id, Permission: req.Permission, Occupancy: req.Occupancy, Rebind: req.Rebind} dedupKey := b.ResourceType + "\x00" + id.String() + "\x00" + b.Permission if _, dup := seen[dedupKey]; dup { continue @@ -266,6 +271,11 @@ func (p *Pipeline) BindTriggerSlots( if err := p.Authz.GrantSlots(ctx, sess.Namespace, sess.Name, bindings, expiresAt); err != nil { logger.Info("trigger slot binding: grant write failed; every slot-bound write in this batch (see bindings) will be refused; there is nobody to approve on a triggered session", "session", sess.Namespace+"/"+sess.Name, "bindings", len(bindings), "err", err.Error()) + // A single-occupancy pin refusal is an answer, not a transient failure: + // the delivery named a target this session cannot switch to. Record a + // session-visible notice, beyond the operator log above. Pass the bindings + // so the notice routes its NextStep by the refused slot's rebind policy. + p.publishSlotBindRefusedNotice(ctx, sess, bindings, err) return } logger.Info("trigger slot binding: bound the instances a verified delivery named", diff --git a/pkg/channels/channelsd/pipeline/triggerslots_test.go b/pkg/channels/channelsd/pipeline/triggerslots_test.go index f663f5cc..e6595747 100644 --- a/pkg/channels/channelsd/pipeline/triggerslots_test.go +++ b/pkg/channels/channelsd/pipeline/triggerslots_test.go @@ -356,6 +356,36 @@ func TestBindTriggerSlots_NonTriggerSlot_BindsNothing(t *testing.T) { assert.Empty(t, az.grantedSlots) } +// TestTriggerSlots_CarriesOccupancyAndRebind is the trigger family's +// propagation guard: the slot's occupancy/rebind must ride from the AuthzSlot +// onto the request (TriggerSlotRequestsFor) and from the request onto every +// bound SlotBinding (BindTriggerSlots). A drop in either hop would land a +// verified-delivery instance at GrantSlots reading as single — fail-closed, but +// a multi slot's second instance would be refused, with nothing red. +func TestTriggerSlots_CarriesOccupancyAndRebind(t *testing.T) { + class := classWithSlots(spiceboxv1alpha1.AuthzSlot{ + ResourceType: "github_pull_request", Permission: "write_memory", + FillFrom: []string{"trigger"}, TriggerInstance: `payload.custom_id`, + Occupancy: "multi", Rebind: "approval", + }) + reqs := TriggerSlotRequestsFor(context.Background(), class) + require.Len(t, reqs, 1) + assert.Equal(t, "multi", reqs[0].Occupancy, "the slot's occupancy must reach the request") + assert.Equal(t, "approval", reqs[0].Rebind) + + az := &fakeAuthz{} + p := &Pipeline{Authz: az} + ch := newGitHubChannel("gh-in", "service:demo") + ev := channelkinds.InboundEvent{ + Channel: ch, DeliveryEvent: "pull_request", + RawDelivery: []byte(`{"action":"opened","number":7,"repository":{"full_name":"acme/widgets"},"custom_id":"pr-123"}`), + } + p.BindTriggerSlots(context.Background(), triggerTestSession(), ev, reqs, time.Now()) + require.Len(t, az.grantedSlots, 1) + assert.Equal(t, "multi", az.grantedSlots[0].Occupancy, "the request's occupancy must reach the bound slot") + assert.Equal(t, "approval", az.grantedSlots[0].Rebind) +} + // TestBindTriggerSlots_ExpressionWinsOverProvider crafts the expression and // the provider to yield DIFFERENT ids for the same slot, and asserts the // expression's id is the one that binds. diff --git a/pkg/controllers/agentclass/builderclass_draft_slot_test.go b/pkg/controllers/agentclass/builderclass_draft_slot_test.go index 5963c5a1..a7328afa 100644 --- a/pkg/controllers/agentclass/builderclass_draft_slot_test.go +++ b/pkg/controllers/agentclass/builderclass_draft_slot_test.go @@ -123,7 +123,7 @@ func TestBuilderClass_WorkshopDraftSlotResolvesFromTheSidecarAlone(t *testing.T) // TestBuilderClass_DraftSlotLeavesMembershipToTheInstaller pins the one field // the manifest deliberately omits. `oap install` completes every slot's // membership to the CRD's own default before applying -// (completeAuthzSlotMembershipDefaults, pkg/platform/oap/install/apply.go), +// (completeAuthzSlotDefaults, pkg/platform/oap/install/apply.go), // because spec.authz.slots is +listType=atomic: SSA replaces the whole list, so // an applied slot that omits membership differs from the live one the apiserver // has since defaulted and every re-apply records a change — a byte-identical diff --git a/pkg/controllers/agentclass/slot_declaration.go b/pkg/controllers/agentclass/slot_declaration.go index 83b29e48..a6dd1a8f 100644 --- a/pkg/controllers/agentclass/slot_declaration.go +++ b/pkg/controllers/agentclass/slot_declaration.go @@ -107,6 +107,17 @@ func validateSlotDeclarations(ac *spiceboxv1alpha1.AgentClass) ([]compiledSlot, } } + occupancy := s.Occupancy + if occupancy == "" { + occupancy = spiceboxv1alpha1.AuthzSlotOccupancyDefault + } + if occupancy == spiceboxv1alpha1.AuthzSlotOccupancyDefault && len(s.Defaults) > 1 { + return nil, spiceboxv1alpha1.ReasonSlotDeclarationInvalid, fmt.Sprintf( + "slot %q is occupancy: single but declares %d defaults; a single-occupancy slot pins exactly one instance. "+ + "Drop all but one default, or set occupancy: multi.", + s.ResourceType, len(s.Defaults)) + } + // autoGrantFrom names whose thread contributions may bind without an // approval. With no channel_thread source nothing is ever seeded from a // thread, so the policy never runs — and unlike an omitted field, a diff --git a/pkg/controllers/agentclass/slot_declaration_test.go b/pkg/controllers/agentclass/slot_declaration_test.go index 1edf0259..b43d37bd 100644 --- a/pkg/controllers/agentclass/slot_declaration_test.go +++ b/pkg/controllers/agentclass/slot_declaration_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" ) @@ -423,3 +424,52 @@ func TestValidateSlotDeclarations_checksEveryPrecondition(t *testing.T) { assert.Equal(t, spiceboxv1alpha1.ReasonSlotDeclarationInvalid, reason) assert.Contains(t, msg, "requires[1]", "the message must name WHICH precondition is wrong") } + +func TestValidateSlotDeclarations_SingleOccupancyRejectsMultipleDefaults(t *testing.T) { + ac := &spiceboxv1alpha1.AgentClass{} + ac.Spec.Authz = &spiceboxv1alpha1.AuthzBlock{Slots: []spiceboxv1alpha1.AuthzSlot{{ + ResourceType: "git_repo", Description: "d", Permission: "push", + Occupancy: "single", + Defaults: []string{"github.com/a/one", "github.com/a/two"}, + }}} + _, reason, msg := validateSlotDeclarations(ac) + require.Equal(t, spiceboxv1alpha1.ReasonSlotDeclarationInvalid, reason) + assert.Contains(t, msg, "git_repo") + assert.Contains(t, msg, "occupancy: multi") +} + +func TestValidateSlotDeclarations_SingleOccupancyAllowsOneDefault(t *testing.T) { + ac := &spiceboxv1alpha1.AgentClass{} + ac.Spec.Authz = &spiceboxv1alpha1.AuthzBlock{Slots: []spiceboxv1alpha1.AuthzSlot{{ + ResourceType: "git_repo", Description: "d", Permission: "push", + Occupancy: "single", Defaults: []string{"github.com/a/one"}, + }}} + _, reason, _ := validateSlotDeclarations(ac) + assert.Empty(t, reason) +} + +// An omitted occupancy MEANS single (AuthzSlotOccupancyDefault) — this is the +// binding constraint, not a convenience: a slot written before the field +// existed must not silently become multi-occupancy. +func TestValidateSlotDeclarations_OmittedOccupancyDefaultsToSingle(t *testing.T) { + ac := &spiceboxv1alpha1.AgentClass{} + ac.Spec.Authz = &spiceboxv1alpha1.AuthzBlock{Slots: []spiceboxv1alpha1.AuthzSlot{{ + ResourceType: "git_repo", Description: "d", Permission: "push", + Defaults: []string{"github.com/a/one", "github.com/a/two"}, + }}} + _, reason, msg := validateSlotDeclarations(ac) + require.Equal(t, spiceboxv1alpha1.ReasonSlotDeclarationInvalid, reason) + assert.Contains(t, msg, "git_repo") + assert.Contains(t, msg, "occupancy: multi") +} + +func TestValidateSlotDeclarations_MultiOccupancyAllowsSeveralDefaults(t *testing.T) { + ac := &spiceboxv1alpha1.AgentClass{} + ac.Spec.Authz = &spiceboxv1alpha1.AuthzBlock{Slots: []spiceboxv1alpha1.AuthzSlot{{ + ResourceType: "git_repo", Description: "d", Permission: "push", + Occupancy: "multi", + Defaults: []string{"github.com/a/one", "github.com/a/two", "github.com/a/three"}, + }}} + _, reason, msg := validateSlotDeclarations(ac) + assert.Empty(t, reason, "unexpected rejection: %s", msg) +} diff --git a/pkg/controllers/agentsession/controller.go b/pkg/controllers/agentsession/controller.go index 0f577a75..64054e8b 100644 --- a/pkg/controllers/agentsession/controller.go +++ b/pkg/controllers/agentsession/controller.go @@ -1140,8 +1140,52 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu // fields this reconcile actually changes; agentstatus.WriteOwned diffs // against it. ctx = withReconcileOriginal(ctx, sess.DeepCopy()) - if added, err := apreconcile.EnsureFinalizer(ctx, r.Client, &sess, spiceboxv1alpha1.FinalizerAgentSession); added || err != nil { - return ctrl.Result{Requeue: added}, err + // Admission sweep: wipe whatever a DEAD PREDECESSOR with this same name left + // behind in SpiceDB, once per creation, BEFORE the finalizer is added. A + // slot_pin tuple never expires, so a reused name would otherwise inherit a + // stale predecessor's pin and be refused its own first bind — + // DeleteSlotGrants is the same call finalize() makes on teardown, and + // sweeping it again here, before this session's own first grant can be + // written, is what closes that window. + // + // The finalizer is the "swept" marker, which is why the sweep runs while it + // is still ABSENT and the finalizer is added only after the sweep succeeds. + // Fail closed: a sweep error returns before EnsureFinalizer, so the requeue + // finds the finalizer still missing and sweeps again. Sweeping after the + // finalizer write (as this once did) made a failed sweep permanent — the + // requeue saw added=false and skipped it, letting the new session proceed + // with a leftover pin live under its name. A sweep that succeeds followed by + // a failed finalizer write just sweeps again, which is idempotent. + // + // EXCEPT a fork child. The parent's ReconcileRestart copies the parent's + // slot grants AND pin onto the child's (ns, name) — authz.CopySlotGrants, + // restart.go step 6c — and BuildChildSession creates the child with no + // finalizer, so that copy lands BEFORE this reconcile runs. Sweeping here + // would be the last writer and would silently strip every non-takeover fork + // of its inherited authority (and could double-pin under the PVC-restore + // requeue, wedging EnsurePin's one-pin read). The sweep guards against a + // stale write from a dead predecessor REUSING this name; a fork child's name + // is generated (PendingRestart.TargetSessionName), so that reuse cannot + // happen to it, and the tuples already present under its name are + // legitimate by construction. ForkedFrom is the same signal + // BuildChildSession stamps on every restart child. + // + // A channelsd mint pre-stamps the finalizer and runs this same sweep itself, + // synchronously ahead of its mint-time binds (pipeline.go), so it never + // reaches this branch. + if !controllerutil.ContainsFinalizer(&sess, spiceboxv1alpha1.FinalizerAgentSession) && + r.SpiceDBDeleter != nil && sess.Spec.ForkedFrom == "" { + if err := r.SpiceDBDeleter.DeleteSlotGrants(ctx, sess.Namespace, sess.Name); err != nil { + log.FromContext(ctx).Error(err, "admission sweep: delete stale slot grants/pins failed", + "session", sess.Namespace+"/"+sess.Name) + return ctrl.Result{}, err + } + } + if addedFinalizer, ferr := apreconcile.EnsureFinalizer(ctx, r.Client, &sess, spiceboxv1alpha1.FinalizerAgentSession); addedFinalizer || ferr != nil { + if ferr != nil { + return ctrl.Result{}, ferr + } + return ctrl.Result{Requeue: true}, nil } // Restore this session's memory-API bearer token into the in-process registry diff --git a/pkg/controllers/agentsession/controller_test.go b/pkg/controllers/agentsession/controller_test.go index a741699e..326ce83f 100644 --- a/pkg/controllers/agentsession/controller_test.go +++ b/pkg/controllers/agentsession/controller_test.go @@ -5,6 +5,7 @@ package agentsession_test import ( "context" + "errors" "testing" "time" @@ -1422,6 +1423,28 @@ type fakeSpiceDBDeleter struct { dataSlotsCalls int // order records which teardown call arrived first. order []string + + // stalePresent simulates slot_pin/slot_grant_* tuples already sitting in + // SpiceDB for a given ns/name — e.g. a straggling write from a dead + // predecessor session that reused this name. seedStaleSlotTuples marks an + // entry present; DeleteSlotGrants clears it, so a test can assert the + // stale tuples are actually GONE rather than merely that the call fired. + stalePresent map[string]bool + + // slotGrantsFailures makes the next N DeleteSlotGrants calls fail, before + // they clear anything — a SpiceDB blip during the admission sweep. + slotGrantsFailures int +} + +func (f *fakeSpiceDBDeleter) seedStaleSlotTuples(ns, name string) { + if f.stalePresent == nil { + f.stalePresent = map[string]bool{} + } + f.stalePresent[ns+"/"+name] = true +} + +func (f *fakeSpiceDBDeleter) hasStaleSlotTuples(ns, name string) bool { + return f.stalePresent[ns+"/"+name] } func (f *fakeSpiceDBDeleter) DeleteAgentSessionRelationships(_ context.Context, ns, name string) error { @@ -1434,9 +1457,14 @@ func (f *fakeSpiceDBDeleter) DeleteAgentSessionRelationships(_ context.Context, func (f *fakeSpiceDBDeleter) DeleteSlotGrants(_ context.Context, ns, name string) error { f.slotGrantsCalls++ + if f.slotGrantsFailures > 0 { + f.slotGrantsFailures-- + return errors.New("spicedb unavailable") + } f.slotGrantsNS = ns f.slotGrantsName = name f.order = append(f.order, "slots") + delete(f.stalePresent, ns+"/"+name) return nil } @@ -1484,7 +1512,13 @@ func TestFinalize_DeletesSpiceDBRelationships(t *testing.T) { // agentsession-as-RESOURCE wipe above cannot reach them. Missing this call // leaks live authority on somebody else's resource for as long as the // retained AgentSession CR keeps slot_grant->interact resolving. - assert.Equal(t, 1, deleter.slotGrantsCalls, "finalize must also collect the session's slot grants") + // + // TWO calls, not one: the admission sweep (during bootstrap, on the + // reconcile that adds the finalizer) ALSO calls DeleteSlotGrants, to wipe + // any stale slot_pin/slot_grant_* left behind by a dead predecessor that + // reused this session's name — see TestReconcile_AdmissionSweepsStaleSlotTuples. + assert.Equal(t, 2, deleter.slotGrantsCalls, + "the admission sweep and finalize must each collect the session's slot grants") assert.Equal(t, "default", deleter.slotGrantsNS, "DeleteSlotGrants called with namespace") assert.Equal(t, "sess-spicedb", deleter.slotGrantsName, "DeleteSlotGrants called with name") // Data slot grants are a THIRD sweep and neither of the others reaches @@ -1497,8 +1531,9 @@ func TestFinalize_DeletesSpiceDBRelationships(t *testing.T) { assert.Equal(t, "default", deleter.dataSlotsNS, "DeleteDataSlotGrants called with namespace") assert.Equal(t, "sess-spicedb", deleter.dataSlotsName, "DeleteDataSlotGrants called with name") - assert.Equal(t, []string{"slots", "dataSlots", "session"}, deleter.order, - "grants that sit on OTHER objects go first — authority then data — because they are the half that outlives the session") + assert.Equal(t, []string{"slots", "slots", "dataSlots", "session"}, deleter.order, + "the admission sweep's slot-grant call lands first (during bootstrap); at finalize, grants that sit on "+ + "OTHER objects still go first — authority then data — because they are the half that outlives the session") // Finalizer should be removed. var gone spiceboxv1alpha1.AgentSession @@ -1510,6 +1545,173 @@ func TestFinalize_DeletesSpiceDBRelationships(t *testing.T) { } } +// TestReconcile_AdmissionSweepsStaleSlotTuples: a brand-new AgentSession whose +// name was reused from a dead predecessor (killed without ever reaching +// finalize, or a name recycled by a user) must not inherit that predecessor's +// slot_pin/slot_grant_* tuples. A pin never expires, so without a sweep at +// admission the new session would be refused its own first bind by a pin it +// never earned. The sweep must fire exactly once — on the reconcile that adds +// the finalizer — not on every reconcile of the session. +func TestReconcile_AdmissionSweepsStaleSlotTuples(t *testing.T) { + env := testenv.Shared(t) + ctx := memory.WithSystemApproval(context.Background(), "test") + + deleter := &fakeSpiceDBDeleter{} + r := newReconciler(t, env) + r.SpiceDBDeleter = deleter + + ac := validClass("ac-admission-sweep") + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + sess := validSession("sess-admission-sweep", "ac-admission-sweep") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + + // Seed the stale tuples a dead predecessor with this same name left + // behind, BEFORE this session is ever reconciled. + deleter.seedStaleSlotTuples(sess.Namespace, sess.Name) + require.True(t, deleter.hasStaleSlotTuples(sess.Namespace, sess.Name), "fixture sanity: the stale tuples must be seeded") + + // First reconcile: adds the finalizer and must run the admission sweep + // before returning. + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(sess)}) + require.NoError(t, err, "Reconcile (admission)") + + assert.Equal(t, 1, deleter.slotGrantsCalls, "the admission sweep must call DeleteSlotGrants exactly once") + assert.Equal(t, sess.Namespace, deleter.slotGrantsNS) + assert.Equal(t, sess.Name, deleter.slotGrantsName) + assert.False(t, deleter.hasStaleSlotTuples(sess.Namespace, sess.Name), + "the stale slot_pin/slot_grant_* tuples must be gone after admission") + + // Further reconciles of the same session must NOT sweep again — the + // finalizer is already present, so EnsureFinalizer's added=false branch + // is taken every time after the first. + for i := 0; i < 3; i++ { + _, _ = r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(sess)}) + } + assert.Equal(t, 1, deleter.slotGrantsCalls, "the admission sweep must run exactly once per session, not on every reconcile") +} + +// A FAILED admission sweep must be retried, not skipped. The sweep used to run +// after the finalizer write, so the requeue saw the finalizer present and never +// swept again, letting the session proceed with a predecessor's pin live under +// its name. The finalizer is now added only after a sweep succeeds. +func TestReconcile_AdmissionSweepRetriesAfterFailure(t *testing.T) { + env := testenv.Shared(t) + ctx := memory.WithSystemApproval(context.Background(), "test") + + deleter := &fakeSpiceDBDeleter{slotGrantsFailures: 1} + r := newReconciler(t, env) + r.SpiceDBDeleter = deleter + + ac := validClass("ac-admission-sweep-retry") + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + sess := validSession("sess-admission-sweep-retry", "ac-admission-sweep-retry") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + deleter.seedStaleSlotTuples(sess.Namespace, sess.Name) + key := client.ObjectKeyFromObject(sess) + + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + require.Error(t, err, "a failed sweep must fail the reconcile") + var got spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &got)) + assert.NotContains(t, got.Finalizers, spiceboxv1alpha1.FinalizerAgentSession, + "the finalizer must not be added until the sweep succeeds") + assert.True(t, deleter.hasStaleSlotTuples(sess.Namespace, sess.Name), "fixture sanity: the failed sweep cleared nothing") + + _, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + require.NoError(t, err, "Reconcile (retry)") + assert.Equal(t, 2, deleter.slotGrantsCalls, "the requeue must sweep again") + assert.False(t, deleter.hasStaleSlotTuples(sess.Namespace, sess.Name), + "the stale tuples must be gone once the retried sweep succeeds") + require.NoError(t, env.Client.Get(ctx, key, &got)) + assert.Contains(t, got.Finalizers, spiceboxv1alpha1.FinalizerAgentSession) +} + +// TestReconcile_AdmissionSweepSparesAForkChildsCopiedTuples: the parent's +// ReconcileRestart copies slot grants + pin onto the fork child's (ns, name) +// BEFORE the child's own first reconcile runs (BuildChildSession creates the +// child with no finalizer). If the admission sweep ran on the child, it would +// be the LAST writer and wipe that copied authority: every clean-path +// non-takeover fork would come back unpinned with its inherited grants +// deleted. A fork child's name is generated (PendingRestart.TargetSessionName), +// so the dead-predecessor name-reuse the sweep guards against cannot happen +// to it — the sweep must skip any session whose spec marks it forked. +func TestReconcile_AdmissionSweepSparesAForkChildsCopiedTuples(t *testing.T) { + env := testenv.Shared(t) + ctx := memory.WithSystemApproval(context.Background(), "test") + + deleter := &fakeSpiceDBDeleter{} + r := newReconciler(t, env) + r.SpiceDBDeleter = deleter + + ac := validClass("ac-fork-no-sweep") + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + child := validSession("sess-fork-no-sweep-child", "ac-fork-no-sweep") + child.Spec.ForkedFrom = "sess-fork-no-sweep-parent" // what BuildChildSession stamps on every restart child + require.NoError(t, env.Client.Create(ctx, child), "create fork-child AgentSession") + + // These stand in for the grants + pin the parent's ReconcileRestart + // already copied onto the child's name — legitimate authority, not a + // stale leftover. + deleter.seedStaleSlotTuples(child.Namespace, child.Name) + + // The child's first reconcile adds the finalizer; the admission sweep + // must NOT fire for a fork child. + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(child)}) + require.NoError(t, err, "Reconcile (fork child admission)") + + assert.Zero(t, deleter.slotGrantsCalls, + "the admission sweep must not run for a fork child — it would wipe the authority the parent's restart just copied") + assert.True(t, deleter.hasStaleSlotTuples(child.Namespace, child.Name), + "the copied grant+pin tuples must survive the child's first reconcile") +} + +// TestReconcile_AdmissionSweepSkippedWhenFinalizerPreStamped: a channelsd mint +// pre-stamps the operator's finalizer at Create and sweeps stale slot tuples +// itself, synchronously, before its mint-time binds. So when the operator first +// reconciles such a session the finalizer is ALREADY present: EnsureFinalizer +// returns added=false, the added=true branch (and its admission sweep) never +// runs, and the operator must NOT wipe the tuples channelsd just wrote. This is +// the restart-variant of the sweep guard — a session that already carries the +// finalizer at first reconcile must take the added=false path. +func TestReconcile_AdmissionSweepSkippedWhenFinalizerPreStamped(t *testing.T) { + env := testenv.Shared(t) + ctx := memory.WithSystemApproval(context.Background(), "test") + + deleter := &fakeSpiceDBDeleter{} + r := newReconciler(t, env) + r.SpiceDBDeleter = deleter + + ac := validClass("ac-prestamped-finalizer") + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + sess := validSession("sess-prestamped-finalizer", "ac-prestamped-finalizer") + // The channelsd mint path stamps this at Create (pipeline.go), which is what + // suppresses the operator's own sweep for a channelsd-minted session. + sess.Finalizers = append(sess.Finalizers, spiceboxv1alpha1.FinalizerAgentSession) + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + + // Stand-in for the pin + grants channelsd legitimately wrote at mint under + // this name. The operator must leave them alone. + deleter.seedStaleSlotTuples(sess.Namespace, sess.Name) + + // First reconcile: finalizer already present → added=false → no admission + // block, no sweep. Nothing outside the admission block or finalize calls + // DeleteSlotGrants, so its count must stay zero. + _, _ = r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(sess)}) + + assert.Zero(t, deleter.slotGrantsCalls, + "a pre-stamped finalizer takes the added=false path, so the operator's admission sweep must not fire for a channelsd mint") + assert.True(t, deleter.hasStaleSlotTuples(sess.Namespace, sess.Name), + "the operator must not wipe the tuples channelsd wrote under a session whose finalizer it pre-stamped") +} + // ownerCapturingGranter implements authz.Granter and records TouchOwner calls. type ownerCapturingGranter struct { ownerCalled bool diff --git a/pkg/controllers/agentsession/restart_envtest_test.go b/pkg/controllers/agentsession/restart_envtest_test.go index a37a4f0c..5e685894 100644 --- a/pkg/controllers/agentsession/restart_envtest_test.go +++ b/pkg/controllers/agentsession/restart_envtest_test.go @@ -471,20 +471,25 @@ func TestEnvtest_RestartFromHere_TriggeredByStatusPatch(t *testing.T) { } // recordingSlotCopier records whether the fork carried the parent's bound -// instances onto the child. +// instances (and pins) onto the child. copied is every relation +// authz.CopySlotGrants asked it to write — the gate-bypassing verbatim copy +// writes through CopySlotTuples alone, not GrantSlots. type recordingSlotCopier struct { - held []authz.SlotBinding - grantedTo string - granted []authz.SlotBinding + held []authz.SlotBinding + pins []authz.Relation + copied []authz.Relation } func (c *recordingSlotCopier) ListSlotGrants(_ context.Context, _, _ string) ([]authz.SlotBinding, error) { return c.held, nil } -func (c *recordingSlotCopier) GrantSlots(_ context.Context, ns, name string, b []authz.SlotBinding, _ time.Time) error { - c.grantedTo = ns + "/" + name - c.granted = append(c.granted, b...) +func (c *recordingSlotCopier) ListSlotPins(_ context.Context, _, _ string) ([]authz.Relation, error) { + return c.pins, nil +} + +func (c *recordingSlotCopier) CopySlotTuples(_ context.Context, rels []authz.Relation) error { + c.copied = append(c.copied, rels...) return nil } @@ -568,9 +573,8 @@ func TestEnvtest_Takeover_CarriesNoSlotGrantsToTheNewOwner(t *testing.T) { require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: ns, Name: "parent-takeover-slots-child"}, &child)) assert.Equal(t, "user:bob", child.Annotations[spiceboxv1alpha1.AnnotationStartedByCanonicalID]) - assert.Empty(t, copier.granted, + assert.Empty(t, copier.copied, "a takeover must carry NO slot grants: the new owner would inherit instance authority a human approved for someone else") - assert.Empty(t, copier.grantedTo) } // TestEnvtest_Inherit_CarriesSlotGrantsToTheChild is the companion the @@ -645,7 +649,10 @@ func TestEnvtest_Inherit_CarriesSlotGrantsToTheChild(t *testing.T) { _, _, err := r.ReconcileRestart(ctx, parent) require.NoError(t, err) - assert.Equal(t, ns+"/parent-inherit-slots-child", copier.grantedTo, + require.Len(t, copier.copied, len(copier.held), "the same-owner continuation must receive the parent's bound instances") - assert.ElementsMatch(t, copier.held, copier.granted) + childScope := authz.SessionRef{Namespace: ns, Name: "parent-inherit-slots-child"} + for _, rel := range copier.copied { + assert.Equal(t, childScope.String(), rel.SubjectID, "a copied grant must be re-pointed at the child") + } } diff --git a/pkg/controllers/webhooks/agentsession/webhook.go b/pkg/controllers/webhooks/agentsession/webhook.go index 3f84682d..efc3b075 100644 --- a/pkg/controllers/webhooks/agentsession/webhook.go +++ b/pkg/controllers/webhooks/agentsession/webhook.go @@ -594,6 +594,10 @@ var runnerWritableStatusFields = map[string]string{ "session's own retry of an already-idempotent write, denying its own starter no more than the runner could " + "already withhold by declining to run at all — self-scoped, and any other session sharing that class/subject " + "still attempts the same touch", + "slotPins": "a display-only mirror of slot pin state written by StatusPatcher (pkg/agent/runner/status.go). " + + "Every enforcement decision reads SpiceDB directly (the slot_pin tuple and the MUST_MATCH preconditions on " + + "grant writes), and the approval card derives move state from a live SpiceDB ReadPin rather than this field, " + + "so a forged value only misdescribes the forging session's own `oap session show` output and buys no authority anywhere", } // Webhook validates AgentSession update admission requests. diff --git a/pkg/memory/kinds/plangateaudit/kind.go b/pkg/memory/kinds/plangateaudit/kind.go index 86efb3f0..9e89dc05 100644 --- a/pkg/memory/kinds/plangateaudit/kind.go +++ b/pkg/memory/kinds/plangateaudit/kind.go @@ -109,6 +109,23 @@ type SlotRef struct { // from the CRD shape (see pkg/authz/hooks/activation.go); callers compare // against the same string constants. Standing string `json:"standing,omitempty"` + + // MovedFrom, when non-empty, is the instance this slot was CURRENTLY pinned + // to when the approval card was built — recorded ONLY when the card named a + // DIFFERENT instance of a single-occupancy slot, i.e. when approving this + // card MOVES a filled slot rather than filling an empty one. It is the + // already-canonical object id read back from the pin (SlotPinner.ReadPin), + // not the raw value the phase declared, so the decision path can derive the + // move's PriorID from it directly. + // + // It is written at CARD-BUILD time, off an advisory read, so the human + // approves exactly the move they were shown: a card parked for days executes + // the move it displayed, never one the pin silently drifted into. Empty is + // the overwhelmingly common first-fill case — an empty slot, a + // multi-occupancy slot (which holds no pin), or a card built with no pinner + // wired. Additive, like Standing: a record written before this field existed + // unmarshals with it empty, which reads as "no move" everywhere downstream. + MovedFrom string `json:"movedFrom,omitempty"` } // Content is one plan-gate record. diff --git a/pkg/memory/kinds/plangateaudit/kind_test.go b/pkg/memory/kinds/plangateaudit/kind_test.go index 47fe46a4..3c7a5490 100644 --- a/pkg/memory/kinds/plangateaudit/kind_test.go +++ b/pkg/memory/kinds/plangateaudit/kind_test.go @@ -124,6 +124,38 @@ func TestContent_StandingRoundTrips(t *testing.T) { assert.Equal(t, "session-only", out.SlotRefs[0].Standing) } +// SlotRef.MovedFrom is ADDITIVE for the same append-only reason as Standing: a +// record written before the field existed must still unmarshal, with it empty +// (read everywhere downstream as "no move"). +func TestContent_MovedFromIsAdditive(t *testing.T) { + const legacy = `{"event":"phase_approved","phaseKey":"k1","slotRefs":[{"type":"git_repo","id":"acme/app"}]}` + var c Content + require.NoError(t, json.Unmarshal([]byte(legacy), &c)) + require.Len(t, c.SlotRefs, 1) + assert.Equal(t, "", c.SlotRefs[0].MovedFrom, "absent, not invalid") +} + +// A record written WITH MovedFrom round-trips it, and it is omitted when empty +// so a first-fill record is byte-identical to one written before the field. +func TestContent_MovedFromRoundTripsAndOmitsWhenEmpty(t *testing.T) { + in := Content{ + Event: EventPhaseApproved, + SlotRefs: []SlotRef{{Type: "git_repo", ID: "acme/new", MovedFrom: "acme/old"}}, + } + b, err := json.Marshal(in) + require.NoError(t, err) + assert.Contains(t, string(b), `"movedFrom":"acme/old"`) + + var out Content + require.NoError(t, json.Unmarshal(b, &out)) + require.Len(t, out.SlotRefs, 1) + assert.Equal(t, "acme/old", out.SlotRefs[0].MovedFrom) + + firstFill, err := json.Marshal(Content{SlotRefs: []SlotRef{{Type: "git_repo", ID: "acme/new"}}}) + require.NoError(t, err) + assert.NotContains(t, string(firstFill), "movedFrom", "a first-fill records no move") +} + // NOTE: registration is NOT asserted here. This package's init() registers the // kind in its own test binary whether or not kinds/all imports it, so such a // test would assert nothing. The real assertion lives in pkg/memory/kinds/all. diff --git a/pkg/platform/manifests/install.yaml b/pkg/platform/manifests/install.yaml index c0e362f8..e7a62b18 100644 --- a/pkg/platform/manifests/install.yaml +++ b/pkg/platform/manifests/install.yaml @@ -732,6 +732,30 @@ spec: - dynamic - frozen type: string + occupancy: + default: single + description: |- + Occupancy decides how many instances may occupy this slot at once. + + single (default): at most one instance occupies the slot for the + session's life, recorded as a non-expiring slot_pin relationship. A + bind to a DIFFERENT instance is refused rather than silently added. + A second permission on the SAME instance, and a re-grant of the same + instance after its grants expired, are not drift and still bind. + + multi: the slot binds a set; each addition is gated as it is today. + No pin is written. + + UPGRADE NOTE. Before this field existed every slot behaved as multi, and a + class written then has no occupancy set, so the apiserver now defaults it + to single. A slot listing two or more defaults is then refused + (SlotDeclarationInvalid), and a thread seed or trigger binding several + instances of one type is refused rather than binding them all. Set + occupancy: multi on such slots before upgrading. + enum: + - single + - multi + type: string permission: minLength: 1 pattern: ^[a-z][a-z0-9_]*$ @@ -758,6 +782,21 @@ spec: type: string type: array x-kubernetes-list-type: atomic + rebind: + default: approval + description: |- + Rebind decides what a bind to a DIFFERENT instance does on a filled + single-occupancy slot. Meaningful only when occupancy is single + (as triggerInstance is meaningful only with fillFrom "trigger"). + + approval (default): refused, and the refusal names the route — an + approved plan amendment naming the new instance moves the pin and + revokes the old instance's grants. + never: refused unconditionally; retargeting requires a new session. + enum: + - approval + - never + type: string requires: description: |- Requires are predicates over the facts a candidate arrived with, all of @@ -1055,6 +1094,30 @@ spec: - dynamic - frozen type: string + occupancy: + default: single + description: |- + Occupancy decides how many instances may occupy this slot at once. + + single (default): at most one instance occupies the slot for the + session's life, recorded as a non-expiring slot_pin relationship. A + bind to a DIFFERENT instance is refused rather than silently added. + A second permission on the SAME instance, and a re-grant of the same + instance after its grants expired, are not drift and still bind. + + multi: the slot binds a set; each addition is gated as it is today. + No pin is written. + + UPGRADE NOTE. Before this field existed every slot behaved as multi, and a + class written then has no occupancy set, so the apiserver now defaults it + to single. A slot listing two or more defaults is then refused + (SlotDeclarationInvalid), and a thread seed or trigger binding several + instances of one type is refused rather than binding them all. Set + occupancy: multi on such slots before upgrading. + enum: + - single + - multi + type: string permission: minLength: 1 pattern: ^[a-z][a-z0-9_]*$ @@ -1081,6 +1144,21 @@ spec: type: string type: array x-kubernetes-list-type: atomic + rebind: + default: approval + description: |- + Rebind decides what a bind to a DIFFERENT instance does on a filled + single-occupancy slot. Meaningful only when occupancy is single + (as triggerInstance is meaningful only with fillFrom "trigger"). + + approval (default): refused, and the refusal names the route — an + approved plan amendment naming the new instance moves the pin and + revokes the old instance's grants. + never: refused unconditionally; retargeting requires a new session. + enum: + - approval + - never + type: string requires: description: |- Requires are predicates over the facts a candidate arrived with, all of @@ -8900,6 +8978,63 @@ spec: gone. Diagnostic + drives the reap decision's "already slept" guard. format: date-time type: string + slotPins: + description: |- + SlotPins is a DISPLAY-ONLY observation, one entry per single-occupancy + resource type, mirroring the instance SpiceDB's slot_pin relation + currently pins this session to. SpiceDB is the enforcement source of + truth for single-occupancy pinning; nothing that authorizes a bind or a + tool call reads this field. See SlotPin's doc comment for what writes + it and what MovedBy/MovedAt mean. + items: + description: |- + SlotPin is a DISPLAY-ONLY mirror of one single-occupancy slot's current + commitment — the instance SpiceDB's slot_pin relation pins the session to + for ResourceType, written by authz.GrantSlots (via SlotPinner.EnsurePin) on + a first bind and repointed by authz.BindApproved's move path + (SlotPinner.MovePin) when an approved plan moves it. + + SpiceDB is the enforcement source of truth: the slot_pin tuple is what a + tool-call Check and GrantSlots' own single-occupancy gate actually consult. + Nothing that authorizes a bind or a call reads this field — it exists so + `oap session show` and operator logs can see the commitment without a live + SpiceDB read, and it can lag or be absent (e.g. no status writer wired) + without changing what is actually enforced. + properties: + movedAt: + description: |- + MovedAt is when MovedBy's move landed on this pin. Empty for a pin that + has only ever been first-filled. + format: date-time + type: string + movedBy: + description: |- + MovedBy is the approval-record reference that authorized the most + recent MOVE off a prior instance — empty for a pin that has only ever + been first-filled. Populated only by the plan-gate approval path + (pkg/agent/runner's narrowToApproved), from the plan-gate audit record + that carried the approval: its PhaseKey, or PlanDigest+PhaseIndex when + PhaseKey is unset on an older record. + type: string + resourceID: + description: ResourceID is the instance ResourceType is currently + pinned to. + type: string + resourceType: + description: |- + ResourceType is the slot's declared resource type, and the key + UpsertSlotPin replaces by — a single-occupancy slot holds at most one + instance per type for the session's life, so one entry per type is + always the complete picture. + type: string + required: + - resourceID + - resourceType + type: object + type: array + x-kubernetes-list-map-keys: + - resourceType + x-kubernetes-list-type: map startApprovalParkedAt: description: |- StartApprovalParkedAt is when the session entered AwaitingStartApproval diff --git a/pkg/platform/oap/install/apply.go b/pkg/platform/oap/install/apply.go index efdf41df..b482eb20 100644 --- a/pkg/platform/oap/install/apply.go +++ b/pkg/platform/oap/install/apply.go @@ -467,28 +467,45 @@ func stampOapSource(agentClass *unstructured.Unstructured, version string, opts return nil } -// completeAuthzSlotMembershipDefaults fills spec.authz.slots[].membership -// with v1alpha1.AuthzSlotMembershipDefault wherever a slot leaves it absent or -// empty — which is the normal case, since no bundle author spells out a -// value that only ever mirrors the field's own +kubebuilder:default. +// completeSlotStringField sets slot[key] to def when unset or empty, so the +// installer writes what the apiserver would have defaulted. A non-string +// value is an error naming the slot index — a malformed slot must refuse, +// not be silently overwritten. +func completeSlotStringField(slot map[string]interface{}, i int, key, def string) (bool, error) { + v, _, err := unstructured.NestedString(slot, key) + if err != nil { + return false, fmt.Errorf("read spec.authz.slots[%d].%s: %w", i, key, err) + } + if v != "" { + return false, nil + } + slot[key] = def + return true, nil +} + +// completeAuthzSlotDefaults fills spec.authz.slots[].membership, +// .occupancy, and .rebind with their v1alpha1.AuthzSlot*Default constants +// wherever a slot leaves one absent or empty — which is the normal case, +// since no bundle author spells out a value that only ever mirrors the +// field's own +kubebuilder:default. // // It exists because AgentClassSpec.Authz.Slots is +listType=atomic // (pkg/apis/v1alpha1/agentclass_types.go): SSA treats the whole list as one // value, so a re-applied slot list REPLACES the live one at merge time rather -// than merging element by element. A slot that omits membership therefore -// applies as a slot the apiserver has since defaulted; the merged object -// differs from the live one by exactly that field, the field manager records -// a change even though the apiserver re-defaults membership on the way to -// storage and the stored spec ends up unchanged. That is a real violation of -// "a byte-identical re-apply must be a no-op" (AGENTS.md), even though -// nothing in the stored spec ever moves. Writing the default into the -// payload ourselves keeps what install applies identical to what the +// than merging element by element. A slot that omits one of these fields +// therefore applies as a slot the apiserver has since defaulted; the merged +// object differs from the live one by exactly that field, the field manager +// records a change even though the apiserver re-defaults the field on the +// way to storage and the stored spec ends up unchanged. That is a real +// violation of "a byte-identical re-apply must be a no-op" (AGENTS.md), even +// though nothing in the stored spec ever moves. Writing the defaults into +// the payload ourselves keeps what install applies identical to what the // apiserver would store either way. // -// A slot that already sets membership — default or not — is left exactly as +// A slot that already sets a field — default or not — is left exactly as // declared; a class with no authz.slots at all is left untouched rather than // gaining a fabricated empty list (itself an applied-field change). -func completeAuthzSlotMembershipDefaults(agentClass *unstructured.Unstructured) error { +func completeAuthzSlotDefaults(agentClass *unstructured.Unstructured) error { slots, found, err := unstructured.NestedSlice(agentClass.Object, "spec", "authz", "slots") if err != nil { return fmt.Errorf("read spec.authz.slots: %w", err) @@ -503,15 +520,20 @@ func completeAuthzSlotMembershipDefaults(agentClass *unstructured.Unstructured) if !ok { return fmt.Errorf("spec.authz.slots[%d] is %T, not an object", i, s) } - membership, _, err := unstructured.NestedString(slot, "membership") - if err != nil { - return fmt.Errorf("read spec.authz.slots[%d].membership: %w", i, err) - } - if membership != "" { - continue + for _, field := range []struct { + key string + def string + }{ + {"membership", v1alpha1.AuthzSlotMembershipDefault}, + {"occupancy", v1alpha1.AuthzSlotOccupancyDefault}, + {"rebind", v1alpha1.AuthzSlotRebindDefault}, + } { + fieldChanged, err := completeSlotStringField(slot, i, field.key, field.def) + if err != nil { + return err + } + changed = changed || fieldChanged } - slot["membership"] = v1alpha1.AuthzSlotMembershipDefault - changed = true } if !changed { return nil diff --git a/pkg/platform/oap/install/apply_authzslot_defaults_test.go b/pkg/platform/oap/install/apply_authzslot_defaults_test.go index 90f0f0af..8a5a105b 100644 --- a/pkg/platform/oap/install/apply_authzslot_defaults_test.go +++ b/pkg/platform/oap/install/apply_authzslot_defaults_test.go @@ -12,7 +12,7 @@ import ( // agentClassWithSlots builds a bare AgentClass unstructured object whose // spec.authz.slots is exactly the given slot maps — enough to exercise -// completeAuthzSlotMembershipDefaults without a real bundle or envtest. +// completeAuthzSlotDefaults without a real bundle or envtest. func agentClassWithSlots(t *testing.T, slots []interface{}) *unstructured.Unstructured { t.Helper() ac := &unstructured.Unstructured{} @@ -42,7 +42,7 @@ func TestCompleteAuthzSlotMembershipDefaults_FillsAbsentMembership(t *testing.T) map[string]interface{}{"resourceType": "git_repo", "permission": "read"}, }) - require.NoError(t, completeAuthzSlotMembershipDefaults(ac)) + require.NoError(t, completeAuthzSlotDefaults(ac)) slots := slotsOf(t, ac) require.Len(t, slots, 1) @@ -61,7 +61,7 @@ func TestCompleteAuthzSlotMembershipDefaults_LeavesEmptyStringFilled(t *testing. map[string]interface{}{"resourceType": "git_repo", "permission": "read", "membership": ""}, }) - require.NoError(t, completeAuthzSlotMembershipDefaults(ac)) + require.NoError(t, completeAuthzSlotDefaults(ac)) slots := slotsOf(t, ac) slot, ok := slots[0].(map[string]interface{}) @@ -79,7 +79,7 @@ func TestCompleteAuthzSlotMembershipDefaults_LeavesExplicitDynamicAlone(t *testi map[string]interface{}{"resourceType": "git_repo", "permission": "read", "membership": "dynamic"}, }) - require.NoError(t, completeAuthzSlotMembershipDefaults(ac)) + require.NoError(t, completeAuthzSlotDefaults(ac)) slots := slotsOf(t, ac) slot, ok := slots[0].(map[string]interface{}) @@ -97,7 +97,7 @@ func TestCompleteAuthzSlotMembershipDefaults_NoSlotsIsUntouched(t *testing.T) { ac.SetName("demo-class") before := ac.DeepCopy() - require.NoError(t, completeAuthzSlotMembershipDefaults(ac)) + require.NoError(t, completeAuthzSlotDefaults(ac)) assert.Equal(t, before.Object, ac.Object, "a class with no slots must be left byte-identical") } @@ -112,7 +112,7 @@ func TestCompleteAuthzSlotMembershipDefaults_MixedSlotsOnlyFillsAbsent(t *testin map[string]interface{}{"resourceType": "slack_channel", "permission": "read", "membership": "frozen"}, }) - require.NoError(t, completeAuthzSlotMembershipDefaults(ac)) + require.NoError(t, completeAuthzSlotDefaults(ac)) slots := slotsOf(t, ac) require.Len(t, slots, 3) @@ -124,6 +124,24 @@ func TestCompleteAuthzSlotMembershipDefaults_MixedSlotsOnlyFillsAbsent(t *testin assert.Equal(t, []string{"frozen", "dynamic", "frozen"}, got) } +// TestCompleteAuthzSlotDefaults_FillsOccupancyAndRebind pins the new fields +// riding the same completion as membership: a slot that leaves occupancy +// and/or rebind unset must be completed with their +kubebuilder:default +// values, for the same SSA-idempotency reason membership is. +func TestCompleteAuthzSlotDefaults_FillsOccupancyAndRebind(t *testing.T) { + ac := &unstructured.Unstructured{Object: map[string]any{ + "spec": map[string]any{"authz": map[string]any{"slots": []any{ + map[string]any{"resourceType": "git_repo", "permission": "push"}, + }}}, + }} + require.NoError(t, completeAuthzSlotDefaults(ac)) + slots, _, _ := unstructured.NestedSlice(ac.Object, "spec", "authz", "slots") + slot := slots[0].(map[string]any) + assert.Equal(t, v1alpha1.AuthzSlotOccupancyDefault, slot["occupancy"]) + assert.Equal(t, v1alpha1.AuthzSlotRebindDefault, slot["rebind"]) + assert.Equal(t, v1alpha1.AuthzSlotMembershipDefault, slot["membership"]) +} + // TestCompleteAuthzSlotMembershipDefaults_MalformedSlotIsAnError pins the // error path: a slots entry that is not an object, or a membership that is // not a string, is refused with a message naming the index — never guessed @@ -142,7 +160,7 @@ func TestCompleteAuthzSlotMembershipDefaults_MalformedSlotIsAnError(t *testing.T for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ac := agentClassWithSlots(t, tc.slots) - err := completeAuthzSlotMembershipDefaults(ac) + err := completeAuthzSlotDefaults(ac) require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) }) diff --git a/pkg/platform/oap/install/apply_integration_test.go b/pkg/platform/oap/install/apply_integration_test.go index ab0b4e1f..a7cd453a 100644 --- a/pkg/platform/oap/install/apply_integration_test.go +++ b/pkg/platform/oap/install/apply_integration_test.go @@ -179,7 +179,7 @@ func TestInstall_StampsOapSourceAnnotationOnAgentClass(t *testing.T) { // // install.Install now completes each slot's membership with // v1alpha1.AuthzSlotMembershipDefault before the SSA-apply loop -// (completeAuthzSlotMembershipDefaults, apply.go) whenever a bundle leaves it +// (completeAuthzSlotDefaults, apply.go) whenever a bundle leaves it // unset, which is what the CRD would have defaulted onto the stored object // anyway — so the applied payload matches what the apiserver stores, the // merge is a true no-op, and resourceVersion no longer moves across the diff --git a/pkg/platform/oap/install/prepare.go b/pkg/platform/oap/install/prepare.go index d292d0cf..3b1620d8 100644 --- a/pkg/platform/oap/install/prepare.go +++ b/pkg/platform/oap/install/prepare.go @@ -268,12 +268,12 @@ func Prepare(ctx context.Context, c client.Client, b *oap.Bundle, answers oap.An return nil, fmt.Errorf("install: stamp oap-source annotation: %w", err) } - // Same reasoning, same place: complete every slot's membership BEFORE the - // SSA-apply loop so the applied payload matches what the apiserver would - // store, keeping a byte-identical re-install a true no-op. See - // completeAuthzSlotMembershipDefaults's doc comment for why this is + // Same reasoning, same place: complete every slot's membership, occupancy, + // and rebind BEFORE the SSA-apply loop so the applied payload matches what + // the apiserver would store, keeping a byte-identical re-install a true + // no-op. See completeAuthzSlotDefaults's doc comment for why this is // necessary at all (spec.authz.slots is +listType=atomic). - if err := completeAuthzSlotMembershipDefaults(agentClass); err != nil { + if err := completeAuthzSlotDefaults(agentClass); err != nil { return nil, fmt.Errorf("install: complete authz slot defaults: %w", err) } diff --git a/site/content/docs/crd-agentclass.mdx b/site/content/docs/crd-agentclass.mdx index 6d1ff47f..5d83da63 100644 --- a/site/content/docs/crd-agentclass.mdx +++ b/site/content/docs/crd-agentclass.mdx @@ -83,8 +83,10 @@ session closed rather than starting it degraded. | `spec.authz.slots[].extractionPrompt` | `string` | | | `spec.authz.slots[].fillFrom` | `[]string` | FillFrom names how an instance may come to occupy this slot. Unset means today's behavior: class-pinned defaults bind at session start and the extractor may propose instances — for every source EXCEPT trigger (below), an unset list narrows nothing. - default class-pinned IDs, bound at session start - query/extract the user names an instance in a message, unprompted - ask the agent prompts and waits, and the user names it in the reply. Binds through the SAME path as query — the two differ in who started the exchange, not in how the value binds — so declaring ask needs no extra tool: the agent asks with respond_to_user and waits with await_user_message. - channel_thread the channel seeds candidates from the thread it was minted from, governed by AutoGrantFrom - metaagent ambient scope intent, human-approved. NOT yet implemented as a binding path: a slot naming only metaagent binds nothing today. - observed a tool result recorded a fact about the instance. NOT yet implemented as a binding path: a slot naming only observed binds nothing today. - trigger the pipeline binds the instances the signed webhook delivery names, at session mint. The ONE source that is NEVER implied by an unset list: naming it puts a session-mint SpiceDB grant behind no human and no second gate, so a slot must name "trigger" here explicitly, or it binds nothing from a trigger however permissive (or absent) its other fillFrom entries read. | | `spec.authz.slots[].membership` | `string` | Membership decides whether a grant follows the session's member set as it grows, or is pinned to the set present when it was approved. Defaults to frozen. The appealing argument for dynamic is that the approver grants to a SESSION and trusts its governance — but that only holds while session membership is hard to obtain, and it is not: thread adoption admits recent authors, and a channel-kind link can make one tuple mean an entire channel. Following a set that grows on its own is therefore an explicit choice, not the quiet default. Set dynamic where the session really is a small owned thread and the approval card has shown the approver the concrete subject set. _(enum: dynamic \| frozen; default: frozen)_ | +| `spec.authz.slots[].occupancy` | `string` | Occupancy decides how many instances may occupy this slot at once. single (default): at most one instance occupies the slot for the session's life, recorded as a non-expiring slot_pin relationship. A bind to a DIFFERENT instance is refused rather than silently added. A second permission on the SAME instance, and a re-grant of the same instance after its grants expired, are not drift and still bind. multi: the slot binds a set; each addition is gated as it is today. No pin is written. UPGRADE NOTE. Before this field existed every slot behaved as multi, and a class written then has no occupancy set, so the apiserver now defaults it to single. A slot listing two or more defaults is then refused (SlotDeclarationInvalid), and a thread seed or trigger binding several instances of one type is refused rather than binding them all. Set occupancy: multi on such slots before upgrading. _(enum: single \| multi; default: single)_ | | `spec.authz.slots[].permission` \* | `string` | | | `spec.authz.slots[].permissions` | `[]string` | Permissions are the ADDITIONAL permissions a grant on this slot may confer, beyond Permission. A slot used to be one (resourceType, permission) pair fixed at declaration time. That could not represent a real type: git_repo is reached by read, write, fetch AND push, and a plan that later needs `read` on a slot declared for `push` had nowhere to bind it. The approval was recorded, no grant could be written for it, and the very next call escalated again — a human clicking Approve on the same amendment forever. Every permission named here gets its own `slot_grant_<permission>` relation in the composed SpiceDB schema, which is what makes a grant for it writable at all. Declaring a permission does NOT grant it: a grant is still written only for what an approval actually named, still scoped to the session, still expiring and revocable. This is the CEILING of what an approval on this slot may ever bind. | +| `spec.authz.slots[].rebind` | `string` | Rebind decides what a bind to a DIFFERENT instance does on a filled single-occupancy slot. Meaningful only when occupancy is single (as triggerInstance is meaningful only with fillFrom "trigger"). approval (default): refused, and the refusal names the route — an approved plan amendment naming the new instance moves the pin and revokes the old instance's grants. never: refused unconditionally; retargeting requires a new session. _(enum: approval \| never; default: approval)_ | | `spec.authz.slots[].requires` | `[]object` | Requires are predicates over the facts a candidate arrived with, all of which must be Satisfied before it may occupy this slot. Any Refused or Undetermined verdict holds the slot closed. | | `spec.authz.slots[].requires[].approvers` | `[]string` | Approvers routes the WAIVER card for a Refused verdict, as SpiceDB subject-set expressions (e.g. "agentsession:{ns}/{name}#approve"). The card asks a RISK question ("accept running untrusted code from a fork?"), which is not the same as "who may grant read on this resource" — and the resource's owner set is often empty for a userless session, which turns an appealable gate into an unappealable crash. Unset defaults to the slot's resolved standing. | | `spec.authz.slots[].requires[].cel` \* | `string` | CEL is a boolean predicate over `facts` and `slot`. `facts` is addressed as facts.<provenance>.<name>, where provenance is `envelope` (derived by platform code from a signed provider payload) or `observed` (derived from a response to a call the agent shaped). They are deliberately separate namespaces: an author gating on the first must not silently receive the second. `slot` carries {resourceType, resourceID} of the candidate being decided. has() is NOT available over facts. It would let an author turn "not yet known" into a decidable boolean, collapsing the tri-state. | @@ -108,8 +110,10 @@ session closed rather than starting it degraded. | `spec.boundEntities[].extractionPrompt` | `string` | | | `spec.boundEntities[].fillFrom` | `[]string` | FillFrom names how an instance may come to occupy this slot. Unset means today's behavior: class-pinned defaults bind at session start and the extractor may propose instances — for every source EXCEPT trigger (below), an unset list narrows nothing. - default class-pinned IDs, bound at session start - query/extract the user names an instance in a message, unprompted - ask the agent prompts and waits, and the user names it in the reply. Binds through the SAME path as query — the two differ in who started the exchange, not in how the value binds — so declaring ask needs no extra tool: the agent asks with respond_to_user and waits with await_user_message. - channel_thread the channel seeds candidates from the thread it was minted from, governed by AutoGrantFrom - metaagent ambient scope intent, human-approved. NOT yet implemented as a binding path: a slot naming only metaagent binds nothing today. - observed a tool result recorded a fact about the instance. NOT yet implemented as a binding path: a slot naming only observed binds nothing today. - trigger the pipeline binds the instances the signed webhook delivery names, at session mint. The ONE source that is NEVER implied by an unset list: naming it puts a session-mint SpiceDB grant behind no human and no second gate, so a slot must name "trigger" here explicitly, or it binds nothing from a trigger however permissive (or absent) its other fillFrom entries read. | | `spec.boundEntities[].membership` | `string` | Membership decides whether a grant follows the session's member set as it grows, or is pinned to the set present when it was approved. Defaults to frozen. The appealing argument for dynamic is that the approver grants to a SESSION and trusts its governance — but that only holds while session membership is hard to obtain, and it is not: thread adoption admits recent authors, and a channel-kind link can make one tuple mean an entire channel. Following a set that grows on its own is therefore an explicit choice, not the quiet default. Set dynamic where the session really is a small owned thread and the approval card has shown the approver the concrete subject set. _(enum: dynamic \| frozen; default: frozen)_ | +| `spec.boundEntities[].occupancy` | `string` | Occupancy decides how many instances may occupy this slot at once. single (default): at most one instance occupies the slot for the session's life, recorded as a non-expiring slot_pin relationship. A bind to a DIFFERENT instance is refused rather than silently added. A second permission on the SAME instance, and a re-grant of the same instance after its grants expired, are not drift and still bind. multi: the slot binds a set; each addition is gated as it is today. No pin is written. UPGRADE NOTE. Before this field existed every slot behaved as multi, and a class written then has no occupancy set, so the apiserver now defaults it to single. A slot listing two or more defaults is then refused (SlotDeclarationInvalid), and a thread seed or trigger binding several instances of one type is refused rather than binding them all. Set occupancy: multi on such slots before upgrading. _(enum: single \| multi; default: single)_ | | `spec.boundEntities[].permission` \* | `string` | | | `spec.boundEntities[].permissions` | `[]string` | Permissions are the ADDITIONAL permissions a grant on this slot may confer, beyond Permission. A slot used to be one (resourceType, permission) pair fixed at declaration time. That could not represent a real type: git_repo is reached by read, write, fetch AND push, and a plan that later needs `read` on a slot declared for `push` had nowhere to bind it. The approval was recorded, no grant could be written for it, and the very next call escalated again — a human clicking Approve on the same amendment forever. Every permission named here gets its own `slot_grant_<permission>` relation in the composed SpiceDB schema, which is what makes a grant for it writable at all. Declaring a permission does NOT grant it: a grant is still written only for what an approval actually named, still scoped to the session, still expiring and revocable. This is the CEILING of what an approval on this slot may ever bind. | +| `spec.boundEntities[].rebind` | `string` | Rebind decides what a bind to a DIFFERENT instance does on a filled single-occupancy slot. Meaningful only when occupancy is single (as triggerInstance is meaningful only with fillFrom "trigger"). approval (default): refused, and the refusal names the route — an approved plan amendment naming the new instance moves the pin and revokes the old instance's grants. never: refused unconditionally; retargeting requires a new session. _(enum: approval \| never; default: approval)_ | | `spec.boundEntities[].requires` | `[]object` | Requires are predicates over the facts a candidate arrived with, all of which must be Satisfied before it may occupy this slot. Any Refused or Undetermined verdict holds the slot closed. | | `spec.boundEntities[].requires[].approvers` | `[]string` | Approvers routes the WAIVER card for a Refused verdict, as SpiceDB subject-set expressions (e.g. "agentsession:{ns}/{name}#approve"). The card asks a RISK question ("accept running untrusted code from a fork?"), which is not the same as "who may grant read on this resource" — and the resource's owner set is often empty for a userless session, which turns an appealable gate into an unappealable crash. Unset defaults to the slot's resolved standing. | | `spec.boundEntities[].requires[].cel` \* | `string` | CEL is a boolean predicate over `facts` and `slot`. `facts` is addressed as facts.<provenance>.<name>, where provenance is `envelope` (derived by platform code from a signed provider payload) or `observed` (derived from a response to a call the agent shaped). They are deliberately separate namespaces: an author gating on the first must not silently receive the second. `slot` carries {resourceType, resourceID} of the candidate being decided. has() is NOT available over facts. It would let an author turn "not yet known" into a decidable boolean, collapsing the tri-state. | diff --git a/site/content/docs/crd-agentsession.mdx b/site/content/docs/crd-agentsession.mdx index a15a5b51..26c3f654 100644 --- a/site/content/docs/crd-agentsession.mdx +++ b/site/content/docs/crd-agentsession.mdx @@ -565,6 +565,11 @@ Status is controller-owned (observed state). | `status.sidecarReachability[].reachable` \* | `boolean` | Reachable is true when the last live tools/list probe succeeded and the declared allowlist was satisfied. | | `status.sidecarReachability[].unreachable` | `string` | Unreachable is the probe/allowlist error when Reachable is false; empty when Reachable is true. | | `status.sleptAt` | `string (date-time)` | SleptAt is set when an Idle session's pods have been reaped (scaled to zero) and cleared when the session is woken. Distinct from LastIdleAt (which marks Idle entry): SleptAt marks that the compute footprint is gone. Diagnostic + drives the reap decision's "already slept" guard. | +| `status.slotPins` | `[]object` | SlotPins is a DISPLAY-ONLY observation, one entry per single-occupancy resource type, mirroring the instance SpiceDB's slot_pin relation currently pins this session to. SpiceDB is the enforcement source of truth for single-occupancy pinning; nothing that authorizes a bind or a tool call reads this field. See SlotPin's doc comment for what writes it and what MovedBy/MovedAt mean. | +| `status.slotPins[].movedAt` | `string (date-time)` | MovedAt is when MovedBy's move landed on this pin. Empty for a pin that has only ever been first-filled. | +| `status.slotPins[].movedBy` | `string` | MovedBy is the approval-record reference that authorized the most recent MOVE off a prior instance — empty for a pin that has only ever been first-filled. Populated only by the plan-gate approval path (pkg/agent/runner's narrowToApproved), from the plan-gate audit record that carried the approval: its PhaseKey, or PlanDigest+PhaseIndex when PhaseKey is unset on an older record. | +| `status.slotPins[].resourceID` \* | `string` | ResourceID is the instance ResourceType is currently pinned to. | +| `status.slotPins[].resourceType` \* | `string` | ResourceType is the slot's declared resource type, and the key UpsertSlotPin replaces by — a single-occupancy slot holds at most one instance per type for the session's life, so one entry per type is always the complete picture. | | `status.startApprovalParkedAt` | `string (date-time)` | StartApprovalParkedAt is when the session entered AwaitingStartApproval (parked on AnnotationStartApprovalRequestRef). The operator measures the start-approval deadline from here. | | `status.startFailure` | `object` | StartFailure is set by channelsd when it detects a terminal pre-start failure (e.g. an authz-write failure on session creation). The operator reads it and produces the Failed state — channelsd never writes phase/failureReason/finishedAt itself. | | `status.startFailure.message` | `string` | Message is the optional human-readable description of the failure. | diff --git a/test/e2e/bronzethread/testdata/plangate-amendment-binds-constant-instance/00-agent.yaml b/test/e2e/bronzethread/testdata/plangate-amendment-binds-constant-instance/00-agent.yaml index ffb02ed5..0e76f272 100644 --- a/test/e2e/bronzethread/testdata/plangate-amendment-binds-constant-instance/00-agent.yaml +++ b/test/e2e/bronzethread/testdata/plangate-amendment-binds-constant-instance/00-agent.yaml @@ -173,6 +173,11 @@ spec: permissions: [read] description: "A remote repository, reached by its URL" permission: push + # This class binds a constant "workspace" instance (read) alongside + # the chosen repo URL (push) under the same resource type — two + # distinct instances of amrepo_repo by design, so the slot must be + # multi-occupancy or the second bind is refused. + occupancy: multi planGate: mode: enforcing budget: diff --git a/test/e2e/bronzethread/testdata/plangate-card-names-constant-instance/00-agent.yaml b/test/e2e/bronzethread/testdata/plangate-card-names-constant-instance/00-agent.yaml index 978918a5..52ced83d 100644 --- a/test/e2e/bronzethread/testdata/plangate-card-names-constant-instance/00-agent.yaml +++ b/test/e2e/bronzethread/testdata/plangate-card-names-constant-instance/00-agent.yaml @@ -173,6 +173,11 @@ spec: # constant instance gets no grant, and the first inspect raises an # amendment for a resource the card already promised to cover. permissions: [read] + # This class binds a constant "workspace" instance (read) alongside + # the chosen repo URL (push) under the same resource type — two + # distinct instances of wsrepo_repo by design, so the slot must be + # multi-occupancy or the second bind is refused. + occupancy: multi planGate: mode: enforcing budget: diff --git a/test/e2e/bronzethread/testdata/plangate-slot-repoint-reasks/bundle.json b/test/e2e/bronzethread/testdata/plangate-slot-repoint-reasks/bundle.json index 99103f5c..2f8669b4 100644 --- a/test/e2e/bronzethread/testdata/plangate-slot-repoint-reasks/bundle.json +++ b/test/e2e/bronzethread/testdata/plangate-slot-repoint-reasks/bundle.json @@ -1,6 +1,6 @@ { "name": "plangate-slot-repoint-reasks", - "description": "An approval buys ONE instance. The agent is cleared for company 4210, then re-plans the SAME phase — same handles, same budget, same prerequisites — pointing at 4299, and must ask again. Two cards, not one.\n\nThe re-plan is deliberately a pure re-point: nothing widens except the instance. That isolates the axis. Approval keys on the plan digest, which changes on any edit, so the phase arrives unapproved and carry-over is consulted — and carry-over asks whether the human's earlier answer still covers what is being asked. It used to compare slots by TYPE alone, find crm_company on both sides, conclude nothing widened, and let the second company through on the first company's approval. An approval obtained for one target, spent on another.", + "description": "An approval buys ONE instance. The agent is cleared for company 4210, then re-plans the SAME phase — same handles, same budget, same prerequisites — pointing at 4299, and must ask again. Two cards, not one.\n\nThe re-plan is deliberately a pure re-point: nothing widens except the instance. That isolates the axis. Approval keys on the plan digest, which changes on any edit, so the phase arrives unapproved and carry-over is consulted — and carry-over asks whether the human's earlier answer still covers what is being asked. It used to compare slots by TYPE alone, find crm_company on both sides, conclude nothing widened, and let the second company through on the first company's approval. An approval obtained for one target, spent on another.\n\nNOTE on single-occupancy pinning: crm_company here declares `standing: required` (the shared centerdot MCPServer seeds owner tuples SpiceDB governs), and this bundle's auto-approver holds no `owner` on 4210/4299 — list returns no rows, so no owner_ref is written. narrowToApproved therefore binds nothing on either approval, and with nothing bound the single-occupancy pin is never written, so the re-point stays two first-fill cards rather than a MOVE. That is correct for this fixture: the re-ask is driven by carry-over comparing the named instance, which is what this bundle proves, and it holds whether or not a pin was taken. The MOVE itself — the second card naming both instances plus the revocation copy, and the approved amendment repointing the pin and revoking the old instance's grants — is proven where pinning actually engages: the slot-pin-move-on-amendment bundle (a session-only slot that binds on a plain approval, so the second card is a real MOVE) and the slot_pinning e2e scenario (which drives the real SpiceDB writer and asserts the slot_pin tuple and grant moves directly).", "agentDir": "../testdata/agent-centerdot-companies", "agentClass": "centerdot-companies", diff --git a/test/e2e/bronzethread/testdata/slot-pin-move-on-amendment/00-agent.yaml b/test/e2e/bronzethread/testdata/slot-pin-move-on-amendment/00-agent.yaml new file mode 100644 index 00000000..e39784c8 --- /dev/null +++ b/test/e2e/bronzethread/testdata/slot-pin-move-on-amendment/00-agent.yaml @@ -0,0 +1,139 @@ +# Fixture agent for the slot-pin-move-on-amendment scenario. +# +# code_repo is a single-occupancy (the default), SESSION-ONLY slot with one +# permission, read, keyed per-call on a repo name. Session-only is the shape +# that binds on a plain plan-gate approval — no owner tuple, no owner-approver — +# so the first approval does not merely grant repo-alpha, it PINS the session to +# it (a non-expiring slot_pin tuple). A re-plan that re-points the same phase at +# repo-bravo is then not a second independent grant but a MOVE: the amendment +# card names both instances and says approving moves the pin and revokes the old +# instance's access, and approving it repoints the pin and revokes repo-alpha's +# grant in one request. +# +# The centerdot repoint bundle (plangate-slot-repoint-reasks) cannot show this: +# its crm_company is `standing: required` and its auto-approver owns nothing, so +# nothing binds and nothing pins. This fixture uses session-only precisely so +# the pin engages and the MOVE renders on a real published card. +apiVersion: v1 +kind: Secret +metadata: {name: codepin-placeholder, namespace: default} +type: Opaque +stringData: {api-key: "unused-by-the-test-provider"} +--- +apiVersion: v1 +kind: Secret +metadata: {name: codepin-fake-creds, namespace: default} +type: Opaque +stringData: {placeholder: "unused-by-the-fake-channel"} +--- +apiVersion: v1 +kind: Secret +metadata: {name: codepin-mcp-token, namespace: default} +type: Opaque +stringData: {access_token: "unused-by-the-fake-mcp-server"} +--- +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: AgentIdentity +metadata: {name: codepin-identity, namespace: default} +spec: + description: "Session-only-standing e2e fixture: static MCP bearer token." + credentials: + - name: codepin-mcp-bearer + type: static + static: {secretRef: {name: codepin-mcp-token, key: access_token}} +--- +# Self-contained MCP fixture exposing one read-only tool gated on code_repo. +# +# code_repo declares `standing: session-only` and a single `read` permission. +# Because the AgentClass below declares code_repo as an authz slot, the composer +# rewrites read to `slot_grant_read->interact + owner` and emits +# `relation slot_grant_read: agentsession` plus the per-type +# `relation slot_pin: agentsession` single-occupancy carries. ONE permission on +# ONE instance per call — deliberately not the git_repo read-on-"workspace" + +# push-on-URL shape, which names two instances of one single-occupancy type and +# muddies what the pin is on. +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: MCPServer +metadata: {name: codepin-fixture, namespace: default} +spec: + name: codepin-fixture + version: v1 + intent: "Self-contained single-instance git-repo-shaped fixture for the e2e harness." + server: + url: "{{MCP_URL}}" # Rewritten by the e2e harness before apply. + transport: http + auth: + credential: codepin-mcp-bearer + spicedbSchema: + resources: + - name: code_repo + standing: session-only + relations: + - {name: owner, subjectType: user} + permissions: + - {name: read, expr: owner, title: "Read the repository"} + tools: + - name: fetch + intent: "Read a repository this session is pinned to." + args: + allowedFields: [repo] + permission: + stateImpact: readonly + check: + resourceType: code_repo + resourceIDExpr: '"" + args.repo' + permission: read + resourceIDHint: "name the repository" + effects: + readOnly: true + idempotent: true +--- +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: AgentClass +metadata: {name: codepin-fixture, namespace: default} +spec: + displayName: CodePinBot + description: "E2E fixture agent. Do not deploy to a real cluster." + model: + provider: test # production operator REFUSES this + name: scripted # cosmetic; ignored at runtime + apiKey: {name: codepin-placeholder, key: api-key} + systemPrompt: + inline: | + You are a fixture agent for the agentprimitives e2e harness. + DO NOT deploy to a real cluster — your model provider is `test`. + + You read repositories. Declare a plan phase naming the repository, then + call codepin_fetch(repo). + agentIdentity: codepin-identity + mcpServers: + - name: codepin + ref: codepin-fixture + authz: + slots: + - resourceType: code_repo + permission: read + description: "A code repository, reached by name" + planGate: + mode: enforcing + rendering: + # One auto-approvable handle for the session. The phase declares one + # handle (read) AND one slot, so breadth promotes it past tier 0 and a + # human must clear it — which is what makes the card (and its move copy) + # observable. Without this the all-readonly phase auto-approves and no + # card is ever published. + maxAutoApproveHandles: 1 + budget: + maxTurns: 10 + maxTokens: 50000 + maxDuration: 5m +--- +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: Channel +metadata: {name: codepin-fixture-fake, namespace: default} +spec: + kind: fake + role: both + agentClass: codepin-fixture + credentialsRef: {secretName: codepin-fake-creds} + fake: {echo: false} diff --git a/test/e2e/bronzethread/testdata/slot-pin-move-on-amendment/bundle.json b/test/e2e/bronzethread/testdata/slot-pin-move-on-amendment/bundle.json new file mode 100644 index 00000000..9a794d92 --- /dev/null +++ b/test/e2e/bronzethread/testdata/slot-pin-move-on-amendment/bundle.json @@ -0,0 +1,88 @@ +{ + "name": "slot-pin-move-on-amendment", + "description": "Single-occupancy pinning, end to end through the plan gate: the agent is cleared for repo-alpha, then re-plans the SAME phase to point at repo-bravo, and the second card is a MOVE.\n\ncode_repo is session-only, so the first approval BINDS and therefore PINS repo-alpha (a non-expiring slot_pin tuple), and the first codepin_fetch(repo-alpha) succeeds on the grant that approval wrote. The re-plan re-points the phase's one slot to repo-bravo: same handle, same budget, so nothing widens but the instance. That arrives as a plan_amendment (DiffPhase finds the slot value changed), and because the session is already pinned to repo-alpha the amendment card is a MOVE — it names BOTH instances (repo-alpha → repo-bravo) and says in words that approving moves the pin and revokes this session's access to repo-alpha. Approving it repoints the pin and revokes repo-alpha's grant in one request, so the second codepin_fetch(repo-bravo) then succeeds on the moved grant.\n\ncardWhatContains asserts the move copy, not just the two ids: a card that rendered repo-bravo as a plain first-fill would hide the revocation the approval comes with, and a bare-ids assertion would pass it (repo-alpha appears on the first card regardless). The category stays plan_amendment (the spec decision — no new interaction kind for a move), so autoApprove clears it exactly as it clears the first phase.\n\nThe companion slot_pinning e2e scenario asserts the same pin/move at the SpiceDB-tuple level (slot_pin present, the read grant moving off repo-alpha onto repo-bravo) and proves the ErrSlotPinned refusal text for both rebind modes; this bundle proves the move renders on a REAL published card driven by a real model script. approvalPrompts is 2 and WHICH two is the point: one plan_phase for repo-alpha, one plan_amendment MOVE for repo-bravo.", + + "agentDir": "testdata/slot-pin-move-on-amendment", + "agentClass": "codepin-fixture", + + "autoApprove": ["plan_phase", "plan_amendment"], + + "toolOutputs": { + "fetch": { "content": "f3tch3d" } + }, + + "userTurns": ["read repo-alpha, then read repo-bravo"], + + "llm": [ + { + "expect": { "userTextContains": "read repo-alpha" }, + "reply": [ + { + "toolUse": { + "name": "update_plan", + "args": { + "name": "main", + "items": [{ "id": "s1", "label": "read a repository", "status": "pending", "phase": "look" }], + "phases": [ + { + "id": "look", + "label": "Read the repository", + "why": "the user named repo-alpha first", + "permissions": [{ "handle": "perm:read:code_repo", "why": "reading needs read access" }], + "slots": [{ "type": "code_repo", "id": "repo-alpha", "why": "the user named repo-alpha" }] + } + ] + } + } + } + ] + }, + { + "expect": { "lastToolResult": "update_plan" }, + "reply": [{ "toolUse": { "name": "codepin_fetch", "args": { "operation_id": "op-alpha", "_reason": "reading repo-alpha", "args": { "repo": "repo-alpha" } } } }] + }, + { + "expect": { "lastToolResult": "codepin_fetch", "lastToolResultIsError": false, "lastToolResultContains": "f3tch3d" }, + "reply": [ + { + "toolUse": { + "name": "update_plan", + "args": { + "name": "main", + "items": [{ "id": "s1", "label": "read a repository", "status": "pending", "phase": "look" }], + "phases": [ + { + "id": "look", + "label": "Read the repository", + "why": "now the user wants repo-bravo", + "permissions": [{ "handle": "perm:read:code_repo", "why": "reading needs read access" }], + "slots": [{ "type": "code_repo", "id": "repo-bravo", "why": "the user named repo-bravo" }] + } + ] + } + } + } + ] + }, + { + "expect": { "lastToolResult": "update_plan" }, + "reply": [{ "toolUse": { "name": "codepin_fetch", "args": { "operation_id": "op-bravo", "_reason": "reading repo-bravo", "args": { "repo": "repo-bravo" } } } }] + }, + { + "expect": { "lastToolResult": "codepin_fetch", "lastToolResultIsError": false, "lastToolResultContains": "f3tch3d" }, + "reply": [{ "text": "read both repositories" }] + }, + { + "expect": { "lastToolResult": "respond_to_user" }, + "reply": [{ "toolUse": { "name": "agent_work_complete", "args": { "summary": "read both" } } }] + } + ], + + "assert": { + "agentReplyContains": ["read both repositories"], + "planGate": { + "approvalPrompts": 2, + "cardWhatContains": ["repo-alpha", "repo-bravo", "repo-alpha → repo-bravo", "revokes this session's access to repo-alpha"] + } + } +} diff --git a/test/e2e/scenarios/policy/slot_pinning_test.go b/test/e2e/scenarios/policy/slot_pinning_test.go new file mode 100644 index 00000000..73a7b666 --- /dev/null +++ b/test/e2e/scenarios/policy/slot_pinning_test.go @@ -0,0 +1,216 @@ +//go:build e2e + +package policy_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/memory" + "github.com/authzed/openagentprimitives/pkg/memory/inmem" + "github.com/authzed/openagentprimitives/pkg/platform/identity" + "github.com/authzed/openagentprimitives/test/e2e" +) + +// TestSlotPinning proves the single-occupancy slot-pinning mechanism end to end +// against the harness's REAL SpiceDB — the layer a scripted LLM session cannot +// reach. It drives authz.GrantSlots / authz.BindApproved and the +// SlotPinner.EnsurePin exactly as the runner wires them (the SpiceDB-backed +// *spicedb.RelationWriter, via h.SpiceDB.Relations), so the pin tuple, the +// refusal of a second instance, and the atomic A→B move with its grant +// revocation are asserted at the tuple level rather than inferred from a reply. +// +// This is the deterministic companion to the slot-pin-move-on-amendment bronze +// bundle (which proves the move rendering on a real published card through a +// full LLM-driven plan gate). Here the writer is called directly, below the +// approver-standing gate, so the code_repo fixture's standing is irrelevant and +// left session-only; +// what is under test is GrantSlots' occupancy/rebind handling and the +// SlotPinner's atomic preconditions. +// +// No real names: code_repo / repo-alpha / repo-bravo are fictional. +func TestSlotPinning(t *testing.T) { + manifests, err := os.ReadFile(filepath.Join(e2e.TestdataDir("slot_pinning"), "manifests.yaml")) + require.NoError(t, err, "read manifests") + + h := e2e.Start(t, e2e.Options{ + ExtraManifests: []string{string(manifests)}, + DefaultTimeout: 30 * time.Second, + }) + + // The MCPServer's one tool must be probeable or the AgentClass never reaches + // Valid and the guardian never composes the schema this test writes against. + h.MCP.OnTool("fetch", func(map[string]any) any { + return map[string]any{"ok": true} + }) + + // The AgentClass declaring code_repo as a slot is what makes the guardian + // compose `relation slot_pin: agentsession` (single-occupancy) and + // `relation slot_grant_read: agentsession` (the read grant) onto code_repo. + h.WaitForAgentClassValid("ac-slot-pinning", 30*time.Second) + h.WaitForComposedSchema(30*time.Second, []e2e.SchemaRel{ + {Definition: "code_repo", Relation: "slot_pin"}, + {Definition: "code_repo", Relation: "slot_grant_read"}, + }) + + const ( + repoA = "repo-alpha" + repoB = "repo-bravo" + ) + // Far-future expiry: the schema requires one on every slot grant, and these + // assertions read fully-consistently moments later, so nothing must lapse. + expiry := authz.SlotGrantExpiry(time.Now(), time.Hour) + + // A single-occupancy, rebind=approval binding for one instance of code_repo. + bindingFor := func(id, rebind string) authz.SlotBinding { + return authz.SlotBinding{ + ResourceType: "code_repo", + ResourceID: authz.TrustedObjectID(id), + Permission: "read", + Occupancy: authz.SlotOccupancySingle, + Rebind: rebind, + } + } + + // ── bind A, refuse B, approve the move to B ────────────────────────────── + t.Run("binds the first instance, refuses a second, and an approved amendment moves the pin", func(t *testing.T) { + writer := h.SpiceDB.Relations() + _, ok := writer.(authz.SlotPinner) + require.True(t, ok, "the SpiceDB RelationWriter must implement authz.SlotPinner (GrantSlots refuses single-occupancy otherwise)") + + sess := authz.SessionRef{Namespace: "default", Name: "pin-move"} + subj := identity.Subject("agentsession:" + sess.String()) + ctx := memory.WithSystemApproval(context.Background(), "test") + + // Bind A: a first-fill. The pin lands and the read grant is written. + require.NoError(t, authz.GrantSlots(ctx, writer, sess, []authz.SlotBinding{bindingFor(repoA, "approval")}, expiry), + "binding the first instance of a single-occupancy slot must succeed") + h.AssertSpiceDB("code_repo:"+repoA, "slot_pin", subj, true) + h.AssertSpiceDB("code_repo:"+repoA, "slot_grant_read", subj, true) + + // Attempt B WITHOUT an approved move: a different instance of a filled + // single-occupancy slot is refused, and the refusal names the pinned + // instance and the route out (the text the model is handed). + err := authz.GrantSlots(ctx, writer, sess, []authz.SlotBinding{bindingFor(repoB, "approval")}, expiry) + require.Error(t, err, "a second distinct instance of a single-occupancy slot must be refused") + require.True(t, errors.Is(err, authz.ErrSlotPinned), "want ErrSlotPinned, got %v", err) + assert.Contains(t, err.Error(), "code_repo:"+repoA, "the refusal must name the pinned instance") + assert.Contains(t, err.Error(), "propose an updated plan", "rebind=approval routes to a plan amendment") + // Nothing moved: B is neither pinned nor granted. + h.AssertSpiceDB("code_repo:"+repoB, "slot_pin", subj, false) + h.AssertSpiceDB("code_repo:"+repoB, "slot_grant_read", subj, false) + + // Approve the move: BindApproved with PriorID=A repoints the pin A→B and + // revokes A's grants in the same request — the human-approved amendment. + mem := memory.NewLocal(inmem.NewBackend()) + scope := memory.Scope{Kind: "session", ID: sess.String()} + move := bindingFor(repoB, "approval") + move.PriorID = authz.TrustedObjectID(repoA) + require.NoError(t, authz.BindApproved(ctx, mem, scope, sess, writer, + []authz.SlotBinding{move}, authz.PreconditionsWaived, expiry, logr.Discard(), time.Now), + "an approved move of a single-occupancy slot must succeed") + + // The pin is now on B, A's is gone; B's read grant is written and A's is + // revoked — the grant permission is false for A, true for B. + h.AssertSpiceDB("code_repo:"+repoB, "slot_pin", subj, true) + h.AssertSpiceDB("code_repo:"+repoA, "slot_pin", subj, false) + h.AssertSpiceDB("code_repo:"+repoB, "slot_grant_read", subj, true) + h.AssertSpiceDB("code_repo:"+repoA, "slot_grant_read", subj, false) + }) + + // ── rebind: never — the refusal routes to a NEW SESSION, nothing moves ─── + t.Run("rebind never refuses a second instance and names a new session, moving nothing", func(t *testing.T) { + writer := h.SpiceDB.Relations() + sess := authz.SessionRef{Namespace: "default", Name: "pin-never"} + subj := identity.Subject("agentsession:" + sess.String()) + ctx := memory.WithSystemApproval(context.Background(), "test") + + require.NoError(t, authz.GrantSlots(ctx, writer, sess, []authz.SlotBinding{bindingFor(repoA, authz.SlotRebindNever)}, expiry), + "binding the first instance must succeed") + h.AssertSpiceDB("code_repo:"+repoA, "slot_pin", subj, true) + + err := authz.GrantSlots(ctx, writer, sess, []authz.SlotBinding{bindingFor(repoB, authz.SlotRebindNever)}, expiry) + require.Error(t, err, "rebind=never must refuse a second instance") + require.True(t, errors.Is(err, authz.ErrSlotPinned), "want ErrSlotPinned, got %v", err) + assert.Contains(t, err.Error(), "code_repo:"+repoA, "the refusal must name the pinned instance") + assert.Contains(t, err.Error(), "start a new session", "rebind=never routes to a new session, not an amendment") + assert.NotContains(t, err.Error(), "an approved plan moves the pin", "rebind=never must not offer the move route") + + // Nothing moved: A is still pinned, B is not. + h.AssertSpiceDB("code_repo:"+repoA, "slot_pin", subj, true) + h.AssertSpiceDB("code_repo:"+repoB, "slot_pin", subj, false) + }) + + // ── concurrent first bind: exactly one winner, atomically ──────────────── + // + // Two goroutines race EnsurePin for DIFFERENT instances through the real + // writer. SpiceDB evaluates each write's MUST_NOT_MATCH precondition and the + // write atomically, so exactly one reaches the server first and wins the + // slot; the other is refused by the server (not by a read-then-write race in + // this process) and reads back the winner's id. The same-instance + // fall-through EnsurePin also exercises (held=true, pinnedID==resourceID) is + // the stated proxy for "same instance after expiry": a fake cannot model the + // TTL, but the pin branch is identical. + t.Run("two concurrent first binds for different instances leave exactly one pin", func(t *testing.T) { + writer := h.SpiceDB.Relations() + pinner, ok := writer.(authz.SlotPinner) + require.True(t, ok, "the SpiceDB RelationWriter must implement authz.SlotPinner") + + sess := authz.SessionRef{Namespace: "default", Name: "pin-concurrent"} + subj := identity.Subject("agentsession:" + sess.String()) + ctx := context.Background() + + type result struct { + id string + held bool + pinnedID string + err error + } + results := make([]result, 2) + ids := []string{repoA, repoB} + var wg sync.WaitGroup + start := make(chan struct{}) + for i := range ids { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start // line both calls up as closely as the scheduler allows + held, pinnedID, err := pinner.EnsurePin(ctx, "code_repo", ids[i], sess) + results[i] = result{id: ids[i], held: held, pinnedID: pinnedID, err: err} + }(i) + } + close(start) + wg.Wait() + + for _, r := range results { + require.NoError(t, r.err, "EnsurePin for %s must not error (a refusal is held=true, not an error)", r.id) + } + + var winners, losers []result + for _, r := range results { + if r.held { + losers = append(losers, r) + } else { + winners = append(winners, r) + } + } + require.Len(t, winners, 1, "exactly one first bind wins the slot") + require.Len(t, losers, 1, "the other is refused by the server, not errored") + assert.Equal(t, winners[0].id, losers[0].pinnedID, + "the loser must read back the WINNER's instance as the one holding the pin") + + // Exactly one pin exists in SpiceDB: the winner's, not the loser's. + h.AssertSpiceDB("code_repo:"+winners[0].id, "slot_pin", subj, true) + h.AssertSpiceDB("code_repo:"+losers[0].id, "slot_pin", subj, false) + }) +} diff --git a/test/e2e/testdata/slot_pinning/manifests.yaml b/test/e2e/testdata/slot_pinning/manifests.yaml new file mode 100644 index 00000000..427186a8 --- /dev/null +++ b/test/e2e/testdata/slot_pinning/manifests.yaml @@ -0,0 +1,157 @@ +# slot_pinning scenario fixtures. +# +# A git-repo-like single-occupancy slot class. code_repo is declared as an +# authz slot with one permission (read); occupancy defaults to `single`, so the +# composed schema carries `relation slot_pin: agentsession` and the grant +# relation `slot_grant_read: agentsession` on code_repo. The slot_pinning e2e +# test drives the REAL SpiceDB-backed writer (authz.GrantSlots / BindApproved / +# the SlotPinner's EnsurePin) against this composed schema directly — the +# deterministic proof of the pin/refuse/move mechanism a scripted LLM session +# cannot make, because it can assert the slot_pin tuple and the grant moving +# from one instance to another at the SpiceDB layer. +# +# Standing is irrelevant to that direct path (the writer is called below the +# approver-standing gate), so code_repo declares session-only: the card-level +# standing semantics are covered in pkg/authz/plangate's card tests, not here. +# +# One tool (fetch) gated on code_repo#read makes the MCPServer valid (its +# tools/list probe must match a declared tool) so the AgentClass reaches Valid +# and the guardian composes the schema. The test never calls it — it is the +# minimum that makes the schema real. +--- +# Placeholder Anthropic-style API key. model.provider=test never reads it, but +# the AgentClass controller resolves the Secret ref during validation. +apiVersion: v1 +kind: Secret +metadata: + name: code-placeholder + namespace: default +type: Opaque +stringData: + api-key: "unused-by-the-test-provider" +--- +# Placeholder Channel credentials. The fake kind ignores them but +# Channel.spec.credentialsRef is mandatory. +apiVersion: v1 +kind: Secret +metadata: + name: code-fake-creds + namespace: default +type: Opaque +stringData: + placeholder: "unused-by-the-fake-channel" +--- +apiVersion: v1 +kind: Secret +metadata: + name: code-mcp-token + namespace: default +type: Opaque +stringData: + access_token: "unused-by-the-fake-mcp-server" +--- +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: AgentIdentity +metadata: + name: code-identity + namespace: default +spec: + description: "slot_pinning e2e fixture: static MCP bearer token." + credentials: + - name: code-mcp-bearer + type: static + static: + secretRef: + name: code-mcp-token + key: access_token +--- +# Self-contained MCP fixture exposing one read-only tool gated on code_repo. +# +# code_repo declares `standing: session-only` and a single `read` permission. +# Because the AgentClass below declares code_repo as an authz slot, the schema +# composer rewrites `read` to `slot_grant_read->interact + owner` and emits +# `relation slot_grant_read: agentsession` plus the per-type +# `relation slot_pin: agentsession` single-occupancy carries. +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: MCPServer +metadata: + name: code-repos + namespace: default +spec: + name: code-repos + version: v1 + intent: "Self-contained git-repo-shaped fixture for the e2e harness." + server: + url: "{{MCP_URL}}" # Rewritten by the e2e harness before apply. + transport: http + auth: + credential: code-mcp-bearer + spicedbSchema: + resources: + - name: code_repo + standing: session-only + relations: + - {name: owner, subjectType: user} + permissions: + - {name: read, expr: owner, title: "Read the repository"} + tools: + - name: fetch + intent: "Read a repository the session is pinned to." + args: + allowedFields: [repo] + permission: + stateImpact: readonly + check: + resourceType: code_repo + resourceIDExpr: '"" + args.repo' + permission: read + resourceIDHint: "name the repository" + effects: + readOnly: true + idempotent: true +--- +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: AgentClass +metadata: + name: ac-slot-pinning + namespace: default +spec: + displayName: CodeRepoBot + description: "E2E fixture agent. Do not deploy to a real cluster." + model: + provider: test # production operator REFUSES this + name: scripted # cosmetic; ignored at runtime + apiKey: + name: code-placeholder + key: api-key + systemPrompt: + inline: | + You are a fixture agent for the agentprimitives e2e harness. + DO NOT deploy to a real cluster. + agentIdentity: code-identity + mcpServers: + - name: code + ref: code-repos + authz: + slots: + - resourceType: code_repo + description: "A code repository, reached by name" + permission: read + budget: + maxTurns: 10 + maxTokens: 50000 + maxDuration: 5m +--- +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: Channel +metadata: + name: ac-slot-pinning-fake + namespace: default +spec: + kind: fake + role: both + agentClass: ac-slot-pinning + credentialsRef: + secretName: code-fake-creds + fake: + echo: false