diff --git a/README.md b/README.md index b2dab3d5..6d7118a3 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. @@ -368,10 +369,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, while the model itself never -sees a credential. 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, while the model +itself never sees a credential. 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/kube/apply.go b/cmd/oap/internal/kube/apply.go index eb17e3a5..536de40b 100644 --- a/cmd/oap/internal/kube/apply.go +++ b/cmd/oap/internal/kube/apply.go @@ -192,6 +192,10 @@ func resolveGVR(gvk schemaGVK) (schemaGVR, bool, error) { return schemaGVR{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingwebhookconfigurations"}, false, nil case "MutatingWebhookConfiguration": return schemaGVR{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "mutatingwebhookconfigurations"}, false, nil + case "ValidatingAdmissionPolicy": + return schemaGVR{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingadmissionpolicies"}, false, nil + case "ValidatingAdmissionPolicyBinding": + return schemaGVR{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingadmissionpolicybindings"}, false, nil case "StorageClass": return schemaGVR{Group: "storage.k8s.io", Version: "v1", Resource: "storageclasses"}, false, nil } diff --git a/cmd/oap/internal/kube/apply_test.go b/cmd/oap/internal/kube/apply_test.go index 8474b824..eb48516a 100644 --- a/cmd/oap/internal/kube/apply_test.go +++ b/cmd/oap/internal/kube/apply_test.go @@ -146,6 +146,7 @@ func TestResolveGVRScope(t *testing.T) { clusterScoped := []string{ "Namespace", "ClusterRole", "ClusterRoleBinding", "CustomResourceDefinition", "ValidatingWebhookConfiguration", "MutatingWebhookConfiguration", "StorageClass", + "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", } for _, k := range clusterScoped { ns, err := kube.ResolveNamespacedForTest("", k) 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/admission/agentsession-finalizer-policy.yaml b/config/admission/agentsession-finalizer-policy.yaml new file mode 100644 index 00000000..9102212f --- /dev/null +++ b/config/admission/agentsession-finalizer-policy.yaml @@ -0,0 +1,78 @@ +# Only the operator and channelsd may ADD the AgentSession finalizer. +# +# Why this exists. The AgentSession reconciler runs an "admission sweep" on a +# session's first reconcile: it deletes every slot_grant_* and slot_pin tuple +# SpiceDB holds under the session's (namespace, name), wiping whatever a dead +# predecessor with the same name left behind. Slot tuples are keyed by +# (namespace, name), not UID, and a slot_pin never expires, so without the +# sweep a recreated session inherits the predecessor's pin and grants. +# +# The sweep is skipped when the finalizer is ALREADY present at first +# reconcile, because two trusted creators pre-stamp it and own the sweep +# themselves: +# - channelsd's mint stamps it at create and runs the same sweep +# synchronously before its own mint-time binds; +# - the operator's fork path stamps it on every restart child, because the +# parent's restart copies its slot grants and pin onto the child right +# after creating it, and a sweep would delete them. +# +# Anyone else who can create an AgentSession could write that same finalizer +# into their manifest and skip the sweep, inheriting a predecessor's +# authority. This policy makes the skip unforgeable: a CREATE carrying the +# finalizer, or an UPDATE that adds it, is refused unless the caller is one of +# those two service accounts. Removing the finalizer, and every write that +# leaves it as it was, is not evaluated at all (matchConditions). +# +# Why a ValidatingAdmissionPolicy and not another webhook in webhook.yaml: it +# runs inside the apiserver, so it fails closed (failurePolicy: Fail) without +# making every AgentSession write depend on the operator pod being up and its +# serving cert being injected. A webhook would force a choice between that +# dependency (Fail) and a silent bypass during an operator outage (Ignore). +# +# The service-account names are spelled out because CEL cannot read +# kustomize's namespace. They must match config/manager/serviceaccount.yaml +# and pkg/platform/manifests/channelsd/serviceaccount.yaml. +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: agentsession-finalizer.agentprimitives.authzed.com +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["agentprimitives.authzed.com"] + apiVersions: ["*"] + operations: ["CREATE", "UPDATE"] + # The main resource only. A status write cannot change + # metadata.finalizers, so agentsessions/status is not matched. + resources: ["agentsessions"] + matchConditions: + # True only for a write that ADDS the finalizer: present on a CREATE, or + # present in the new object and absent from the old one on an UPDATE. + # oldObject is null on CREATE, so the operation is tested first. + - name: adds-agentsession-finalizer + expression: >- + has(object.metadata.finalizers) && + 'agentsession.agentprimitives.authzed.com/finalizer' in object.metadata.finalizers && + (request.operation == 'CREATE' || + !has(oldObject.metadata.finalizers) || + !('agentsession.agentprimitives.authzed.com/finalizer' in oldObject.metadata.finalizers)) + validations: + - expression: >- + request.userInfo.username in [ + 'system:serviceaccount:agentprimitives-system:spicebox-operator', + 'system:serviceaccount:agentprimitives-system:spicebox-channelsd'] + reason: Forbidden + messageExpression: >- + 'only the agentprimitives operator and channelsd may add the + agentsession.agentprimitives.authzed.com/finalizer finalizer; ' + + request.userInfo.username + ' may not. Create the AgentSession + without it and the operator adds it after sweeping stale slot grants.' +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: agentsession-finalizer.agentprimitives.authzed.com +spec: + policyName: agentsession-finalizer.agentprimitives.authzed.com + validationActions: ["Deny"] diff --git a/config/admission/kustomization.yaml b/config/admission/kustomization.yaml new file mode 100644 index 00000000..018f8ce8 --- /dev/null +++ b/config/admission/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# No `namespace:` here, deliberately. ValidatingAdmissionPolicy and its binding +# are cluster-scoped, but kustomize's namespace transformer does not know that +# and would stamp agentprimitives-system onto them (as it does for everything +# under config/manager), which `oap install` would then send to a +# cluster-scoped endpoint. +resources: + - agentsession-finalizer-policy.yaml 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 d0891e50..98cf2fdf 100644 --- a/config/crds/agentprimitives.authzed.com_agentsessions.yaml +++ b/config/crds/agentprimitives.authzed.com_agentsessions.yaml @@ -69,6 +69,12 @@ spec: a rule that only refuses REMOVING parent still lets it be repointed at a different session, which silently moves a running session's approval and transcript-read standing to a different set of humans. + + forkedFrom is pinned harder than parent: it may be neither added, removed nor + repointed after create, because only the operator's fork path sets it, and + only at create (BuildChildSession). A field-level rule cannot express this — + it never runs when the field is absent on either side — hence the spec-level + transition rule comparing presence as well as value. properties: agentIdentity: description: |- @@ -141,7 +147,10 @@ spec: forkedFrom: description: |- ForkedFrom names the prior AgentSession (same namespace) this - session was forked from via Restart-from-here. Immutable. + session was forked from via Restart-from-here. Immutable after + create (CEL rule on AgentSessionSpec). Whoever creates the CR picks + the value and nothing verifies that the named session exists, so it + must never gate anything security-relevant. Distinct from ChannelBinding.InheritFrom (set on auto-inherit after archive); ForkedFrom is set on explicit user-triggered restart. @@ -338,6 +347,10 @@ spec: x-kubernetes-validations: - message: spec.parent is immutable once set rule: '!has(oldSelf.parent) || (has(self.parent) && self.parent == oldSelf.parent)' + - message: 'spec.forkedFrom is immutable: it cannot be added, removed + or changed after create' + rule: has(self.forkedFrom) == has(oldSelf.forkedFrom) && (!has(self.forkedFrom) + || self.forkedFrom == oldSelf.forkedFrom) status: properties: activeWidgets: @@ -4522,6 +4535,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/config/kustomization.yaml b/config/kustomization.yaml index 4e44fbcf..4029c372 100644 --- a/config/kustomization.yaml +++ b/config/kustomization.yaml @@ -1,6 +1,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - admission - authzd - crds - extractord diff --git a/docs/security-model.md b/docs/security-model.md index 85c52c99..87adea17 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 @@ -625,10 +691,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, while the model itself never -sees a credential. 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, while the model +itself never sees a credential. 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 c37be848..dc84ddfe 100644 --- a/internal/cmd/operator/main.go +++ b/internal/cmd/operator/main.go @@ -2612,8 +2612,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 @@ -2774,6 +2775,7 @@ func run(cfg *config) { APIReader: mgr.GetAPIReader(), Scheme: mgr.GetScheme(), Authz: spiceDBClient, + SlotSweeper: spiceDBClient, WorkshopBuild: spiceDBClient, MaxDelegationDepth: 3, DefaultMaxDelegatedAgents: 8, diff --git a/internal/cmd/runner/main.go b/internal/cmd/runner/main.go index aad502f3..4d6cba7d 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..c0601f7e 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"), } @@ -2340,6 +2346,18 @@ func (h *runnerHost) recordPlanGateDecision( // no multi-select to render, so a channel that can only show approve/deny // still yields a correctly-bounded grant. // + // A single-occupancy pin moves at most ONCE per approval. A plan-scoped card + // covers several phases and stampMovedFrom stamps the SAME move onto every + // covered phase that names the moved-to instance; each phase is then bound by + // its OWN BindApproved call, each carrying the same PriorID. The FIRST repoints + // the pin (repoA->repoB); every later one finds the pin already at the target + // and is an idempotent no-op in MovePin, not a drift — so no record's MovedFrom + // has to be stripped to keep the move from being re-attempted, and every + // covered phase keeps it on its own approved record for the audit trail. (This + // used to require clearing MovedFrom off all but the first covered record, + // which also erased it from those phases' audit records; MovePin's idempotency + // made that band-aid unnecessary.) + // Asked ONCE for the union and intersected per phase. A plan-scoped card // covers several phases that often request the same instance, and asking // SpiceDB per phase would repeat identical lookups while a human waits. @@ -2357,10 +2375,21 @@ 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, discardedApprovalNotice()) return } + // Every covered phase's bindings are resolved BEFORE any is written, so the + // approval can be judged as a whole (conflictingMoveTargets) and its outcome + // reported once (approvalOutcome) rather than once per phase. + type phaseBind struct { + rec plangateaudit.Content + bindings []authz.SlotBinding + } + var phases []phaseBind for _, rec := range approvedPhaseRecords(pg) { rec.Event = plangateaudit.EventPhaseApproved rec.PlanDigest = pg.planDigest @@ -2392,20 +2421,53 @@ func (h *runnerHost) recordPlanGateDecision( rec.SlotRefs = granted } + // Resolved per record, from THIS record's own refs, so the bindings + // reflect both the DELEGABLE subset (a request the approver could not + // speak for is not bound to, because binding an instance nobody granted + // would deny the type outright) and THIS PHASE'S instance — a plan-scoped + // card can cover two phases that bind the same resource type to two + // different instances (clone from upstream/repo, push to fork/repo), and + // each must get its own grant, not just whichever phase raised the card. + phases = append(phases, phaseBind{rec: rec, bindings: h.approvedBindings(rec, pg, requested, granted, delegable)}) + } + + // A plan whose phases MOVE one single-occupancy pin to two different + // instances cannot be honored: bound phase by phase, the first move commits + // (revoking the prior instance) and the second then fails against the moved + // pin. Refuse the whole approval before anything is written or recorded, and + // say why — the card will be asked again and conflict again until the plan + // changes. + all := make([][]authz.SlotBinding, 0, len(phases)) + for _, ph := range phases { + all = append(all, ph.bindings) + } + if conflict := conflictingMoveTargets(all); conflict != nil { + cause := fmt.Errorf("plan moves a single-occupancy slot to more than one instance: %v", conflict) + slog.Default().Info("plan_gate: refusing an approval whose phases move one pin to different instances; "+ + "recording no approval and telling the approver", + "session", h.sess.Namespace+"/"+h.sess.Name, + "approver", approverIDOf(approver), "phase", pg.phaseIndex, "conflict", conflict) + h.publishPlanGateApprovalFailed(ctx, cause, moveConflictNotice(conflict)) + return + } + + var outcome approvalOutcome + for _, ph := range phases { // Writes the instance-scoped SpiceDB grant that makes the approved // instance the permitted one (a call against a DIFFERENT instance of // the same type has no grant to succeed on) and records the matching // session_scope entry alongside it — see narrowToApproved's doc comment - // for what each half currently does and does not enforce. Written per - // record, from THIS record's own refs, so it reflects both the - // DELEGABLE subset (a request the approver could not speak for is not - // bound to, because binding an instance nobody granted would deny the - // type outright) and THIS PHASE'S instance — a plan-scoped card can - // cover two phases that bind the same resource type to two different - // instances (clone from upstream/repo, push to fork/repo), and each - // must get its own grant, not just whichever phase raised the card. - h.narrowToApproved(ctx, rec, pg, requested, granted, delegable) - h.writePlanGateDecision(ctx, rec) + // for what each half currently does and does not enforce. + h.narrowToApproved(ctx, ph.rec, ph.bindings, &outcome) + h.writePlanGateDecision(ctx, ph.rec) + } + // ONE notice for the whole approval, true for every type it covered. + if n, ok := outcome.notice(); ok { + slog.Default().Info("plan_gate: an approved slot bind did not fully apply; telling the approver", + "session", h.sess.Namespace+"/"+h.sess.Name, + "committed", outcome.typesWith(slotMoveCommitted), "refused", outcome.typesWith(slotRefused), + "faulted", outcome.typesWith(slotFault), "err", outcome.cause().Error()) + h.publishPlanGateApprovalFailed(ctx, outcome.cause(), n) } } @@ -2432,7 +2494,7 @@ func (h *runnerHost) recordPlanGateDecision( // narrower and still real: the instance-scoped grant is the thing a // different instance has none of. // -// granted is THIS RECORD's own delegable slot requests — the instance each +// bindings are THIS RECORD's own (approvedBindings) — the instance each // covered phase itself named, already filtered by what the approver may // speak for. Per record rather than per card because a plan-scoped card // covers several phases, and two of them can legitimately bind the SAME @@ -2441,20 +2503,98 @@ func (h *runnerHost) recordPlanGateDecision( // phase raised the card and silently deny the others' already-approved // calls. // -// requested is the same record's unfiltered set, carried only so the log -// below can say what the human's yes was asked to cover against what it -// actually bought. -// // Best-effort by the same rule as the audit write — losing it must not take // down the turn — but never silent: an approval that grants nothing at all // is exactly the gap this change exists to close, so both a failure here and // a yes that bound nothing have to be findable in the log. -func (h *runnerHost) narrowToApproved( - ctx context.Context, rec plangateaudit.Content, pg *pendingPlanGate, requested, granted []plangateaudit.SlotRef, +// +// The per-type outcome is folded into outcome rather than published here: a plan-scoped approval calls this once +// per covered phase, and the approver gets one notice for the whole approval. +func (h *runnerHost) narrowToApproved(ctx context.Context, rec plangateaudit.Content, bindings []authz.SlotBinding, outcome *approvalOutcome) { + if h.l.Mem == nil || len(bindings) == 0 { + return + } + scp := memory.Scope{Kind: "session", ID: h.l.SessionKey.Namespace + "/" + h.l.SessionKey.Name} + sess := authz.SessionRef{Namespace: h.l.SessionKey.Namespace, Name: h.l.SessionKey.Name} + // Loop carries no bare SessionExpiration field; the same wall-clock cap the + // JIT tool-approval path re-reads off the AgentSession status + // (pkg/channels/channelsd/pipeline/tool_approval_interaction.go's sessionExpirationOf) + // is already resident here on Budget, loaded once at loop start + // (internal/cmd/runner/main.go), so read it from there instead of a second fetch. + var sessionExpiration time.Duration + if h.l.Budget != nil { + sessionExpiration = h.l.Budget.cfg.SessionExpiration.Duration + } + // bindSlots reads session_scope through l.Mem directly (not over the + // bearer-token HTTP path), so it needs the same system approval every + // other direct l.Mem call in this file mints — see PlanGateRecords + // (pipeline_wiring.go) for the sibling read on the same "plan_gate" + // component. + writeCtx := memory.WithSystemApproval(ctx, "plan_gate") + // logr.FromContextOrDiscard, not logr.Discard: bindSlots' nil-writer branch + // returns SUCCESS having granted nothing, and says so only via + // logger.Info — discarding it would mean an approval can narrow scope with + // no grant written and no trace of why, on top of the error path already + // logged below. + // EnforcePreconditions, never waived: a plan-phase approval consents to the + // phase, not to the risk a slot precondition guards. Each binding carries its + // type's compiled Requires (via planGateBindings), so an instance the phase + // named whose gate is Refused is dropped here rather than bound — it stays + // unbound and escalates to the human waiver card at its next tool call, which + // is the one card that asks for risk consent. An instance with no precondition + // (the overwhelmingly common phase) binds exactly as it did before. + 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) + if !outcome.record(bindings, err) { + // Not a per-type slot outcome: the bind failed before GrantSlots ran. + 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 + } + // Every type that bound — possibly by a MOVE — supersedes any recorded + // promotion refusal for it: 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. A type that failed keeps its refusal. + h.l.clearSlotPinRefusals(boundResourceTypes(bindings, err)) + // 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, or partially + // failed, and a committed move changed the pin even on error); see + // mirrorApprovedSlotPins' doc comment. + mirrorApprovedSlotPins(ctx, h.l.Status, h.l.SlotBinder, sess, rec, bindings, time.Now()) +} + +// boundResourceTypes is bindingResourceTypes minus every type err reports a +// per-type failure for. +func boundResourceTypes(bindings []authz.SlotBinding, err error) []string { + failed := map[string]struct{}{} + for _, f := range authz.SlotBindFailures(err) { + for _, rt := range f.ResourceTypes { + failed[rt] = struct{}{} + } + } + var out []string + for _, rt := range bindingResourceTypes(bindings) { + if _, ok := failed[rt]; !ok { + out = append(out, rt) + } + } + return out +} + +// approvedBindings resolves one covered record into the slot bindings its +// approval writes: THIS record's delegable refs, each paired with the +// permissions the approver was verified for and the instance it resolves to. +// Empty when the approval binds nothing (logged; the phase still runs and each +// named instance escalates on its own). +func (h *runnerHost) approvedBindings( + rec plangateaudit.Content, pg *pendingPlanGate, requested, granted []plangateaudit.SlotRef, delegable map[plangateaudit.SlotRef]map[string]struct{}, -) { +) []authz.SlotBinding { if h.l.Mem == nil { - return + return nil } // The permissions the approver actually holds, keyed by type+id — what // planGateBindings filters each binding against, so a permission the @@ -2482,7 +2622,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 @@ -2497,44 +2637,9 @@ func (h *runnerHost) narrowToApproved( "phase", phaseIndexOf(rec), "phaseKey", rec.PhaseKey, "requested", slotRefLabels(requested), "granted", slotRefLabels(granted)) } - return - } - scp := memory.Scope{Kind: "session", ID: h.l.SessionKey.Namespace + "/" + h.l.SessionKey.Name} - sess := authz.SessionRef{Namespace: h.l.SessionKey.Namespace, Name: h.l.SessionKey.Name} - // Loop carries no bare SessionExpiration field; the same wall-clock cap the - // JIT tool-approval path re-reads off the AgentSession status - // (pkg/channelsd/pipeline/tool_approval_interaction.go's sessionExpirationOf) - // is already resident here on Budget, loaded once at loop start - // (cmd/runner/main.go), so read it from there instead of a second fetch. - var sessionExpiration time.Duration - if h.l.Budget != nil { - sessionExpiration = h.l.Budget.cfg.SessionExpiration.Duration - } - // bindSlots reads session_scope through l.Mem directly (not over the - // bearer-token HTTP path), so it needs the same system approval every - // other direct l.Mem call in this file mints — see PlanGateRecords - // (pipeline_wiring.go) for the sibling read on the same "plan_gate" - // component. - writeCtx := memory.WithSystemApproval(ctx, "plan_gate") - // logr.FromContextOrDiscard, not logr.Discard: bindSlots' nil-writer branch - // returns SUCCESS having granted nothing, and says so only via - // logger.Info — discarding it would mean an approval can narrow scope with - // no grant written and no trace of why, on top of the error path already - // logged below. - // EnforcePreconditions, never waived: a plan-phase approval consents to the - // phase, not to the risk a slot precondition guards. Each binding carries its - // type's compiled Requires (via planGateBindings), so an instance the phase - // named whose gate is Refused is dropped here rather than bound — it stays - // unbound and escalates to the human waiver card at its next tool call, which - // is the one card that asks for risk consent. An instance with no precondition - // (the overwhelmingly common phase) binds exactly as it did before. - 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 { - 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 nil } + return bindings } // planGateSlotPreconditions returns the compiled precondition set per resource @@ -2572,6 +2677,69 @@ 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, read straight off the spec slots — the same plain fields +// slotOccupancyRebindFor reads, so the plan gate and the tool-approval raise +// path cannot disagree. +// +// Deliberately NOT derived through boundEntitySpecsForAutofill: that converter +// also compiles each slot's preconditions, and a compile failure would empty +// this map — reading every slot as single with the DEFAULT rebind, which offers +// and executes a move on a `rebind: never` slot. Occupancy and rebind need no +// compile, so nothing here can fail open. +func (l *Loop) slotOccupancyByType() map[string]slotOccupancy { + if l == nil || l.AgentClass == nil { + return nil + } + out := map[string]slotOccupancy{} + seen := map[string]bool{} + for _, s := range l.AgentClass.Spec.GetSlots() { + if seen[s.ResourceType] { + continue // first declaration answers, as in slotOccupancyRebindFor + } + seen[s.ResourceType] = true + 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 +3035,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 +3057,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 +3146,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..ea611318 --- /dev/null +++ b/pkg/agent/runner/host_approval_move_test.go @@ -0,0 +1,497 @@ +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" + "github.com/authzed/openagentprimitives/pkg/platform/pipeline" +) + +// 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 + wrote []authz.Relation +} + +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 (b *moveTrackingBinder) WriteGrantsPinned(_ context.Context, rels []authz.Relation, _, _ string, _ authz.SessionRef) error { + b.wrote = append(b.wrote, rels...) + 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) + +// movingBinder is a STATEFUL SlotPinner for the multi-phase approved-move path. +// The pin starts at pinned[type]; a MovePin whose fromID matches the live pin +// SUCCEEDS and repoints it, while one whose fromID no longer matches fails as a +// drift (ErrSlotPinned) — exactly as SpiceDB's MUST_MATCH precondition would. It +// records every grant written (WriteGrantsPinned) so a test can assert which +// (type, id, permission) grants actually landed. +type movingBinder struct { + pinned map[string]string // resourceType -> currently pinned instance + moveCalls int + wrote []authz.Relation +} + +func (*movingBinder) WriteRelationships(context.Context, []authz.Relation) error { return nil } +func (*movingBinder) DeleteRelationships(context.Context, []authz.Relation) error { return nil } + +func (b *movingBinder) EnsurePin(_ context.Context, resourceType, resourceID string, _ authz.SessionRef) (bool, string, error) { + if cur := b.pinned[resourceType]; cur != "" { + return true, cur, nil + } + b.pinned[resourceType] = resourceID + return false, resourceID, nil +} + +func (b *movingBinder) WriteGrantsPinned(_ context.Context, rels []authz.Relation, _, _ string, _ authz.SessionRef) error { + b.wrote = append(b.wrote, rels...) + return nil +} + +func (b *movingBinder) MovePin(_ context.Context, resourceType, fromID, toID string, _ []authz.Relation, _ authz.SessionRef) error { + cur := b.pinned[resourceType] + if cur == toID { + // Idempotent no-op: the pin already names the target (an earlier phase of + // this same approval moved it). Matches the real MovePin contract — no + // repoint, so moveCalls is NOT incremented. + return nil + } + if cur != fromID { + // Genuine drift: the pin is neither the displaced instance nor the target. + return fmt.Errorf("move pin %s:%s -> %s:%s: %w", resourceType, fromID, resourceType, toID, authz.ErrSlotPinned) + } + // A real repoint. moveCalls counts these, so "moves at most once per approval" + // is observable even though MovePin is INVOKED once per covered phase. + b.moveCalls++ + b.pinned[resourceType] = toID + return nil +} + +func (b *movingBinder) ReadPin(_ context.Context, resourceType string, _ authz.SessionRef) (string, error) { + return b.pinned[resourceType], nil +} + +func (*movingBinder) ListGrantsFor(context.Context, string, string, authz.SessionRef) ([]authz.Relation, error) { + return nil, nil +} + +func (b *movingBinder) wroteGrant(resourceType, resourceID, permission string) bool { + want := authz.SlotGrantRelationName(permission) + for _, r := range b.wrote { + if r.ResourceType == resourceType && r.ResourceID == resourceID && r.Relation == want { + return true + } + } + return false +} + +var _ authz.SlotPinner = (*movingBinder)(nil) + +// A move the human approved that cannot execute — the pin drifted since the +// card — is a BROKEN PROMISE, not an operator-log footnote. The approval must +// surface it to the approver as a fault (InternalError), rather than swallowing +// it in a slog.Info the approver never sees — and without claiming nothing was +// granted, since the decision is recorded and other types may have bound. +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 fault, not the committed-elsewhere refusal. + assert.Contains(t, pl.Lead, "fault on our side", + "a drifted move is a fault, not a refusal") + assert.NotContains(t, pl.Lead, "nothing was granted", + "the decision is recorded; the copy must not claim nothing was granted") + assert.Contains(t, pl.NextStep, "ask again") + 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) + assert.NotEqual(t, categories.InternalError, pl.Category, + "a plain pinned refusal is an answer, not a fault — it must not carry the internal-error (we-broke, retry) category") + if pl.Category == categories.SlotBindRefused { + 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") + // A movable (default-rebind) slot: the route is an amendment naming the + // instance the approval WANTED, then a new session — never pointed at + // the already-committed instance the old copy named. + assert.Contains(t, pl.NextStep, "the instance you wanted", + "the route must name the instance the approval wanted, not the committed one") + assert.Contains(t, pl.NextStep, "approved amendment moves the commitment", + "a movable slot's commitment can be moved by an approved amendment") + assert.NotContains(t, pl.NextStep, "To target the committed instance", + "the old copy pointed the approver at the wrong instance") + assert.NotContains(t, pl.Lead, "fault on our side", + "a refusal is not a fault") + } + } + assert.True(t, told, "a plain pinned refusal must tell the approver (under the slot-bind-refused category) 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") + require.Len(t, binder.wrote, 1, "the first-fill must actually bind") + assert.Equal(t, "repoB", binder.wrote[0].ResourceID) + assert.Equal(t, authz.SlotGrantRelationName("push"), binder.wrote[0].Relation) + 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") + } +} + +// A plan-scoped approval that covers TWO phases both naming the moved-to +// instance of ONE single-occupancy slot must repoint the pin exactly ONCE and +// bind BOTH phases' permissions. stampMovedFrom stamps the same repoA->repoB +// move onto every covered record, and the approval is resolved one BindApproved +// call PER covered record — so the first phase repoints repoA->repoB and the +// second re-attempts the identical move with a now-stale PriorID=repoA. Because +// MovePin is idempotent at the target (the pin already names repoB), the second +// attempt is a no-op rather than a drift-fail: both phases' grants land, no +// phase is wedged, and every covered record keeps its MovedFrom for the audit +// trail. This is what replaced the earlier dedupe-the-move band-aid. +func TestRecordPlanGateDecision_multiPhaseMoveBindsEveryCoveredPhase(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + var published []channelevents.Envelope + h := planGateHost(t, &channelevents.Envelope{}) + h.l.Mem = mem + binder := &movingBinder{pinned: map[string]string{"git_repo": "repoA"}} + h.l.SlotBinder = binder + h.l.PlanGateSlotPermissionSets = map[string][]string{"git_repo": {"read", "push"}} + h.l.InteractionRequestPublish = func(_ context.Context, _, _ string, env channelevents.Envelope) error { + published = append(published, env) + return nil + } + + readHandle, err := permsurface.NewPermHandle("read", "git_repo") + require.NoError(t, err) + pushHandle, err := permsurface.NewPermHandle("push", "git_repo") + require.NoError(t, err) + idx0, idx2 := int32(0), int32(2) + ask := planPhaseAsk() + ask.Payload["phaseKey"] = "p0" + ask.Payload["ceiling"] = []string{readHandle.String()} + // Session pinned to repoA; both covered phases name repoB. The read phase + // (0) and the push phase (2) each carry the same stamped repoA->repoB move. + ask.Payload["covered"] = []plangateaudit.Content{ + { + Event: plangateaudit.EventPhaseApproved, PhaseKey: "p0", PhaseIndex: &idx0, + Ceiling: []string{readHandle.String()}, Slots: []string{"git_repo"}, + SlotRefs: []plangateaudit.SlotRef{{Type: "git_repo", ID: "repoB", MovedFrom: "repoA"}}, + }, + { + Event: plangateaudit.EventPhaseApproved, PhaseKey: "p2", PhaseIndex: &idx2, + 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, + "a single-occupancy pin moves at most once per approval; the later phase must bind same-instance, not re-move") + assert.True(t, binder.wroteGrant("git_repo", "repoB", "read"), "the moving phase's read grant landed") + assert.True(t, binder.wroteGrant("git_repo", "repoB", "push"), + "the later phase's push grant must NOT be dropped by a spurious second move") + assert.Equal(t, "repoB", binder.pinned["git_repo"], "the pin ends on the approved instance") + for _, env := range published { + assert.NotEqual(t, categories.InternalError, decodeInteractionRequest(t, env).Category, + "a successful multi-phase move must not report an approval failure to the approver") + } +} + +// coveredPushPhases builds a plan-scoped ask whose covered phases each name one +// git_repo instance (and optionally a MovedFrom) under the push ceiling. +func coveredPushPhases(t *testing.T, refs ...plangateaudit.SlotRef) pipeline.ApprovalAsk { + t.Helper() + pushHandle, err := permsurface.NewPermHandle("push", "git_repo") + require.NoError(t, err) + ask := planPhaseAsk() + ask.Payload["phaseKey"] = "p0" + ask.Payload["ceiling"] = []string{pushHandle.String()} + covered := make([]plangateaudit.Content, 0, len(refs)) + for i, ref := range refs { + idx := int32(i) + covered = append(covered, plangateaudit.Content{ + Event: plangateaudit.EventPhaseApproved, PhaseKey: fmt.Sprintf("p%d", i), PhaseIndex: &idx, + Ceiling: []string{pushHandle.String()}, Slots: []string{"git_repo"}, + SlotRefs: []plangateaudit.SlotRef{ref}, + }) + } + ask.Payload["covered"] = covered + return ask +} + +// One approval whose phases MOVE the same pin to two different instances +// (phase 0 → repoB, phase 1 → repoC, pinned to repoA) cannot be honored. Bound +// phase by phase it used to commit the first move — revoking repoA, granting +// repoB — then drift-fail the second and tell the approver "nothing was +// granted". It must be refused whole, before any move or grant, with a notice +// that says why, and no phase recorded as approved. +func TestRecordPlanGateDecision_twoMoveTargetsForOnePinAreRefusedWhole(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + var published []channelevents.Envelope + h := planGateHost(t, &channelevents.Envelope{}) + h.l.Mem = mem + binder := &movingBinder{pinned: map[string]string{"git_repo": "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 + } + + recs := driveDecision(t, h, mem, coveredPushPhases(t, + plangateaudit.SlotRef{Type: "git_repo", ID: "repoB", MovedFrom: "repoA"}, + plangateaudit.SlotRef{Type: "git_repo", ID: "repoC", MovedFrom: "repoA"}, + ), true) + + assert.Zero(t, binder.moveCalls, "no move may run when the approval as a whole cannot be honored") + assert.Empty(t, binder.wrote, "no grant may be written") + assert.Equal(t, "repoA", binder.pinned["git_repo"], "the pin stays where it was") + for _, r := range recs { + assert.NotEqual(t, plangateaudit.EventPhaseApproved, r.Event, "an approval refused whole records no approved phase") + } + notices := approvalFailureNotices(t, published) + require.Len(t, notices, 1, "exactly one notice") + pl := notices[0] + assert.Equal(t, categories.SlotBindRefused, pl.Category, "an unhonorable plan is an answer, not a fault") + assert.Contains(t, pl.Lead, "can be committed to only one") + assert.Contains(t, pl.NextStep, "revise the plan") +} + +// A plan-scoped approval binds once per covered phase; a failure must still +// reach the approver ONCE. Three phases each naming a refused instance used to +// publish three identical refusals. +func TestRecordPlanGateDecision_refusalIsPublishedOncePerApproval(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + var published []channelevents.Envelope + h := planGateHost(t, &channelevents.Envelope{}) + h.l.Mem = mem + h.l.SlotBinder = &moveTrackingBinder{pinnedTo: "repoA"} + 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 + } + + driveDecision(t, h, mem, coveredPushPhases(t, + plangateaudit.SlotRef{Type: "git_repo", ID: "repoB"}, + plangateaudit.SlotRef{Type: "git_repo", ID: "repoB"}, + plangateaudit.SlotRef{Type: "git_repo", ID: "repoB"}, + ), true) + + notices := approvalFailureNotices(t, published) + require.Len(t, notices, 1, "one approval, one notice") + assert.Equal(t, categories.SlotBindRefused, notices[0].Category) +} + +// approvalFailureNotices filters published envelopes to the approval-failure +// notices, leaving out the plan card the ask itself published. +func approvalFailureNotices(t *testing.T, published []channelevents.Envelope) []channelevents.InteractionRequestPayload { + t.Helper() + var out []channelevents.InteractionRequestPayload + for _, env := range published { + pl := decodeInteractionRequest(t, env) + if pl.Category == categories.SlotBindRefused || pl.Category == categories.InternalError { + out = append(out, pl) + } + } + return out +} + +// The audit's case: pinned to repoA, phase 0 stays on repoA and phase 1 moves +// to repoB. Bound phase by phase, phase 1's move revoked phase 0's fresh repoA +// grant with no notice. Counting every instance, not only the moving ones, +// refuses the approval whole. +func TestRecordPlanGateDecision_stayBesideMoveIsRefusedWhole(t *testing.T) { + mem := memory.NewLocal(inmem.NewBackend()) + var published []channelevents.Envelope + h := planGateHost(t, &channelevents.Envelope{}) + h.l.Mem = mem + binder := &movingBinder{pinned: map[string]string{"git_repo": "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 + } + + driveDecision(t, h, mem, coveredPushPhases(t, + plangateaudit.SlotRef{Type: "git_repo", ID: "repoA"}, + plangateaudit.SlotRef{Type: "git_repo", ID: "repoB", MovedFrom: "repoA"}, + ), true) + + assert.Zero(t, binder.moveCalls) + assert.Empty(t, binder.wrote) + notices := approvalFailureNotices(t, published) + require.Len(t, notices, 1) + assert.Equal(t, categories.SlotBindRefused, notices[0].Category) +} + +// Occupancy and rebind are plain spec fields. A class whose slot preconditions +// do not compile must still report rebind:never — reading it through the +// precondition compiler used to empty the map, so the plan gate offered and +// executed a move on a slot the class declared unmovable. +func TestSlotRebindByType_survivesAPreconditionCompileFailure(t *testing.T) { + class := classWithGatedCompany("this is not CEL ((") + class.Spec.Authz.Slots[0].Rebind = authz.SlotRebindNever + _, err := boundEntitySpecsForAutofill(class) + require.Error(t, err, "the fixture must not compile, or the test proves nothing") + + l := &Loop{AgentClass: class} + assert.Equal(t, map[string]string{"crm_company": authz.SlotRebindNever}, l.slotRebindByType()) +} diff --git a/pkg/agent/runner/loop_autofill.go b/pkg/agent/runner/loop_autofill.go index 0a06d032..39826f38 100644 --- a/pkg/agent/runner/loop_autofill.go +++ b/pkg/agent/runner/loop_autofill.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "path/filepath" + "strings" spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" "github.com/authzed/openagentprimitives/pkg/authz" @@ -375,9 +376,35 @@ func (l *Loop) promoteObservedSlots(ctx context.Context) { } if err := l.Engine.PromoteObservedSlots(ctx, l.bindingScope(), l.authzSessionRef(), specs, l.AuthSubject()); err != nil { + if refusals := authz.SlotPinRefusals(err); len(refusals) > 0 { + // An authorization outcome, not a hiccup: a single-occupancy pin + // refused an instance. Warn (louder than a mechanism error) with the + // FULL error for an operator — including any store fault on another + // type beside it — and record ONLY the refusals so the next gated call + // on a refused type explains itself to the model rather than reading + // as a bare denial. A co-occurring fault must neither hide the refusal + // nor leak its store detail into model-facing text. + 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.recordSlotPinRefusals(ctx, specs, refusals) + return + } + // A plain mechanism error or a transient store fault: logged and left + // unbound, never surfaced to the model as a ruling — its text carries raw + // store detail and a retry may yet bind. 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 +534,181 @@ 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() + } +} + +// recordSlotPinRefusals records each refusal against the declared spec types it +// names. Recorded one refusal at a time, so a type is handed only its OWN +// refusal's text rather than every refused type's. +func (l *Loop) recordSlotPinRefusals(ctx context.Context, specs []authz.BoundEntitySpec, refusals []*authz.SlotBindFailure) { + for _, r := range refusals { + l.recordSlotPinRefusal(ctx, specs, r.Error()) + } +} + +// 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 d0bd358f..751b9d1c 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 1804930b..708aff1c 100644 --- a/pkg/agent/runner/loop_dispatch.go +++ b/pkg/agent/runner/loop_dispatch.go @@ -16,6 +16,7 @@ import ( "github.com/authzed/openagentprimitives/pkg/agent/tool" "github.com/authzed/openagentprimitives/pkg/agent/tool/sandbox" "github.com/authzed/openagentprimitives/pkg/agent/toolenvelope" + "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" @@ -206,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. @@ -739,8 +785,38 @@ 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 refusals := authz.SlotPinRefusals(perr); len(refusals) > 0 { + // An authorization outcome, not a hiccup: a + // single-occupancy pin refused an instance. Warn + // (louder than a mechanism error) with the FULL error + // for an operator, and record ONLY the refusals so the + // per-tool Check's denial on a refused type explains + // itself to the model rather than reading as a bare + // "permission denied". A store fault on another type + // beside it must neither hide the refusal nor leak its + // store detail into model-facing text. + 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.recordSlotPinRefusals(callCtx, specs, refusals) + } else { + // A plain mechanism error or a transient store fault: + // logged and left unbound, never surfaced to the model + // as a ruling — its text carries raw store detail and a + // retry may yet bind. + 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(), @@ -911,6 +987,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..ae3c229b 100644 --- a/pkg/agent/runner/plangate_notice.go +++ b/pkg/agent/runner/plangate_notice.go @@ -13,9 +13,13 @@ package runner import ( "context" + "errors" "fmt" "log/slog" + "slices" + "strings" + "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" @@ -38,8 +42,33 @@ func approverIDOf(p identity.Principal) string { return "(unidentified)" } +// approvalNotice is the copy one failed approval publishes to its approvers. +type approvalNotice struct { + category string + lead string + nextStep string +} + +// discardedApprovalNotice is the copy for an approval the gate could not RECORD +// at all — the standing lookup that bounds what the approver may delegate +// failed, so nothing was written and the card will be asked again. "Nothing was +// granted, try once more" is true here and only here: once a decision is +// recorded, the bind-time outcomes below apply instead. +func discardedApprovalNotice() approvalNotice { + return approvalNotice{ + // InternalError is the "we broke, retrying is a remedy" category. + category: categories.InternalError, + 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.", + // Bounded rather than "retry"/"don't retry" — one more attempt + // distinguishes a transient blip from a persistent one; a third never does. + nextStep: "Try approving once more. If it fails again, report it — nothing " + + "has been granted either way.", + } +} + // publishPlanGateApprovalFailed tells the session that an approval was received -// and could not be applied. +// and did not take effect as shown. // // Best-effort by the same rule as every other gate write — a notice that fails // to publish must not take down the turn — but it must never be silent, so a @@ -49,7 +78,7 @@ 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) { +func (h *runnerHost) publishPlanGateApprovalFailed(ctx context.Context, cause error, n approvalNotice) { if h.l == nil || h.l.InteractionRequestPublish == nil { return } @@ -57,23 +86,15 @@ func (h *runnerHost) publishPlanGateApprovalFailed(ctx context.Context, cause er if sessNS == "" && sessName == "" { sessNS, sessName = h.sess.Namespace, h.sess.Name } - pl := channelevents.InteractionRequestPayload{ AgentSessionRef: channelevents.SessionRef{Namespace: sessNS, Name: sessName}, - Category: categories.InternalError, + Category: n.category, 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: n.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: n.nextStep, Audience: channelevents.InteractionAudience{Scope: channelevents.AudienceApprovers}, } env, err := channelevents.BuildEnvelope(sessNS, sessName, channelevents.KindInteractionRequest, pl) @@ -160,6 +181,27 @@ 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, + }) + } + if r.PinnedImmovable != "" { + // A `rebind: never` slot pinned elsewhere: the named instance + // cannot be granted here. Carried as its own line so a surface + // cannot render the resource above as grantable — external tone + // because it is a hard limit the approver must not skim past. + item.Items = append(item.Items, channelevents.InteractionItem{ + Text: "cannot be granted — this session is pinned to " + r.PinnedImmovable + + " for its life; start a new session to reach " + r.Detail, + Tone: channelevents.ToneExternal, + }) + } } out = append(out, item) } @@ -172,3 +214,239 @@ func planGateItems(c plangate.Card) []channelevents.InteractionItem { } return out } + +// slotOutcome is how one resource type fared across an approval's binds. +type slotOutcome int + +const ( + slotBound slotOutcome = iota + // slotFault: a store fault, or an approved move whose pin drifted since the + // card. Nothing changed for the type; the agent asks again when it needs it. + slotFault + // slotMoveCommitted: the approved move landed — the displaced instance's + // access is revoked — but the grant on the new instance did not. + slotMoveCommitted + // slotRefused: the type is committed to a different instance; an answer, + // not a fault, and re-approving will not change it. + slotRefused +) + +// approvalOutcome aggregates, PER RESOURCE TYPE, how one approval's binds +// landed across every phase it covers, so the approver gets ONE notice that is +// true for the approval as a whole. +// +// Per type because GrantSlots is per-type partitioned: one type can be refused +// while another binds and a third blips, and a single label for the whole call +// lies about at least two of them. Across phases because a plan-scoped approval +// binds once per covered phase: publishing per phase repeated the notice for +// each, and could contradict itself — a "move committed but not granted" from +// phase 1 beside a later phase whose same-instance bind then wrote that grant. +// +// Merge rule, per (type, permission) in phase order: a refusal is sticky (the +// instance it named was not granted, whatever a later phase did); otherwise the +// LATEST outcome wins, so a later successful bind of the same grant supersedes +// an earlier fault or committed-move. A failure covers every permission its type +// was bound with in that phase — a type's grants land together or not at all. +// +// A type held out by a precondition (BindApproved drops it without error) reads +// as bound here; it escalates to the waiver card on its own, which is the +// notice it needs. +type approvalOutcome struct { + order []outcomeKey + byKey map[outcomeKey]slotOutcome + causes []error +} + +// outcomeKey is one grant an approval asked for. Keyed by permission as well as +// type because each covered phase binds its own permissions: a later phase that +// writes `push` on a type must not paper over an earlier phase whose `read` on +// the same type failed. +type outcomeKey struct{ resourceType, permission string } + +// record folds one phase's BindApproved result in. It reports false when err +// carries no per-type slot outcome at all (a session_scope write failure before +// GrantSlots ran); the caller keeps that log-only, as before. +func (o *approvalOutcome) record(bindings []authz.SlotBinding, err error) bool { + if o.byKey == nil { + o.byKey = map[outcomeKey]slotOutcome{} + } + failures := authz.SlotBindFailures(err) + if err != nil && len(failures) == 0 { + return false + } + if err != nil { + o.causes = append(o.causes, err) + } + failed := map[string]slotOutcome{} + for _, f := range failures { + k := slotFault + switch f.Kind { + case authz.SlotFailureRefused: + k = slotRefused + case authz.SlotFailureMoveCommitted: + k = slotMoveCommitted + case authz.SlotFailureFault, authz.SlotFailureDrifted: + } + for _, rt := range f.ResourceTypes { + failed[rt] = k + } + } + for _, b := range bindings { + k, ok := failed[b.ResourceType] + if !ok { + k = slotBound + } + o.merge(outcomeKey{b.ResourceType, b.Permission}, k) + } + return true +} + +func (o *approvalOutcome) merge(key outcomeKey, k slotOutcome) { + prev, seen := o.byKey[key] + if !seen { + o.order = append(o.order, key) + } + if seen && prev == slotRefused { + return + } + o.byKey[key] = k +} + +// typesWith lists, once each and in first-seen order, the types holding at +// least one grant with outcome k. +func (o *approvalOutcome) typesWith(k slotOutcome) []string { + var out []string + for _, key := range o.order { + if o.byKey[key] == k && !slices.Contains(out, key.resourceType) { + out = append(out, key.resourceType) + } + } + return out +} + +// cause joins every recorded error, for the log line beside the notice. +func (o *approvalOutcome) cause() error { return errors.Join(o.causes...) } + +// notice builds the one approver notice the outcome warrants; ok=false when +// every type bound. +// +// The primary copy follows the most consequential fact — a committed move (an +// instance's access was REMOVED), then a refusal (an answer), then a fault — +// and any other fact present is appended to the next step, so nothing the +// approver needs is dropped and nothing claims more than happened. In +// particular no bind-time copy says "nothing was granted": the decision is +// recorded and the other types may well have bound. +// +// The refusal route follows the REFUSED types' policy only (authz.AllRebindNever +// over the refusals, never over every binding): when every refused slot is +// `rebind: never`, no amendment can move it and the only route is a new +// session. +func (o *approvalOutcome) notice() (approvalNotice, bool) { + committed, refused, faulted := o.typesWith(slotMoveCommitted), o.typesWith(slotRefused), o.typesWith(slotFault) + var n approvalNotice + switch { + case len(committed) > 0: + n = approvalNotice{ + category: categories.InternalError, + lead: "Your approval moved this session to the new target for " + typeList(committed) + + " 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 len(refused) > 0: + // An answer, not a fault: SlotBindRefused, not InternalError — the + // category is what a surface reads to tell "we erred, try again" from + // "this is the answer". + n = approvalNotice{ + category: categories.SlotBindRefused, + lead: "This session is already committed to a different target for " + typeList(refused) + + ", so the instance your approval named there was not granted. Other instances the plan " + + "named may have been granted; that one was not.", + } + if authz.AllRebindNever(authz.SlotPinRefusals(o.cause())) { + n.nextStep = "This commitment is fixed for the session's life and cannot be moved. " + + "Start a new session to work on the instance you wanted. Re-approving this card will not change it." + } else { + n.nextStep = "To reach the instance you wanted, the plan must name it so an approved amendment " + + "moves the commitment to it; otherwise start a new session for it. Re-approving this card will not move it." + } + case len(faulted) > 0: + n = approvalNotice{ + category: categories.InternalError, + lead: "Your approval was recorded but could not be fully applied, so some or all of the access " + + "it covers (" + typeList(faulted) + ") was not granted. This is a fault on our side, not a refusal.", + nextStep: "The agent will ask again when it reaches anything that was not granted. If that fails too, report it.", + } + default: + return approvalNotice{}, false + } + if len(committed) > 0 && len(refused) > 0 { + n.nextStep += " Separately, this session is committed to a different target for " + typeList(refused) + + ", so the instance named there was not granted." + } + if (len(committed) > 0 || len(refused) > 0) && len(faulted) > 0 { + n.nextStep += " Separately, access to " + typeList(faulted) + + " could not be granted because of a fault on our side; the agent will ask again when it reaches it." + } + return n, true +} + +func typeList(types []string) string { return strings.Join(types, ", ") } + +// conflictingMoveTargets returns, per single-occupancy resource type, the +// distinct instances an approval would bind when it MOVES that type's pin and +// names more than one instance of it across its covered phases. Such a plan +// cannot be honored — a session holds one — and executing it phase by phase +// goes wrong whichever way round it lands: two moves commit the first (revoking +// the prior instance) and then fail the second; a phase that stays on the +// pinned instance beside one that moves off it binds the first, then the move +// revokes that fresh grant with no notice at all. Every binding counts, moving +// or staying, so the approval is refused whole before anything is written. +// +// A type with no move among its bindings is left to GrantSlots, which refuses +// the extra instance per type with its own notice. +func conflictingMoveTargets(phases [][]authz.SlotBinding) map[string][]string { + targets := map[string][]string{} + moving := map[string]bool{} + for _, bindings := range phases { + for _, b := range bindings { + if b.Occupancy == authz.SlotOccupancyMulti { + continue + } + if !b.PriorID.IsZero() { + moving[b.ResourceType] = true + } + id := b.ResourceID.String() + if !slices.Contains(targets[b.ResourceType], id) { + targets[b.ResourceType] = append(targets[b.ResourceType], id) + } + } + } + for rt, ids := range targets { + if len(ids) < 2 || !moving[rt] { + delete(targets, rt) + } + } + if len(targets) == 0 { + return nil + } + return targets +} + +// moveConflictNotice is the copy for an approval refused whole by +// conflictingMoveTargets. Nothing was recorded or written, so the card will be +// asked again — and will conflict again until the plan changes, which is the +// route it names. +func moveConflictNotice(conflict map[string][]string) approvalNotice { + types := make([]string, 0, len(conflict)) + for rt := range conflict { + types = append(types, rt) + } + slices.Sort(types) + return approvalNotice{ + category: categories.SlotBindRefused, + lead: "This plan moves " + typeList(types) + " to a new target but also names another one, and a " + + "session can be committed to only one, so your approval was not applied and nothing was granted.", + nextStep: "Ask the agent to revise the plan so it works on one target per session, or split the " + + "work across separate sessions. Re-approving this card will not change it.", + } +} diff --git a/pkg/agent/runner/plangate_notice_test.go b/pkg/agent/runner/plangate_notice_test.go index 6eaa5da6..e3cf2ecd 100644 --- a/pkg/agent/runner/plangate_notice_test.go +++ b/pkg/agent/runner/plangate_notice_test.go @@ -1,13 +1,18 @@ package runner import ( + "errors" + "fmt" + "strings" "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" + "github.com/authzed/openagentprimitives/pkg/channels/channelinteractions/categories" ) // A read and an irreversible push must not look identical on the card: the @@ -120,3 +125,213 @@ 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) +} + +// A `rebind: never` slot pinned elsewhere carries a BLOCKED line over the wire: +// the resource line, then its own line saying the named instance cannot be +// granted and the new-session route — external tone so a surface cannot render +// the resource above as grantable. +func TestPlanGateItems_PinnedImmovableCarriesABlockedLine(t *testing.T) { + items := planGateItems(plangate.Card{Phases: []plangate.CardPhase{{ + Title: "Phase 1", + Resources: []plangate.CardLine{{Text: "git_repo", Detail: "repoB", PinnedImmovable: "repoA"}}, + }}}) + require.Len(t, items, 1) + require.Len(t, items[0].Items, 2, "the resource line, then its blocked notice") + assert.Equal(t, "repoB", items[0].Items[0].Detail) + assert.Contains(t, items[0].Items[1].Text, "cannot be granted") + assert.Contains(t, items[0].Items[1].Text, "pinned to repoA") + assert.Contains(t, items[0].Items[1].Text, "start a new session to reach repoB") + assert.Equal(t, channelevents.ToneExternal, items[0].Items[1].Tone) +} + +// The approver gets ONE notice per approval, and it must be true for every type +// the approval covered: GrantSlots resolves each type on its own, so one call can +// bind one type, refuse another and blip on a third. Each case folds one or more +// phases' outcomes, the way recordPlanGateDecision does, and checks the copy. +func TestApprovalOutcome_Notice(t *testing.T) { + refused := func(rt, rebind string) *authz.SlotBindFailure { + return authz.NewSlotBindFailure(authz.SlotFailureRefused, rebind, + fmt.Errorf("%w: this session is pinned to %s:A", authz.ErrSlotPinned, rt), rt) + } + fault := func(rt string) *authz.SlotBindFailure { + return authz.NewSlotBindFailure(authz.SlotFailureFault, "", + fmt.Errorf("%w: ensure pin: dial tcp 10.0.0.1:50051: Unavailable", authz.ErrSlotStore), rt) + } + committed := func(rt string) *authz.SlotBindFailure { + return authz.NewSlotBindFailure(authz.SlotFailureMoveCommitted, "", + fmt.Errorf("%w: write failed", authz.ErrSlotMoveCommitted), rt) + } + drifted := func(rt string) *authz.SlotBindFailure { + return authz.NewSlotBindFailure(authz.SlotFailureDrifted, "", + fmt.Errorf("%w: %w", authz.ErrSlotMoveDrifted, authz.ErrSlotPinned), rt) + } + type phase struct { + types []string + failures []*authz.SlotBindFailure + } + // A type may be written "type/permission"; a bare type binds "read". + bindingsOf := func(types ...string) []authz.SlotBinding { + out := make([]authz.SlotBinding, 0, len(types)) + for _, rt := range types { + perm := "read" + if i := strings.IndexByte(rt, '/'); i >= 0 { + rt, perm = rt[:i], rt[i+1:] + } + out = append(out, authz.SlotBinding{ResourceType: rt, Permission: perm}) + } + return out + } + cases := []struct { + name string + phases []phase + wantNotice bool + wantCategory string + leadHas []string + leadNot []string + nextHas []string + nextNot []string + }{ + {name: "every type bound: no notice", + phases: []phase{{types: []string{"git_repo", "label"}}}}, + {name: "partial store fault: names the faulted type and never claims nothing was granted", + phases: []phase{{types: []string{"git_repo", "label"}, failures: []*authz.SlotBindFailure{fault("label")}}}, + wantNotice: true, wantCategory: categories.InternalError, + leadHas: []string{"could not be fully applied", "label", "fault on our side"}, + leadNot: []string{"nothing was granted", "git_repo", "dial tcp"}, + nextHas: []string{"ask again"}, nextNot: []string{"nothing has been granted"}}, + {name: "drifted move: a fault, never a refusal", + phases: []phase{{types: []string{"git_repo"}, failures: []*authz.SlotBindFailure{drifted("git_repo")}}}, + wantNotice: true, wantCategory: categories.InternalError, + leadHas: []string{"fault on our side"}, leadNot: []string{"already committed"}}, + {name: "store fault beside a refusal: refusal copy, the fault named separately, no store text", + phases: []phase{{types: []string{"git_repo", "label"}, + failures: []*authz.SlotBindFailure{refused("git_repo", ""), fault("label")}}}, + wantNotice: true, wantCategory: categories.SlotBindRefused, + leadHas: []string{"already committed to a different target for git_repo", "Other instances"}, + leadNot: []string{"dial tcp"}, + nextHas: []string{"approved amendment moves the commitment", "Separately, access to label", "fault on our side"}, + nextNot: []string{"dial tcp"}}, + {name: "refused rebind:never beside a bound default type: new session only", + phases: []phase{{types: []string{"ticket", "git_repo"}, + failures: []*authz.SlotBindFailure{refused("git_repo", authz.SlotRebindNever)}}}, + wantNotice: true, wantCategory: categories.SlotBindRefused, + nextHas: []string{"cannot be moved", "Start a new session"}, nextNot: []string{"amendment"}}, + {name: "refused never and refused movable: the amendment route stays open", + phases: []phase{{types: []string{"ticket", "git_repo"}, + failures: []*authz.SlotBindFailure{refused("git_repo", authz.SlotRebindNever), refused("ticket", "")}}}, + wantNotice: true, wantCategory: categories.SlotBindRefused, + nextHas: []string{"approved amendment"}}, + {name: "committed move with a fault elsewhere: says what was revoked, names the fault", + phases: []phase{{types: []string{"git_repo", "label"}, + failures: []*authz.SlotBindFailure{committed("git_repo"), fault("label")}}}, + wantNotice: true, wantCategory: categories.InternalError, + leadHas: []string{"moved this session to the new target for git_repo", "removed its access"}, + nextHas: []string{"Separately, access to label"}}, + {name: "a later phase's bind supersedes an earlier committed move: no notice", + phases: []phase{ + {types: []string{"git_repo"}, failures: []*authz.SlotBindFailure{committed("git_repo")}}, + {types: []string{"git_repo"}}, + }}, + {name: "a later phase's bind supersedes an earlier fault: no notice", + phases: []phase{ + {types: []string{"git_repo"}, failures: []*authz.SlotBindFailure{fault("git_repo")}}, + {types: []string{"git_repo"}}, + }}, + {name: "a later phase binding a different permission does not hide an earlier committed move", + phases: []phase{ + {types: []string{"git_repo/read"}, failures: []*authz.SlotBindFailure{committed("git_repo")}}, + {types: []string{"git_repo/push"}}, + }, + wantNotice: true, wantCategory: categories.InternalError, + leadHas: []string{"removed its access"}}, + {name: "a refusal is sticky across a later phase's bind", + phases: []phase{ + {types: []string{"git_repo"}, failures: []*authz.SlotBindFailure{refused("git_repo", "")}}, + {types: []string{"git_repo"}}, + }, + wantNotice: true, wantCategory: categories.SlotBindRefused}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var o approvalOutcome + for _, ph := range tc.phases { + var err error + if len(ph.failures) > 0 { + errs := make([]error, 0, len(ph.failures)) + for _, f := range ph.failures { + errs = append(errs, f) + } + err = fmt.Errorf("bindSlots(approved): grant slots: %w", errors.Join(errs...)) + } + require.True(t, o.record(bindingsOf(ph.types...), err)) + } + n, ok := o.notice() + require.Equal(t, tc.wantNotice, ok) + if !ok { + return + } + assert.Equal(t, tc.wantCategory, n.category) + for _, want := range tc.leadHas { + assert.Contains(t, n.lead, want) + } + for _, not := range tc.leadNot { + assert.NotContains(t, n.lead, not) + } + for _, want := range tc.nextHas { + assert.Contains(t, n.nextStep, want) + } + for _, not := range tc.nextNot { + assert.NotContains(t, n.nextStep, not) + } + }) + } +} + +// An error raised before GrantSlots ran (a session_scope write) carries no +// per-type outcome; record says so and the caller keeps it log-only. +func TestApprovalOutcome_RecordRejectsANonSlotError(t *testing.T) { + var o approvalOutcome + assert.False(t, o.record([]authz.SlotBinding{{ResourceType: "git_repo"}}, errors.New("write session_scope: conflict"))) + _, ok := o.notice() + assert.False(t, ok) +} + +// Two phases moving one pin to two different instances is detected before any +// write; the same move repeated across phases (idempotent at the target) and a +// multi-occupancy type are not conflicts. +func TestConflictingMoveTargets(t *testing.T) { + move := func(rt, id, prior string) authz.SlotBinding { + return authz.SlotBinding{ResourceType: rt, ResourceID: authz.TrustedObjectID(id), PriorID: authz.TrustedObjectID(prior)} + } + assert.Equal(t, map[string][]string{"git_repo": {"repoB", "repoC"}}, + conflictingMoveTargets([][]authz.SlotBinding{{move("git_repo", "repoB", "repoA")}, {move("git_repo", "repoC", "repoA")}})) + assert.Nil(t, conflictingMoveTargets([][]authz.SlotBinding{{move("git_repo", "repoB", "repoA")}, {move("git_repo", "repoB", "repoA")}}), + "the same move in every phase is one move") + multi := move("label", "b", "a") + multi.Occupancy = authz.SlotOccupancyMulti + multi2 := move("label", "c", "a") + multi2.Occupancy = authz.SlotOccupancyMulti + assert.Nil(t, conflictingMoveTargets([][]authz.SlotBinding{{multi}, {multi2}}), "a multi-occupancy slot holds no pin to conflict over") + assert.Nil(t, conflictingMoveTargets([][]authz.SlotBinding{{ + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("repoB")}, + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("repoC")}, + }}), "first-fills are not moves; GrantSlots refuses that per type") + stay := authz.SlotBinding{ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("repoA")} + assert.Equal(t, map[string][]string{"git_repo": {"repoA", "repoB"}}, + conflictingMoveTargets([][]authz.SlotBinding{{stay}, {move("git_repo", "repoB", "repoA")}}), + "a phase staying on the pin beside one moving off it is a conflict: the move would revoke the stay's grant") +} 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..67682e06 --- /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 an 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 — GrantSlots executes that move in the type's own per-type +// handling inside BindApproved — and its type gets MovedBy +// (planGateApprovalRef(rec)) and MovedAt stamped, but only when the read-back +// pin equals the move's target. +// +// Called after per-type failures too, on purpose: GrantSlots resolves each type +// on its own, so a call that failed for one type may still have moved or +// pinned another, and a committed move changed the pin even though its grant +// failed. Reading the pin back is what keeps this honest either way. +// status.slotPins is display-only and may lag; the next bind 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 bafde7b4..2cd080f0 100644 --- a/pkg/agent/runner/status.go +++ b/pkg/agent/runner/status.go @@ -586,6 +586,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 fefa8573..598693a2 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 d7b00e00..36ee3d43 100644 --- a/pkg/apis/v1alpha1/agentsession_types.go +++ b/pkg/apis/v1alpha1/agentsession_types.go @@ -44,6 +44,13 @@ type AgentSessionList struct { // different session, which silently moves a running session's approval and // transcript-read standing to a different set of humans. // +kubebuilder:validation:XValidation:rule="!has(oldSelf.parent) || (has(self.parent) && self.parent == oldSelf.parent)",message="spec.parent is immutable once set" +// +// forkedFrom is pinned harder than parent: it may be neither added, removed nor +// repointed after create, because only the operator's fork path sets it, and +// only at create (BuildChildSession). A field-level rule cannot express this — +// it never runs when the field is absent on either side — hence the spec-level +// transition rule comparing presence as well as value. +// +kubebuilder:validation:XValidation:rule="has(self.forkedFrom) == has(oldSelf.forkedFrom) && (!has(self.forkedFrom) || self.forkedFrom == oldSelf.forkedFrom)",message="spec.forkedFrom is immutable: it cannot be added, removed or changed after create" type AgentSessionSpec struct { // Class names the AgentClass in the same namespace. Class string `json:"class"` @@ -76,7 +83,10 @@ type AgentSessionSpec struct { OutputChannel *ChannelBinding `json:"outputChannel,omitempty"` // ForkedFrom names the prior AgentSession (same namespace) this - // session was forked from via Restart-from-here. Immutable. + // session was forked from via Restart-from-here. Immutable after + // create (CEL rule on AgentSessionSpec). Whoever creates the CR picks + // the value and nothing verifies that the named session exists, so it + // must never gate anything security-relevant. // Distinct from ChannelBinding.InheritFrom (set on auto-inherit // after archive); ForkedFrom is set on explicit user-triggered // restart. @@ -220,6 +230,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/agentsession_validation_test.go b/pkg/apis/v1alpha1/agentsession_validation_test.go index 3ffc9684..90104731 100644 --- a/pkg/apis/v1alpha1/agentsession_validation_test.go +++ b/pkg/apis/v1alpha1/agentsession_validation_test.go @@ -23,6 +23,7 @@ package v1alpha1_test import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -104,3 +105,46 @@ func TestAgentSessionParentImmutableCEL(t *testing.T) { assert.Contains(t, err.Error(), "spec.parent is immutable once set") }) } + +// TestAgentSessionForkedFromImmutableCEL pins spec.forkedFrom as fully +// immutable after create: unlike parent, it may not even be ADDED later. The +// field is creator-chosen and unverified, so a value that could change after +// the operator first reconciled the session would be a lever with no owner. +func TestAgentSessionForkedFromImmutableCEL(t *testing.T) { + env := testenv.Start(t) + ctx := context.Background() + + const msg = "spec.forkedFrom is immutable" + cases := []struct { + name string + createWith string + updateTo string + wantErr bool + }{ + {name: "A to A: allowed (a re-apply is a no-op)", createWith: "session-a", updateTo: "session-a", wantErr: false}, + {name: "unset to unset: allowed (unrelated spec edits are untouched)", createWith: "", updateTo: "", wantErr: false}, + {name: "unset to A: rejected (adding after create)", createWith: "", updateTo: "session-a", wantErr: true}, + {name: "A to unset: rejected (removing)", createWith: "session-a", updateTo: "", wantErr: true}, + {name: "A to B: rejected (repointing)", createWith: "session-a", updateTo: "session-b", wantErr: true}, + } + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sess := newParentTestSession(fmt.Sprintf("forkedfrom-%d", i)) + sess.Spec.ForkedFrom = tc.createWith + require.NoError(t, env.Client.Create(ctx, sess), "create") + + var fresh v1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, client.ObjectKeyFromObject(sess), &fresh), "Get before update") + fresh.Spec.ForkedFrom = tc.updateTo + fresh.Spec.Prompt.Inline = "edited" + err := env.Client.Update(ctx, &fresh) + if !tc.wantErr { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.True(t, apierrors.IsInvalid(err), "expected an Invalid API error, got: %v", err) + assert.Contains(t, err.Error(), msg) + }) + } +} 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 360f961f..a651b2e5 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)) @@ -6577,6 +6584,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..a35ab801 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,20 @@ 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, + // Carried so GrantSlots can execute an approved MOVE as part of this + // type's own per-type handling (repoint the pin off PriorID, revoke + // its grants, write the new grant) rather than in a separate + // all-types pass. Zero for every first-fill — class defaults and + // promoted extractions never set it — so their grant path is byte-for- + // byte unchanged. + PriorID: c.PriorID, + }) } var scopeErr error @@ -522,6 +548,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 +559,13 @@ func BindApproved( return nil } } + // The approved MOVE is no longer a separate all-types pass here: each moving + // candidate carries its PriorID through bindSlots into GrantSlots, which + // repoints the pin and revokes the prior instance's grants as part of THAT + // type's own per-type handling, atomically, alongside writing the new grant. + // That is what keeps one type's move failure from stranding or mislabeling + // another type's grant (the all-moves-first pass that used to live here could + // do both). A per-type outcome — a drift, a committed move, a refusal — rides + // out on the joined error GrantSlots already returns. return bindSlots(ctx, mem, memScope, sess, w, scope.SourceApproved, logger, now, expiresAt, bound) } diff --git a/pkg/authz/bind_approved_test.go b/pkg/authz/bind_approved_test.go index b8a4f9e5..080afc1f 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,105 @@ 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 + listErr map[string]error // per (type\x00id), injected into ListGrantsFor +} + +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{} + } + key := scope.String() + "\x00" + resourceType + cur := w.pins[key] + // Model the real MovePin's MUST_MATCH(fromID) and its idempotent no-op at the + // target: if the pin already names toID, a prior phase of the same approval + // landed this exact move, so it is a no-op (nil), never a drift. If the pin + // is NEITHER fromID nor toID, MUST_MATCH fails for real — genuine drift. + if cur == toID { + return nil + } + if cur != fromID { + return fmt.Errorf("fake move pin %s:%s -> %s:%s: %w", resourceType, fromID, resourceType, toID, ErrSlotPinned) + } + w.ops = append(w.ops, "move") + w.moves = append(w.moves, moveCall{resourceType: resourceType, fromID: fromID, toID: toID, revoke: revoke}) + w.pins[key] = 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) { + if err := w.listErr[grantStoreKey(resourceType, resourceID)]; err != nil { + return nil, err + } + 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 +266,290 @@ 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"]) +} + +// One type's store failure must NOT strand another type's move-and-grant. +// GrantSlots handles each type independently, so when crm_company's revoke-set +// read fails, git_repo's move has ALREADY landed its new grant in git_repo's own +// iteration — not been abandoned before bindSlots ran (the all-moves-first pass +// this replaces would have dropped it). crm_company's failure rides out as a +// transient store fault for that type alone, never mislabeled as a committed +// move of git_repo. +func TestBindApproved_oneTypesStoreFailureLeavesAnotherTypesMoveGrantIntact(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() } + gitPrior := SlotGrantRelation("git_repo", "repoA", "push", sess) + w := &approvedRecordingWriter{ + pins: map[string]string{ + sess.String() + "\x00git_repo": "repoA", + sess.String() + "\x00crm_company": "coX", + }, + grantsFor: map[string][]Relation{grantStoreKey("git_repo", "repoA"): {gitPrior}}, + // crm_company's revoke-set read fails; git_repo's move is unaffected. + listErr: map[string]error{grantStoreKey("crm_company", "coX"): errors.New("spicedb read 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")}, + {ResourceType: "crm_company", ResourceID: TrustedObjectID("coY"), Permission: "contact_access", PriorID: TrustedObjectID("coX")}, + }, + EnforcePreconditions, now().Add(time.Hour), logr.Discard(), now) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrSlotStore, "the failing type surfaces as a transient store fault") + assert.NotErrorIs(t, err, ErrSlotMoveCommitted, + "git_repo's success must not be dragged into a committed-move report for a sibling's failure") + // git_repo fully landed: moved and granted. + assert.Len(t, w.moves, 1, "git_repo's move landed independently") + assert.Equal(t, "repoB", w.pins[sess.String()+"\x00git_repo"], "git_repo repointed to the new instance") + 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 + }), "git_repo's new grant is written, not stranded by the sibling type's failure") + // crm_company never moved. + assert.Equal(t, "coX", w.pins[sess.String()+"\x00crm_company"], "the failing type's pin is untouched") +} + +// A plan-scoped approval resolves one BindApproved call per covered phase, and +// each re-attempts the SAME move. The first repoints the pin; every later one +// finds the pin already at the target and is a no-op (MovePin's idempotency) — +// not a drift that drops that phase's grant. This is what lets the move stop +// depending on which covered phase happens to run first. +func TestBindApproved_repeatedMoveAcrossPhasesIsIdempotentNotDrift(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() } + gitPrior := 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"): {gitPrior}}, + } + + move := []SlotBinding{{ + ResourceType: "git_repo", ResourceID: TrustedObjectID("repoB"), + Permission: "push", PriorID: TrustedObjectID("repoA"), + }} + + // Phase 1: the real move. + require.NoError(t, BindApproved(ctx, mem, memScope, sess, w, move, + EnforcePreconditions, now().Add(time.Hour), logr.Discard(), now)) + assert.Len(t, w.moves, 1, "the first phase performs the real move") + assert.Equal(t, "repoB", w.pins[sess.String()+"\x00git_repo"]) + + // Phase 2: the SAME move, with the now-stale PriorID=repoA. Must succeed as a + // no-op (pin already repoB) and still write the grant — not drift-fail. + require.NoError(t, BindApproved(ctx, mem, memScope, sess, w, move, + EnforcePreconditions, now().Add(time.Hour), logr.Discard(), now), + "a repeated move whose target is already pinned must be an idempotent no-op, not a drift") + assert.Len(t, w.moves, 1, "no second MovePin is performed; the pin already names the target") + assert.Equal(t, "repoB", w.pins[sess.String()+"\x00git_repo"], "the pin stays on the target") +} + +// Two DISTINCT move targets for ONE single-occupancy type in one approval +// cannot both occupy the slot. GrantSlots refuses the type (two distinct +// instances) 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. GrantSlots must SKIP the move (no MovePin, no needless +// revoke of the instance's own grants) and bind it through the normal +// same-instance pinned path (EnsurePin + WriteGrantsPinned). +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..44741195 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,33 @@ 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) + if len(added) > 0 || len(addedSlots) > 0 { + // An amendment card renders ONLY its delta, but the move is stamped on + // every covered ref — including an unchanged, already-approved slot whose + // pin has since moved elsewhere. Approving executes that move and revokes + // the current instance, so it must be on the card the human approves. + addedSlots = withUnshownPinChanges(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 +1165,181 @@ 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) pinned to a DIFFERENT instance is +// stamped PinnedImmovable, not MovedFrom: it cannot move by approval, so the +// card must say the named instance cannot be granted (new-session route) +// rather than render a plain first-fill. The bind still takes the ordinary +// first-fill path and GrantSlots refuses it; +// - 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 + } + 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() { + // Already pinned to this instance — a same-instance re-grant, not + // a move and not a block. Nothing to record. + continue + } + if h.deps.SlotRebind[ref.Type] == authz.SlotRebindNever { + // rebind: never — the pin is fixed for the session's life, so the + // instance this phase names CANNOT be granted here. Record it as + // immovable (not a move) so the card says exactly that with the + // new-session route, rather than rendering a plain first-fill the + // human would read as grantable. No PriorID is derived from this, + // so the bind takes the ordinary first-fill path and GrantSlots + // refuses the instance. + ref.PinnedImmovable = pinned + continue + } + ref.MovedFrom = pinned + } + } +} + +// threadMovedFrom copies the pin state stampMovedFrom recorded on the covered +// records onto the card's added slots, matched by (type, id): MovedFrom so the +// amendment card renders a re-point as a move, and PinnedImmovable so a +// `rebind: never` slot pinned elsewhere renders as a blocked line 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 state is left a first-fill. A no-op when nothing was +// stamped. +func threadMovedFrom(slots []plangate.Slot, covered []plangateaudit.Content) []plangate.Slot { + if len(slots) == 0 { + return slots + } + moved := map[string]string{} + immovable := map[string]string{} + for _, rec := range covered { + for _, ref := range rec.SlotRefs { + if ref.ID == "" { + continue + } + key := ref.Type + "\x00" + ref.ID + if ref.MovedFrom != "" { + moved[key] = ref.MovedFrom + } + if ref.PinnedImmovable != "" { + immovable[key] = ref.PinnedImmovable + } + } + } + if len(moved) == 0 && len(immovable) == 0 { + return slots + } + out := append([]plangate.Slot(nil), slots...) + for i := range out { + key := out[i].Type + "\x00" + out[i].ID + if mf, ok := moved[key]; ok { + out[i].MovedFrom = mf + } + if pi, ok := immovable[key]; ok { + out[i].PinnedImmovable = pi + } + } + return out +} + +// withUnshownPinChanges appends to an amendment's delta every covered slot ref +// that carries a pin change (MovedFrom or PinnedImmovable) the delta does not +// already show, so the card renders every move and block its approval +// executes. Returns a copy; slots is never mutated. +func withUnshownPinChanges(slots []plangate.Slot, covered []plangateaudit.Content) []plangate.Slot { + shown := map[string]struct{}{} + for _, s := range slots { + shown[s.Type+"\x00"+s.ID] = struct{}{} + } + out := append([]plangate.Slot(nil), slots...) + for _, rec := range covered { + for _, ref := range rec.SlotRefs { + if ref.ID == "" || (ref.MovedFrom == "" && ref.PinnedImmovable == "") { + continue + } + key := ref.Type + "\x00" + ref.ID + if _, ok := shown[key]; ok { + continue + } + shown[key] = struct{}{} + out = append(out, plangate.Slot{Type: ref.Type, ID: ref.ID, MovedFrom: ref.MovedFrom, PinnedImmovable: ref.PinnedImmovable}) + } + } + 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..dbc22e4a --- /dev/null +++ b/pkg/authz/hooks/plangate_move_test.go @@ -0,0 +1,264 @@ +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 pinned to a DIFFERENT instance cannot move by approval, +// so it records no MOVE — but it must not render as a plain first-fill either, +// which would read to the approver as grantable. It is recorded immovable and +// the card says the named instance cannot be granted, with the new-session +// route. The bind is then refused by GrantSlots. +func TestRequestPhaseApproval_rebindNeverRendersBlockedNotGrantable(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.Equal(t, "repoA", ref.PinnedImmovable, + "a never slot pinned elsewhere is recorded immovable so the card can say it can't be granted") + + card := cardFromPhaseAsk(t, h) + assert.NotContains(t, card.What, "→", "a never slot must not render as a move") + assert.Contains(t, card.What, "Cannot be granted", "the card must say the named instance can't be granted") + assert.Contains(t, card.What, "pinned to repoA", "it must name the instance the pin is stuck on") + assert.Contains(t, card.What, "Start a new session", "and the route out") +} + +// 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) +} + +// threadMovedFrom also carries an IMMOVABLE pin (a `rebind: never` slot pinned +// elsewhere) onto the card's slots, matched by (type, id) — the same path +// MovedFrom takes — so the card can render it as a block rather than a grant. +func TestThreadMovedFrom_carriesPinnedImmovable(t *testing.T) { + covered := []plangateaudit.Content{{SlotRefs: []plangateaudit.SlotRef{ + {Type: "git_repo", ID: "repoB", PinnedImmovable: "repoA"}, + {Type: "crm_company", ID: "4299", MovedFrom: "4210"}, // a move alongside, unaffected + }}} + in := []plangate.Slot{ + {Type: "git_repo", ID: "repoB"}, + {Type: "crm_company", ID: "4299"}, + } + + out := threadMovedFrom(in, covered) + + require.Len(t, out, 2) + assert.Equal(t, "repoA", out[0].PinnedImmovable, "the immovable pin is carried onto the slot") + assert.Empty(t, out[0].MovedFrom, "an immovable slot is not a move") + assert.Equal(t, "4210", out[1].MovedFrom, "a move alongside is still carried") + assert.Empty(t, out[1].PinnedImmovable) + assert.Empty(t, in[0].PinnedImmovable, "the input delta must not be mutated") +} + +// An amendment card renders only its delta, but a move can be stamped on a +// covered ref the delta does not contain — an unchanged slot whose pin has since +// moved elsewhere. Approving executes that move, so the card must show it: an +// amendment that adds only a handle still renders " → ". +func TestWithUnshownPinChanges_amendmentShowsEveryMoveItExecutes(t *testing.T) { + covered := []plangateaudit.Content{{SlotRefs: []plangateaudit.SlotRef{ + {Type: "crm_company", ID: "4299", MovedFrom: "4210"}, // unchanged slot, pin moved + {Type: "git_repo", ID: "repoB", PinnedImmovable: "repoA"}, + {Type: "crm_contact", ID: "c1"}, // no pin change: stays off the delta + }}} + in := []plangate.Slot{{Type: "git_repo", ID: "repoB", PinnedImmovable: "repoA"}} // already shown + + out := withUnshownPinChanges(in, covered) + + require.Len(t, out, 2, "the unshown move is added; the shown block and the unchanged slot are not") + assert.Equal(t, plangate.Slot{Type: "crm_company", ID: "4299", MovedFrom: "4210"}, out[1]) + assert.Len(t, in, 1, "the input delta must not be mutated") + + card := plangate.BuildCard(plangate.CardInput{ + Plan: plangate.Plan{Phases: []plangate.Phase{{Label: "edit"}}}, + AddedHandles: []string{"perm:read:crm_contact"}, + AddedSlots: out, + }) + assert.Contains(t, card.What, "4210 → 4299", "the move the approval executes is on the card") +} diff --git a/pkg/authz/plangate/card.go b/pkg/authz/plangate/card.go index 83137c73..f0e7581a 100644 --- a/pkg/authz/plangate/card.go +++ b/pkg/authz/plangate/card.go @@ -117,6 +117,22 @@ 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"` + + // PinnedImmovable, on a RESOURCE line, is the display label of the instance a + // `rebind: never` slot is pinned to while this card names a different one + // (Detail). Approving does NOT move it — the pin is fixed for the session's + // life — so Detail cannot be granted here. A surface renders this as a + // blocked line ("pinned to PinnedImmovable, cannot change it; start a new + // session"), never as a grant. Mutually exclusive with MovedFrom. + PinnedImmovable string `json:"pinnedImmovable,omitempty"` } func (c Card) JSON() (string, error) { @@ -424,6 +440,9 @@ func buildProjectedPlanWhat( } var b strings.Builder var deferred []int + // Set when any rendered resource is a `rebind: never` slot pinned elsewhere — + // named, but not grantable here — so the coverage line does not over-claim. + blocked := false phases := make([]CardPhase, 0, len(visible)) for _, i := range visible { @@ -473,6 +492,41 @@ func buildProjectedPlanWhat( // the DERIVED id, so it can only merge slots that genuinely resolve to // the same object; a type with no declared chain falls back to its raw // value and merges with nothing. + // A MOVE is a per-TYPE property — the pin lives on one resource type — but + // the dedup below collapses every type that resolves to ONE instance onto a + // single line. So a move recorded on one of those types (git_repo keyed on + // the remote URL) has to render on the one kept line even when a DIFFERENT + // type of the same instance (github_repo keyed on owner/name) sorts first + // and wins the dedup. Pre-scan for the move per derived id so whichever + // slot the dedup keeps still shows it: without this, a whole-plan card + // rendered a re-point as a plain first-fill while the approval it recorded + // revoked the displaced instance — the approver authorizing a revocation + // they were never shown. + // An IMMOVABLE pin (a `rebind: never` slot pinned elsewhere) is carried the + // same way, and for the same dedup reason: whichever type wins the merge, + // the one kept line must say the instance cannot be granted here. + type pinInstance struct { + resourceType string + from string + } + movedByID := make(map[string]pinInstance, len(ordered)) + immovableByID := make(map[string]pinInstance, len(ordered)) + for _, s := range ordered { + if s.ID == "" { + continue + } + id, _ := derivedInstanceID(slotValueTransforms, s.Type, s.ID) + if s.MovedFrom != "" { + if _, ok := movedByID[id]; !ok { + movedByID[id] = pinInstance{resourceType: s.Type, from: s.MovedFrom} + } + } + if s.PinnedImmovable != "" { + if _, ok := immovableByID[id]; !ok { + immovableByID[id] = pinInstance{resourceType: s.Type, from: s.PinnedImmovable} + } + } + } slotSeen := make(map[string]bool, len(ordered)) for _, s := range ordered { id, shown := derivedInstanceID(slotValueTransforms, s.Type, s.ID) @@ -495,8 +549,31 @@ 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 mv, moving := movedByID[id]; moving && shown != "" { + // A MOVE, rendered exactly as the single-phase and amendment + // cards render it (buildSlotSection): both instances, and the + // revocation the approver must not miss. The displaced label is + // resolved through the type that actually carries the move (its + // pin is the one repointing), not whichever type won the dedup. + displaced := displacedLabel(resourceDisplays, mv.resourceType, mv.from) + 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 if im, stuck := immovableByID[id]; stuck && shown != "" { + // A `rebind: never` slot pinned elsewhere: the named instance + // cannot be granted here. Say so — the instance, then why and the + // route out — rather than render a grantable-looking line. + pinnedLabel := displacedLabel(resourceDisplays, im.resourceType, im.from) + line.PinnedImmovable = pinnedLabel + blocked = true + b.WriteString("\n reaches " + text + " — " + detail) + b.WriteString("\n Cannot be granted: this session is pinned to " + pinnedLabel + + " for its life and cannot change it. Start a new session to reach " + detail + ".") + } else { + b.WriteString("\n reaches " + text + " — " + detail) + } + out.Resources = append(out.Resources, line) } // Instances the CEILING names on EVERY call, which no slot declared. @@ -557,6 +634,11 @@ func buildProjectedPlanWhat( coverage = "Approving covers the phases whose target is named. " + strings.Join(names, ", ") + " names no target yet and will ask again once it does." } + if blocked { + // A named-but-immovable resource is not covered by this approval, so the + // coverage line must not let "covers every phase" be read as covering it. + coverage += " Resources marked as blocked above cannot be granted in this session; start a new session to reach them." + } coverage += "\nApproving grants only the resources you have access to; the rest go to their owners." b.WriteString("\n" + coverage) return b.String(), phases, coverage @@ -704,38 +786,95 @@ func buildSlotSection( var b strings.Builder b.WriteString("\n\nAlso asks to reach:") unnamed := 0 + blocked := 0 for _, s := range ordered { text, _, _ := resolveResourceLine(displays, s.Type, s.ID) b.WriteString("\n " + text + " — ") // 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. An IMMOVABLE pin + // (PinnedImmovable set) renders the named instance too, but is a BLOCK + // rather than a grant — see below. + 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) } + // A `rebind: never` slot pinned to a DIFFERENT instance cannot be granted + // here at all: the pin is fixed for the session's life. Say exactly that — + // the named instance, then why it can't take and the route out — in place + // of the grant sentence a first-fill would get, so the approver is not + // shown a line that reads as grantable when it never will be. + if s.ID != "" && s.PinnedImmovable != "" { + blocked++ + b.WriteString("\n Cannot be granted: this session is pinned to " + + displacedLabel(displays, s.Type, s.PinnedImmovable) + + " for its life and cannot change it. Start a new session to reach " + s.ID + ".") + continue + } perm := permission[s.Type] if p, ok := granting[s.Type]; ok && p != "" { 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 // finish this, or is it the first of several prompts? An approver who is not // told cannot tell the two apart, and the whole point of naming targets in - // the plan is that the first case becomes possible. - if unnamed == 0 { - b.WriteString("\nEvery resource above is named, so this approval covers them.") - } else { + // the plan is that the first case becomes possible. A blocked (immovable) + // resource is named yet NOT covered, so it must not be swept into "covers + // them" — that would contradict the block line printed right above. + switch { + case unnamed > 0: b.WriteString("\nSome resources are not named yet, so this phase will ask again once they are.") + case blocked > 0: + b.WriteString("\nThe resources above that are not marked blocked are covered by this approval.") + default: + b.WriteString("\nEvery resource above is named, so this approval covers them.") } b.WriteString("\n" + slotStandingSummary(ordered, standing)) 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..2276ab46 100644 --- a/pkg/authz/plangate/card_resource_display_test.go +++ b/pkg/authz/plangate/card_resource_display_test.go @@ -355,3 +355,79 @@ 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") +} + +// A `rebind: never` slot pinned to a DIFFERENT instance must render as a BLOCK, +// not a grant: the card names the requested instance, says it cannot be granted, +// names the instance the pin is stuck on, and gives the new-session route. The +// resource line carries PinnedImmovable (not MovedFrom) so a surface renders it +// as blocked. +func TestBuildCard_WholePlanRebindNeverPinnedElsewhereRendersBlocked(t *testing.T) { + p := planWithPushPhaseAndRepo(t, "repoB") + p.Phases[0].Slots[0].PinnedImmovable = "repoA" + card := BuildCard(CardInput{Plan: p, WholePlan: true}) + + assert.Contains(t, card.What, "Cannot be granted", "the named instance cannot be granted here") + assert.Contains(t, card.What, "pinned to repoA", "it must name the instance the pin is stuck on") + assert.Contains(t, card.What, "Start a new session to reach repoB", "and the route out") + assert.NotContains(t, card.What, "repoA → repoB", "an immovable pin is not a move") + + require.Len(t, card.Phases, 1) + require.Len(t, card.Phases[0].Resources, 1) + line := card.Phases[0].Resources[0] + assert.Equal(t, "repoA", line.PinnedImmovable, "the line carries the immovable pin for a surface to render as blocked") + assert.Empty(t, line.MovedFrom, "an immovable pin is not a move") + assert.Equal(t, "repoB", line.Detail, "Detail stays the requested instance") +} + +// A move recorded on the type that LOSES the derived-id dedup must still render. +// One repository reached through two toolkits is one line (the git/gh merge), +// and the pin is per type — so a re-point recorded on github_repo (which sorts +// AFTER git_repo and is therefore the slot the dedup drops) would otherwise be +// swallowed, leaving the whole-plan card showing a plain first-fill while the +// approval it records revokes the displaced instance. The one kept line has to +// carry the move whichever same-id slot the dedup happened to keep. +func TestBuildCard_WholePlanMergedLineShowsAMoveRecordedOnTheDedupedType(t *testing.T) { + p := planWithPushPhase(t) + p.Phases[0].Slots = []Slot{ + // git_repo sorts first and carries NO move; github_repo sorts second and + // is the one actually repointing. Pre-fix, git_repo won the dedup and the + // move vanished. + {Type: "git_repo", ID: "https://github.com/demo-org/demo-repo.git"}, + {Type: "github_repo", ID: "demo-org/demo-repo", MovedFrom: "demo-org/old-repo"}, + } + + card := BuildCard(CardInput{ + Plan: p, + WholePlan: true, + Surface: []permsurface.Descriptor{permDesc(t, "push", "git_repo", "")}, + SlotValueTransforms: map[string][]string{ + "git_repo": {"github_repo_id", "normalize_url", "spicedb_escape"}, + "github_repo": {"github_repo_id", "spicedb_escape"}, + }, + }) + + require.Len(t, card.Phases, 1) + require.Len(t, card.Phases[0].Resources, 1, + "one repository reached two ways is still one line; got %d", len(card.Phases[0].Resources)) + assert.Equal(t, "demo-org/old-repo", card.Phases[0].Resources[0].MovedFrom, + "the move recorded on the deduped type must survive onto the kept line") + assert.Contains(t, card.What, "revokes this session's access to demo-org/old-repo") +} 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..3b55b397 100644 --- a/pkg/authz/plangate/plan.go +++ b/pkg/authz/plangate/plan.go @@ -118,6 +118,31 @@ 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 + + // PinnedImmovable, when non-empty, is the instance a `rebind: never` slot is + // CURRENTLY pinned to while this card names a DIFFERENT one. It is NOT a move + // (a never slot's pin cannot change), so the card renders it as "pinned to + // , cannot change it; start a new session to reach " + // rather than the " → " move line — the instance this phase names + // simply cannot be granted in this session. DISPLAY ONLY and excluded from + // identity exactly like MovedFrom; threaded onto the card's slots at + // card-build time from the approval record's PinnedImmovable. Mutually + // exclusive with MovedFrom. + PinnedImmovable 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..fbf9630b 100644 --- a/pkg/authz/slot_grant.go +++ b/pkg/authz/slot_grant.go @@ -2,7 +2,10 @@ package authz import ( "context" + "errors" "fmt" + "maps" + "slices" "time" "github.com/authzed/openagentprimitives/pkg/authz/precondition" @@ -125,14 +128,115 @@ 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 +} + +// AllRebindNever reports whether EVERY refusal is for a rebind:never slot — the +// one case whose only route out is a NEW SESSION, with no re-plan/amendment +// route. An empty Rebind reads as the default ("approval"), which DOES have the +// amendment route, so any approval/unset refusal flips this false. No refusals +// → false: default to naming the re-plan route (which also names the +// new-session fallback) rather than foreclosing it. +// +// Takes the REFUSALS (SlotPinRefusals), not the bindings the call was handed: a +// call that bound a default-rebind type and was refused on a never type must +// route to a new session, and the bindings alone cannot say which types were +// refused. +// +// Shared by every surface that phrases a pin refusal — the channelsd mint-time +// notice and the runner's plan-gate approval notice — so the "new session only" +// vs "amendment or new session" split is decided in ONE place and the two +// surfaces cannot drift. +func AllRebindNever(refusals []*SlotBindFailure) bool { + if len(refusals) == 0 { + return false + } + for _, r := range refusals { + if r.Rebind != SlotRebindNever { + return false + } + } + return true } -// GrantSlots writes a slot grant per binding. +// GrantSlots writes a slot grant per binding, enforcing single-occupancy +// pinning PER RESOURCE TYPE. // -// 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. +// 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: +// +// - single-occupancy types go through the SlotPinner (EnsurePin + +// WriteGrantsPinned), which records the one pinned instance and guards the +// grant write against a concurrent move; +// - a single-occupancy binding that carries a PriorID (the plan-gate approval +// path, after a human cleared a MOVE) repoints the pin off PriorID onto the +// new instance and revokes PriorID's grants — via MovePin, atomically — in +// place of the first-fill EnsurePin, then writes the new grant; +// - multi-occupancy types keep the plain batched TOUCH write, unpinned, each +// addition gated as before. +// +// Handling the move HERE, per type, is what keeps a move's failure contained: +// each type's move-and-grant land together in its own iteration, so a failure +// on one type cannot strand another type's grant, mislabel another's failure as +// a committed move, or block a sibling type's first-fill. +// +// 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 +248,210 @@ 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 + // priorID, when non-empty, is the instance an APPROVED MOVE displaces: + // the binding carried a PriorID, so this type's one bind repoints the pin + // off priorID onto firstID rather than first-filling an empty slot. Only + // the plan-gate approval path sets it; every other producer leaves it + // zero and takes the plain first-fill (EnsurePin) path. Meaningful only + // for a single-occupancy type holding exactly one instance — a group with + // two distinct ids is refused before priorID is ever consulted. + priorID 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 + } + // rebind: never wins over any other mode in the same call, for the same + // fail-closed reason: a producer that disagrees with itself must not + // make an unmovable slot movable. + if grp.rebind == "" || b.Rebind == SlotRebindNever { + grp.rebind = b.Rebind + } + if !b.PriorID.IsZero() && grp.priorID == "" { + // A move source. First non-zero wins; a single-occupancy group that + // reaches the move path holds one instance, so every move binding of + // it names the same prior instance. + grp.priorID = b.PriorID.String() + } 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 fault (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. Each entry is a + // *SlotBindFailure naming its type and kind, so a reader can separate the + // refusals (user-facing) from the faults (log-only) after the errors.Join + // below — see SlotBindFailures. + var refusals []error + fail := func(kind SlotFailureKind, grp *typeGroup, rt string, err error) { + refusals = append(refusals, NewSlotBindFailure(kind, grp.rebind, err, rt)) } - return nil + var plainRels []Relation + var plainTypes []string + for _, rt := range order { + grp := groups[rt] + if !grp.single { + plainRels = append(plainRels, grp.rels...) + plainTypes = append(plainTypes, rt) + 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 { + fail(SlotFailureRefused, grp, rt, 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. + fail(SlotFailureFault, grp, rt, fmt.Errorf("authz: single-occupancy slot type %s needs a SlotPinner-capable writer, got %T", rt, g)) + continue + } + id := grp.firstID + + // An APPROVED MOVE: the binding named a prior instance to displace, so + // this type repoints its pin off priorID onto id and revokes the prior + // instance's grants — atomically, in MovePin's own RPC — rather than + // first-filling. Done HERE, inside the per-type loop, so a move's failure + // is this type's refusal alone: it cannot strand another type's grant, + // mislabel another type's failure, or block a first-fill on a sibling + // type (the all-moves-first pass this replaces could do all three). Only + // the plan-gate approval path sets PriorID; priorID == id is not a move + // (the pin already names the target) and falls through to EnsurePin, which + // re-binds it same-instance. + if grp.priorID != "" && grp.priorID != id { + if grp.rebind == SlotRebindNever { + // rebind: never — the class declared this slot unmovable, so no + // approval may retarget it. Refuse with the new-session route; the + // pin stays on the prior instance and nothing is revoked. + fail(SlotFailureRefused, grp, rt, fmt.Errorf("%w: this session is pinned to %s:%s for its lifetime; start a new session to target %s:%s", + ErrSlotPinned, rt, grp.priorID, rt, id)) + continue + } + // The revoke set is the prior instance's ACTUAL grants (read back, not + // rebuilt from a permission list), so a grant the moving approval does + // not re-name is not left behind — the asymmetric-permission leak. + revoke, err := pinner.ListGrantsFor(ctx, rt, grp.priorID, scope) + if err != nil { + fail(SlotFailureFault, grp, rt, fmt.Errorf("%w: list prior grants for %s:%s to revoke on move: %w", ErrSlotStore, rt, grp.priorID, err)) + continue + } + if err := pinner.MovePin(ctx, rt, grp.priorID, id, revoke, scope); err != nil { + if errors.Is(err, ErrSlotPinned) { + // Genuine drift: the pin is neither the displaced instance the + // card showed nor the target (MovePin's own no-op already + // absorbed the already-at-target case). Nothing moved, nothing + // revoked; re-approving rebuilds the card from the current pin. + fail(SlotFailureDrifted, grp, rt, fmt.Errorf("%w: %w: execute approved slot move for %s", ErrSlotMoveDrifted, err, rt)) + } else { + fail(SlotFailureFault, grp, rt, fmt.Errorf("%w: execute approved slot move for %s: %w", ErrSlotStore, rt, err)) + } + continue + } + // The pin now names id (repointed, or already there via MovePin's + // idempotent no-op). Write this instance's grants under the MUST_MATCH + // guard. A failure HERE is a COMMITTED move: the displaced instance's + // access is already revoked but the new grant did not land — the one + // case the approver must be told changed access, not "nothing happened". + if err := pinner.WriteGrantsPinned(ctx, grp.rels, rt, id, scope); err != nil { + fail(SlotFailureMoveCommitted, grp, rt, fmt.Errorf("%w: %w", ErrSlotMoveCommitted, err)) + } + continue + } + + 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. Tagged ErrSlotStore so + // a reader can tell this transient fault from an ErrSlotPinned ruling + // it may be joined with — otherwise the join makes the whole error + // read as a final refusal and leaks the raw cause to users. + fail(SlotFailureFault, grp, rt, fmt.Errorf("%w: ensure pin for %s:%s: %w", ErrSlotStore, 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: + fail(SlotFailureRefused, grp, rt, 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 + fail(SlotFailureRefused, grp, rt, 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, and tagged + // ErrSlotStore for the same reason: it is a transient fault, not a + // ruling. 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. + fail(SlotFailureFault, grp, rt, fmt.Errorf("%w: write pinned slot grants for %s:%s: %w", ErrSlotStore, rt, id, err)) + continue + } + } + + 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. Tagged + // ErrSlotStore: a failed multi-occupancy write is a transient fault, + // so a reader must not read the joined error as a final pin refusal. + refusals = append(refusals, NewSlotBindFailure(SlotFailureFault, "", + fmt.Errorf("%w: write slot grants: %w", ErrSlotStore, err), plainTypes...)) + } + } + return errors.Join(refusals...) } // RevokeSlots removes slot grants. @@ -184,16 +477,81 @@ 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 a session holds, verbatim — resource and + // subject only, no derived occupancy. See SlotPinRelation. CopySlotGrants + // reads it for BOTH sides: the parent's pins to copy, and the child's pins + // to decide which types it has already committed to on its own. + 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. + // + // The write is atomically guarded: for every type in unpinnedTypes it + // carries a precondition that child holds NO slot_pin of that type, so a + // pin the child's own runner commits between CopySlotGrants's read of the + // child's pins and this write makes the whole write refuse (wrapping + // ErrSlotPinned) instead of landing a second pin beside it. + CopySlotTuples(ctx context.Context, child SessionRef, rels []Relation, unpinnedTypes []string) error +} + +// SlotCopyResult reports what CopySlotGrants carried onto a child. +type SlotCopyResult struct { + // Grants and Pins count the tuples written. + Grants, Pins int + // SkippedTypes names, sorted, every resource type the parent held grants + // or a pin for that was NOT copied because the child already holds a pin + // of that type — its own commitment, or this same copy landed by an + // earlier pass. + SkippedTypes []string } -// 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. +// +// 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 CHILD'S OWN PIN WINS. The caller (restart.go step 6c) runs this on EVERY +// ReconcileRestart pass, and the parent requeues every few seconds while the +// workspace snapshot/restore Jobs run. The child's CR already exists by then +// and its own reconcile does not wait for the parent's PendingRestart to clear, +// so the child's runner can be live and can first-fill or MOVE a pin in the +// meantime. Re-touching the parent's pin on top of that would leave the child +// holding two pins for one type — ReadPin fails closed on two forever, and the +// pin never expires — and re-touching the parent's grants would hand back +// authority a move just revoked. So the child's pins are read first, and for +// every type the child already pins, NEITHER the parent's pin NOR the parent's +// grants of that type are copied. A re-run after a clean first pass finds the +// child holding the parent's own copied pin and likewise skips the type — +// there is nothing left to carry. +// +// The read-then-write window is closed atomically, not just narrowed: every +// type that IS copied is passed to CopySlotTuples as unpinnedTypes, which +// guards the write on the child still holding no pin of that type. A pin the +// child commits in between refuses the whole write (ErrSlotPinned); the next +// pass re-reads and skips that type. RESIDUAL: a type with no pin anywhere — +// multi-occupancy — is guarded only against a pin appearing, so a grant the +// child REVOKED on such a type between two passes is re-touched by the next +// one. Revocation of a multi-occupancy grant within the seconds-long restore +// window of a fresh fork is the only exposure, and the grant still carries the +// child's expiry. // // 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 @@ -209,21 +567,65 @@ type SlotGrantCopier interface { // resolves through slot_grant->interact, so it must never go live ahead of the // membership it resolves against — the same window restart.go documents for // copying denied before participants. -func CopySlotGrants(ctx context.Context, c SlotGrantCopier, parent, child SessionRef, expiresAt time.Time) (int, error) { +func CopySlotGrants(ctx context.Context, c SlotGrantCopier, parent, child SessionRef, expiresAt time.Time) (SlotCopyResult, error) { + var res SlotCopyResult if c == nil { - return 0, nil + return res, nil } held, err := c.ListSlotGrants(ctx, parent.Namespace, parent.Name) if err != nil { - return 0, fmt.Errorf("authz: list parent slot grants %s: %w", parent, err) + return res, fmt.Errorf("authz: list parent slot grants %s: %w", parent, err) + } + pins, err := c.ListSlotPins(ctx, parent.Namespace, parent.Name) + if err != nil { + return res, fmt.Errorf("authz: list parent slot pins %s: %w", parent, err) + } + if len(held) == 0 && len(pins) == 0 { + return res, nil + } + childPins, err := c.ListSlotPins(ctx, child.Namespace, child.Name) + if err != nil { + return res, fmt.Errorf("authz: list child slot pins %s: %w", child, err) + } + childPinned := make(map[string]bool, len(childPins)) + for _, p := range childPins { + childPinned[p.ResourceType] = true } - if len(held) == 0 { - return 0, nil + + skipped := map[string]bool{} + copiedTypes := map[string]bool{} + rels := make([]Relation, 0, len(held)+len(pins)) + for _, b := range held { + if childPinned[b.ResourceType] { + skipped[b.ResourceType] = true + continue + } + rel := SlotGrantRelation(b.ResourceType, b.ResourceID.String(), b.Permission, child) + rel.ExpiresAt = expiresAt + rels = append(rels, rel) + copiedTypes[b.ResourceType] = true + res.Grants++ } - 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) + for _, p := range pins { + if childPinned[p.ResourceType] { + skipped[p.ResourceType] = true + continue + } + // The pin is otherwise copied byte-for-byte; only the subject moves. + p.SubjectID = child.String() + rels = append(rels, p) + copiedTypes[p.ResourceType] = true + res.Pins++ + } + res.SkippedTypes = slices.Sorted(maps.Keys(skipped)) + if len(rels) == 0 { + return res, nil + } + + if err := c.CopySlotTuples(ctx, child, rels, slices.Sorted(maps.Keys(copiedTypes))); err != nil { + return SlotCopyResult{}, fmt.Errorf("authz: copy slot tuples to child %s: %w", child, err) } - return len(held), nil + return res, nil } // SlotRevokeChecker is the standing gate for removing a slot grant. 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..c25b30af 100644 --- a/pkg/authz/slot_grant_test.go +++ b/pkg/authz/slot_grant_test.go @@ -3,6 +3,8 @@ package authz_test import ( "context" "errors" + "fmt" + "slices" "testing" "time" @@ -35,6 +37,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 +163,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 +207,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 +220,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 +267,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 +297,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 +309,262 @@ 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") + assert.ErrorIs(t, err, authz.ErrSlotStore, "a store failure is tagged so a reader can tell it from a ruling") + 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 TRANSIENT store fault and a genuine pin refusal can land in the SAME call — +// one type is pinned elsewhere while another type's write blips. The joined +// error must carry BOTH sentinels: ErrSlotPinned (so a reader still sees the +// ruling) and ErrSlotStore (so a reader that checks it FIRST treats the whole +// thing as a recoverable fault rather than telling the approver "re-approving +// will not move it" for a type that merely timed out). +func TestGrantSlots_StoreFaultJoinedWithPinRefusalCarriesBothSentinels(t *testing.T) { + scope := testScope() + // git_repo is already pinned to a DIFFERENT instance → a genuine refusal. + // The multi-occupancy label's plain write is made to blip → a store fault. + w := &pinningFake{writeErr: errors.New("spicedb unavailable: dial tcp 10.0.0.1:50051")} + w.seedPin(scope, "git_repo", "acme/other") + + 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.ErrorIs(t, err, authz.ErrSlotPinned, "the genuine pin refusal must remain visible") + assert.ErrorIs(t, err, authz.ErrSlotStore, "the co-occurring store blip must be taggable so it is not read as a final ruling") + assert.Contains(t, err.Error(), "acme/other", "the refusal still names the pinned instance for an operator") + + // Per type, the two separate cleanly — through a caller's own wrapping — + // so a surface can quote the refusal without the store detail beside it. + failures := authz.SlotBindFailures(fmt.Errorf("bindSlots(approved): grant slots: %w", err)) + require.Len(t, failures, 2) + refusals := authz.SlotPinRefusals(err) + require.Len(t, refusals, 1, "only the ruling is a refusal") + assert.Equal(t, []string{"git_repo"}, refusals[0].ResourceTypes) + text := authz.SlotRefusalText(refusals) + assert.Contains(t, text, "pinned to git_repo:acme/other") + assert.NotContains(t, text, "dial tcp", "store detail must not reach the user-facing text") + assert.Equal(t, authz.SlotFailureFault, failures[1].Kind) + assert.Equal(t, []string{"label"}, failures[1].ResourceTypes) +} + +// 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") +} + +// AllRebindNever decides "new session only" vs "amendment or new session" for +// every pin-refusal surface, so it must be computed over the REFUSED types, not +// every binding in the call: a default-rebind type that bound fine beside a +// refused rebind:never type must not flip the route to "amend the plan", which +// GrantSlots would refuse every time. A single refused movable (or unset) type +// flips it false, and no refusals must NOT foreclose the amendment route. +func TestAllRebindNever(t *testing.T) { + scope := testScope() + w := &pinningFake{} + w.seedPin(scope, "git_repo", "acme/pinned") + err := authz.GrantSlots(context.Background(), w, scope, []authz.SlotBinding{ + {ResourceType: "ticket", ResourceID: authz.TrustedObjectID("T-1"), Permission: "edit"}, + {ResourceType: "git_repo", ResourceID: authz.TrustedObjectID("acme/other"), Permission: "push", Rebind: authz.SlotRebindNever}, + }, testExpiry()) + require.Error(t, err) + refusals := authz.SlotPinRefusals(err) + require.Len(t, refusals, 1, "only git_repo is refused; ticket binds") + assert.True(t, authz.AllRebindNever(refusals), "the only refused type is never → new session only") + + movable := authz.NewSlotBindFailure(authz.SlotFailureRefused, "approval", authz.ErrSlotPinned, "ticket") + unset := authz.NewSlotBindFailure(authz.SlotFailureRefused, "", authz.ErrSlotPinned, "ticket") + assert.False(t, authz.AllRebindNever(append(refusals, movable)), "any movable refusal → false") + assert.False(t, authz.AllRebindNever(append(refusals, unset)), "unset reads as approval → false") + assert.False(t, authz.AllRebindNever(nil), "empty must not foreclose the amendment route") +} + // 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 +577,21 @@ 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 is the parent's grants; +// pins is every pin in the store, parent's and child's alike, answered per +// subject the way SpiceDB's subject filter would. copied is every relation +// CopySlotGrants asked it to write, verbatim — the single seam the +// gate-bypassing copy writes through. A successful copy lands its pins in +// pins (so a second pass sees them) and honors unpinnedTypes the way the +// MUST_NOT_MATCH precondition does. 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 + unpinnedTypes []string } func (f *fakeSlotCopier) ListSlotGrants(_ context.Context, ns, name string) ([]authz.SlotBinding, error) { @@ -230,13 +601,35 @@ 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 + } + var out []authz.Relation + for _, p := range f.pins { + if p.SubjectID == ns+"/"+name { + out = append(out, p) + } + } + return out, nil +} + +func (f *fakeSlotCopier) CopySlotTuples(_ context.Context, child authz.SessionRef, rels []authz.Relation, unpinnedTypes []string) error { + if f.copyErr != nil { + return f.copyErr + } + f.unpinnedTypes = unpinnedTypes + for _, p := range f.pins { + if p.SubjectID == child.String() && slices.Contains(unpinnedTypes, p.ResourceType) { + return fmt.Errorf("fake copy: child pinned %s: %w", p.ResourceType, authz.ErrSlotPinned) + } + } + f.copied = append(f.copied, rels...) + for _, r := range rels { + if r.Relation == authz.SlotPinRelationName { + f.pins = append(f.pins, r) + } } - f.grantedTo = ns + "/" + name - f.granted = append(f.granted, b...) - f.expiry = exp return nil } @@ -249,46 +642,240 @@ func TestCopySlotGrants_RePointsTheParentsGrantsAtTheChild(t *testing.T) { childRef := authz.SessionRef{Namespace: "ns", Name: "child"} exp := testExpiry() - n, err := authz.CopySlotGrants(context.Background(), c, parent, childRef, exp) + res, 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") + assert.Equal(t, 2, res.Grants) + 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) { parent := authz.SessionRef{Namespace: "ns", Name: "parent"} childRef := authz.SessionRef{Namespace: "ns", Name: "child"} - n, err := authz.CopySlotGrants(context.Background(), nil, parent, childRef, testExpiry()) + res, err := authz.CopySlotGrants(context.Background(), nil, parent, childRef, testExpiry()) require.NoError(t, err) - assert.Zero(t, n) + assert.Zero(t, res) empty := &fakeSlotCopier{} - n, err = authz.CopySlotGrants(context.Background(), empty, parent, childRef, testExpiry()) + res, 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.Zero(t, res) + 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)}, + } + + res, err := authz.CopySlotGrants(context.Background(), c, parent, childRef, exp) + require.NoError(t, err) + assert.Equal(t, 1, res.Grants) + assert.Equal(t, 1, res.Pins) + require.Len(t, c.copied, 2, "the grant and the pin both copy in the same call") + assert.Equal(t, []string{"git_repo"}, c.unpinnedTypes, + "the copied type must be guarded on the child still holding no pin of it") + + 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"}, + }} + + res, 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, res.Grants) + 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}) +} + +// failingChildPinsCopier fails only the CHILD's pin read — the parent's reads +// succeed, so the failure under test is the one the child-pins-win rule adds. +type failingChildPinsCopier struct{ fakeSlotCopier } + +func (f *failingChildPinsCopier) ListSlotPins(ctx context.Context, ns, name string) ([]authz.Relation, error) { + if name == "child" { + return nil, errors.New("child pins boom") + } + return f.fakeSlotCopier.ListSlotPins(ctx, ns, name) +} - grantFail := &fakeSlotCopier{ - held: []authz.SlotBinding{{ResourceType: "crm_company", ResourceID: authz.TrustedObjectID("acme"), Permission: "contact_access"}}, - grantErr: errors.New("grant boom"), +// TestCopySlotGrants_ChildAlreadyPinnedKeepsItsOwnPinForThatType: step 6c +// re-runs on every ReconcileRestart pass while the restore Jobs are pending, +// and the child's runner can be live by then. A child that has already pinned +// (or moved) git_repo on its own must receive NEITHER the parent's git_repo +// pin — two pins would wedge ReadPin fail-closed forever — NOR the parent's +// git_repo grants, which would hand back authority the child's move revoked. +// Every other type still copies, guarded. +func TestCopySlotGrants_ChildAlreadyPinnedKeepsItsOwnPinForThatType(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: "crm_company", ResourceID: authz.TrustedObjectID("acme"), Permission: "contact_access"}, + }, + pins: []authz.Relation{ + authz.SlotPinRelation("git_repo", "acme/app", parent), + authz.SlotPinRelation("crm_company", "acme", parent), + // The child's own runner moved git_repo to a different instance. + authz.SlotPinRelation("git_repo", "acme/other", childRef), + }, + } + + res, err := authz.CopySlotGrants(context.Background(), c, parent, childRef, exp) + require.NoError(t, err) + assert.Equal(t, 1, res.Grants, "only the crm_company grant copies") + assert.Equal(t, 1, res.Pins, "only the crm_company pin copies") + assert.Equal(t, []string{"git_repo"}, res.SkippedTypes) + assert.Equal(t, []string{"crm_company"}, c.unpinnedTypes, "the copied type is guarded; the skipped one is not written at all") + + for _, rel := range c.copied { + assert.NotEqual(t, "git_repo", rel.ResourceType, + "no parent git_repo tuple may land on a child that already pins git_repo") + assert.Equal(t, childRef.String(), rel.SubjectID) + } + childPins, err := c.ListSlotPins(context.Background(), childRef.Namespace, childRef.Name) + require.NoError(t, err) + assert.ElementsMatch(t, []authz.Relation{ + authz.SlotPinRelation("git_repo", "acme/other", childRef), + authz.SlotPinRelation("crm_company", "acme", childRef), + }, childPins, "exactly one pin per type: the child's own git_repo and the copied crm_company") +} + +// TestCopySlotGrants_ReRunAfterACleanCopyWritesNothingPinned: the second +// ReconcileRestart pass finds the child holding the pin the first pass landed, +// so it skips that type rather than re-touching it. +func TestCopySlotGrants_ReRunAfterACleanCopyWritesNothingPinned(t *testing.T) { + parent := authz.SessionRef{Namespace: "ns", Name: "parent"} + childRef := authz.SessionRef{Namespace: "ns", Name: "child"} + 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)}, } - _, err = authz.CopySlotGrants(context.Background(), grantFail, parent, childRef, testExpiry()) + + first, err := authz.CopySlotGrants(context.Background(), c, parent, childRef, testExpiry()) + require.NoError(t, err) + assert.Equal(t, 1, first.Pins) + c.copied = nil + + second, err := authz.CopySlotGrants(context.Background(), c, parent, childRef, testExpiry()) + require.NoError(t, err) + assert.Zero(t, second.Grants) + assert.Zero(t, second.Pins) + assert.Equal(t, []string{"git_repo"}, second.SkippedTypes) + assert.Empty(t, c.copied, "a re-run must not re-touch a type the child already pins") +} + +// TestCopySlotGrants_ChildPinsBetweenReadAndWriteRefusesTheCopy: the guard +// closes the read-then-write window — a child pin that lands after the read +// refuses the write (ErrSlotPinned) instead of creating a second pin. +func TestCopySlotGrants_ChildPinsBetweenReadAndWriteRefusesTheCopy(t *testing.T) { + parent := authz.SessionRef{Namespace: "ns", Name: "parent"} + childRef := authz.SessionRef{Namespace: "ns", Name: "child"} + c := &racingChildPinCopier{fakeSlotCopier: fakeSlotCopier{ + pins: []authz.Relation{authz.SlotPinRelation("git_repo", "acme/app", parent)}, + }, childPin: authz.SlotPinRelation("git_repo", "acme/other", childRef)} + + _, err := authz.CopySlotGrants(context.Background(), c, parent, childRef, testExpiry()) require.Error(t, err) - assert.Contains(t, err.Error(), "grant boom") + assert.ErrorIs(t, err, authz.ErrSlotPinned) + assert.Empty(t, c.copied) +} + +// racingChildPinCopier commits childPin right after the child's pins are read, +// standing in for the child's runner winning the race against the copy. +type racingChildPinCopier struct { + fakeSlotCopier + childPin authz.Relation +} + +func (f *racingChildPinCopier) ListSlotPins(ctx context.Context, ns, name string) ([]authz.Relation, error) { + out, err := f.fakeSlotCopier.ListSlotPins(ctx, ns, name) + if name == "child" { + f.pins = append(f.pins, f.childPin) + } + return out, err +} + +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 authz.SlotGrantCopier + 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"}, + {name: "listing the child's pins fails", copier: &failingChildPinsCopier{fakeSlotCopier: fakeSlotCopier{held: binding}}, wantMsg: "child pins 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) + }) + } } // 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..ef74cf78 --- /dev/null +++ b/pkg/authz/slot_pin.go @@ -0,0 +1,239 @@ +package authz + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// 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") + +// ErrSlotStore marks a TRANSIENT STORE FAILURE inside GrantSlots — an EnsurePin, +// WriteGrantsPinned or plain-write that erred against SpiceDB — as distinct from +// an authorization ruling (ErrSlotPinned). The distinction is load-bearing in +// two places that would otherwise lie: +// +// - A store blip gets errors.Join'd alongside a genuine ErrSlotPinned refusal +// for a DIFFERENT type in the same call, so errors.Is(err, ErrSlotPinned) +// turns true and the refusal would be reported as final ("re-approving will +// not move it") when a retry would in fact bind the type that timed out. +// Callers that route on the outcome walk the per-type SlotBindFailures +// rather than testing the sentinels on the joined error, so the refusal +// and the fault are each reported for their own type. +// - The store cause carries raw endpoints and gRPC status text (dial tcp …, +// Unavailable), which is operator detail, not something a model or a chat +// reader can act on. User-facing surfaces quote only SlotPinRefusals and +// leave the faults to the operator log. +// +// The raw cause is still wrapped (operators read it in logs); the sentinel only +// lets a reader tell "we could not reach the store" from "this is the answer". +var ErrSlotStore = errors.New("slot grant store failure") + +// SlotFailureKind is what went wrong for one resource type inside GrantSlots. +type SlotFailureKind int + +const ( + // SlotFailureRefused is an authorization RULING (wraps ErrSlotPinned): the + // type is single-occupancy and committed elsewhere, or the call named two + // instances of it. Its text is written for users and the model — it names the + // pinned instance and the route out — and retrying will not change it. + SlotFailureRefused SlotFailureKind = iota + // SlotFailureFault is a fault on our side: a store error (wraps ErrSlotStore) + // or a writer that cannot pin. Its text carries operator detail (endpoints, + // gRPC status) and must stay in logs; a later attempt may succeed. + SlotFailureFault + // SlotFailureDrifted is an approved move whose MUST_MATCH failed because the + // pin changed since the card was shown (wraps ErrSlotMoveDrifted). Nothing + // moved and nothing was revoked. + SlotFailureDrifted + // SlotFailureMoveCommitted is an approved move that landed — the displaced + // instance's grants are revoked — whose new grant then failed (wraps + // ErrSlotMoveCommitted). + SlotFailureMoveCommitted +) + +// SlotBindFailure is ONE resource type's failed outcome inside GrantSlots. +// GrantSlots resolves each type independently and errors.Joins the failures, so +// a single call can carry a permanent refusal for one type beside a transient +// fault for another. The sentinels alone cannot tell a reader WHICH type each +// belongs to, or keep the fault's raw store text out of the refusal a user is +// shown; this type does both. Walk a returned error with SlotBindFailures. +// +// Error() is the wrapped cause's text unchanged, and Unwrap exposes it, so +// errors.Is against the sentinels keeps working for every existing reader. +type SlotBindFailure struct { + // ResourceTypes the failure covers: exactly one for a single-occupancy + // outcome; every multi-occupancy type in the call for a failed plain write. + ResourceTypes []string + // Rebind is the type's declared rebind mode ("" reads as the default, + // "approval"). Meaningful for a refusal: it decides whether the route out is + // an approved amendment or only a new session. + Rebind string + Kind SlotFailureKind + err error +} + +// NewSlotBindFailure builds one per-type failure. GrantSlots is the producer; +// this exists for writers and test doubles that stand in for it. +func NewSlotBindFailure(kind SlotFailureKind, rebind string, err error, resourceTypes ...string) *SlotBindFailure { + return &SlotBindFailure{ResourceTypes: resourceTypes, Rebind: rebind, Kind: kind, err: err} +} + +func (f *SlotBindFailure) Error() string { + if f.err == nil { + return fmt.Sprintf("slot bind failure on %s", strings.Join(f.ResourceTypes, ", ")) + } + return f.err.Error() +} +func (f *SlotBindFailure) Unwrap() error { return f.err } + +// SlotBindFailures returns every per-type failure in err's tree, in the order +// GrantSlots recorded them. Empty when err is nil or carries none (an error +// raised before GrantSlots ran, such as a session_scope write). +func SlotBindFailures(err error) []*SlotBindFailure { + var out []*SlotBindFailure + var walk func(error) + walk = func(e error) { + if e == nil { + return + } + if f, ok := e.(*SlotBindFailure); ok { + // A failure is a leaf for this walk: its own cause chain can hold an + // ErrSlotPinned from the store (a drifted move) that is not a second + // failure. + out = append(out, f) + return + } + switch u := e.(type) { + case interface{ Unwrap() []error }: + for _, c := range u.Unwrap() { + walk(c) + } + case interface{ Unwrap() error }: + walk(u.Unwrap()) + } + } + walk(err) + return out +} + +// SlotPinRefusals returns only the refusals (SlotFailureRefused) in err's tree: +// the part of a GrantSlots error that is a final answer a user or model can act +// on. Faults beside them stay out, so their raw store text never reaches a +// user-facing surface and they never mask a refusal. +func SlotPinRefusals(err error) []*SlotBindFailure { + var out []*SlotBindFailure + for _, f := range SlotBindFailures(err) { + if f.Kind == SlotFailureRefused { + out = append(out, f) + } + } + return out +} + +// SlotRefusalText renders refusals for a user- or model-facing surface: each +// refusal's own text, one per line. Never pass a whole GrantSlots error to such +// a surface — it may carry a fault's store detail beside the refusals. +func SlotRefusalText(refusals []*SlotBindFailure) string { + parts := make([]string, 0, len(refusals)) + for _, r := range refusals { + parts = append(parts, r.Error()) + } + return strings.Join(parts, "\n") +} + +// 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. + // + // IDEMPOTENT at the target: if the precondition fails because the pin + // ALREADY names toID, the move is a no-op and returns nil, not an error. A + // plan-scoped approval resolves one BindApproved call per covered phase and + // each re-attempts the SAME move, so the first lands it and the rest find it + // already landed — without this they would drift-fail and drop their grant. + // The displaced instance's grants were revoked by whoever performed the real + // move; a no-op does not re-revoke (a straggler lapses via the grant's + // mandatory expiry, the same inherent window a real move leaves). A failed + // precondition where the pin is NEITHER fromID nor toID is genuine drift and + // 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/data_slot_grants.go b/pkg/authz/spicedb/data_slot_grants.go index 5f286e86..c7ad7636 100644 --- a/pkg/authz/spicedb/data_slot_grants.go +++ b/pkg/authz/spicedb/data_slot_grants.go @@ -72,6 +72,10 @@ func (c *Client) ListDataSlotGrants(ctx context.Context, ns, name string) ([]aut // Idempotent: succeeds when the session holds no data slots. func (c *Client) DeleteDataSlotGrants(ctx context.Context, ns, name string) error { held, err := c.ListDataSlotGrants(ctx, ns, name) + if isSchemaDefinitionAbsent(err) { + // No schema yet means nothing can have been granted — see DeleteSlotGrants. + return nil + } if err != nil { return fmt.Errorf("delete data slot grants %s/%s: %w", ns, name, err) } diff --git a/pkg/authz/spicedb/integration_test.go b/pkg/authz/spicedb/integration_test.go index 1bd92dab..991be88e 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 { @@ -924,3 +929,69 @@ func TestIntegration_DeniedLosesTheNewPermissions(t *testing.T) { "a denied user must lose %s, whatever else grants it — denied wins over every grant", perm) } } + +// TestIntegration_CopySlotGrants_ChildPinWinsAndTheGuardIsAtomic locks the +// fork-copy re-run fix against real SpiceDB. The copy re-runs on every +// ReconcileRestart pass while the child's runner may be live, so: +// +// - a child that already pins demo_target keeps exactly its own pin and +// receives none of the parent's demo_target tuples, while demo_widget still +// copies; +// - the MUST_NOT_MATCH guard on CopySlotTuples is evaluated by SpiceDB itself: +// a write guarded on a type the child already pins is refused with +// ErrSlotPinned and writes nothing. +func TestIntegration_CopySlotGrants_ChildPinWinsAndTheGuardIsAtomic(t *testing.T) { + c := newSlotIntegrationClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + ns := "integration-test" + parent := authz.SessionRef{Namespace: ns, Name: uniq(t, "fork-parent-")} + child := authz.SessionRef{Namespace: ns, Name: uniq(t, "fork-child-")} + parentTarget, childTarget, widget := uniq(t, "tgt-a-"), uniq(t, "tgt-b-"), uniq(t, "widget-") + t.Cleanup(func() { + _ = c.DeleteSlotGrants(context.Background(), ns, parent.Name) + _ = c.DeleteSlotGrants(context.Background(), ns, child.Name) + }) + exp := time.Now().Add(time.Hour) + + // Parent: pinned demo_target (A) + a grant on it, and pinned demo_widget. + require.NoError(t, authz.GrantSlots(ctx, c.Relations(), parent, []authz.SlotBinding{ + {ResourceType: "demo_target", ResourceID: authz.TrustedObjectID(parentTarget), Permission: "reachable"}, + {ResourceType: "demo_widget", ResourceID: authz.TrustedObjectID(widget), Permission: "usable"}, + }, exp), "GrantSlots (parent)") + // Child: its own runner already pinned demo_target to a DIFFERENT instance. + require.NoError(t, authz.GrantSlots(ctx, c.Relations(), child, []authz.SlotBinding{ + {ResourceType: "demo_target", ResourceID: authz.TrustedObjectID(childTarget), Permission: "reachable"}, + }, exp), "GrantSlots (child)") + + res, err := authz.CopySlotGrants(ctx, c, parent, child, exp) + require.NoError(t, err, "CopySlotGrants") + assert.Equal(t, []string{"demo_target"}, res.SkippedTypes) + assert.Equal(t, 1, res.Grants, "only the demo_widget grant copies") + assert.Equal(t, 1, res.Pins, "only the demo_widget pin copies") + + pinned, err := c.Relations().(authz.SlotPinner).ReadPin(ctx, "demo_target", child) + require.NoError(t, err, "exactly one demo_target pin: ReadPin fails closed on two") + assert.Equal(t, childTarget, pinned, "the child's own pin wins") + widgetPin, err := c.Relations().(authz.SlotPinner).ReadPin(ctx, "demo_widget", child) + require.NoError(t, err) + assert.Equal(t, widget, widgetPin, "an unpinned type still copies") + + grants, err := c.ListSlotGrants(ctx, ns, child.Name) + require.NoError(t, err) + assert.ElementsMatch(t, []authz.SlotBinding{ + {ResourceType: "demo_target", ResourceID: authz.TrustedObjectID(childTarget), Permission: "reachable"}, + {ResourceType: "demo_widget", ResourceID: authz.TrustedObjectID(widget), Permission: "usable"}, + }, grants, "the parent's demo_target grant must not come back onto a child that pins demo_target") + + // The guard itself, evaluated server-side: the child pins demo_target, so a + // write guarded on it must be refused and land nothing. + stray := authz.SlotPinRelation("demo_target", parentTarget, child) + err = c.CopySlotTuples(ctx, child, []authz.Relation{stray}, []string{"demo_target"}) + require.Error(t, err) + assert.ErrorIs(t, err, authz.ErrSlotPinned) + pinned, err = c.Relations().(authz.SlotPinner).ReadPin(ctx, "demo_target", child) + require.NoError(t, err, "the refused write must not have landed a second pin") + assert.Equal(t, childTarget, pinned) +} diff --git a/pkg/authz/spicedb/slot_grants.go b/pkg/authz/spicedb/slot_grants.go index be3b9cfe..ed1ac6b3 100644 --- a/pkg/authz/spicedb/slot_grants.go +++ b/pkg/authz/spicedb/slot_grants.go @@ -9,6 +9,8 @@ import ( "time" v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" "github.com/authzed/openagentprimitives/pkg/authz" @@ -47,6 +49,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,32 +104,166 @@ 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. +// +// For every type in unpinnedTypes the RPC carries a MUST_NOT_MATCH +// precondition that child holds no slot_pin of that type — the same +// any-instance filter EnsurePin guards its first-fill with. SpiceDB evaluates +// the preconditions and the TOUCH atomically, so a pin the child's own runner +// committed after CopySlotGrants read the child's pins refuses the whole copy +// rather than landing a second pin beside it (which ReadPin would then fail +// closed on forever). A refusal wraps authz.ErrSlotPinned. +func (c *Client) CopySlotTuples(ctx context.Context, child authz.SessionRef, rels []authz.Relation, unpinnedTypes []string) 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), + }) + } + preconditions := make([]*v1.Precondition, 0, len(unpinnedTypes)) + for _, t := range unpinnedTypes { + preconditions = append(preconditions, &v1.Precondition{ + Operation: v1.Precondition_OPERATION_MUST_NOT_MATCH, + Filter: pinFilter(t, "", child), + }) + } + _, err := c.cl.WriteRelationships(ctx, &v1.WriteRelationshipsRequest{ + Updates: updates, + OptionalPreconditions: preconditions, + }) + if err == nil { + return nil + } + if status.Code(err) == codes.FailedPrecondition && !isSchemaDefinitionAbsent(err) { + return fmt.Errorf("copy slot tuples to %s: the child pinned a copied type concurrently: %w", child, authz.ErrSlotPinned) + } + return fmt.Errorf("copy slot tuples to %s: %w", child, err) +} + +// 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. +// +// PINS FIRST, then grants are listed and revoked. A single-occupancy grant is +// written by WriteGrantsPinned under a MUST_MATCH precondition on the pin, so +// once the pin is gone every racing pinned write fails instead of landing. +// Listing the grants only AFTER that delete means the grant read sees every +// pinned write that could still succeed. Revoking grants first (as this once +// did) left a window in which a pinned write landed after the grant list was +// read and before the pin was deleted — an orphaned grant that outlived the +// teardown until it expired. +// +// 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. - held, err := c.ListSlotGrants(ctx, ns, name) + // filter — so the read is what identifies the slot grants and pins, and the + // delete names exactly those. + pins, err := c.ListSlotPins(ctx, ns, name) + if isSchemaDefinitionAbsent(err) { + // No schema has been composed yet (no class has ever become valid), so + // no session can have bound anything: there is nothing to sweep. The + // mint and admission sweeps meet this state on a fresh install, and + // failing closed there would refuse every first session. + return nil + } if err != nil { - return fmt.Errorf("delete slot grants %s/%s: %w", ns, name, err) + return fmt.Errorf("delete slot grants %s/%s: list pins: %w", ns, name, err) } - if len(held) == 0 { - return nil + 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) + } } - if err := authz.RevokeSlots(ctx, c.Relations(), authz.SessionRef{Namespace: ns, Name: name}, held); err != nil { + 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 { + 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) + } + } return nil } @@ -185,18 +328,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 +396,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..4e2696ef --- /dev/null +++ b/pkg/authz/spicedb/slot_grants_test.go @@ -0,0 +1,300 @@ +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" + "errors" + "testing" + "time" + + 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" +) + +// 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(), authz.SessionRef{Namespace: "ns", Name: "child"}, rels, nil)) + + 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") +} + +// TestCopySlotTuples_GuardsEveryCopiedTypeOnTheChildBeingUnpinned: the fork +// copy re-runs on every ReconcileRestart pass while the child's runner may be +// live, so each copied type must ride a MUST_NOT_MATCH on the CHILD holding any +// pin of that type — the same any-instance filter EnsurePin first-fills under — +// in the same RPC as the TOUCH. +func TestCopySlotTuples_GuardsEveryCopiedTypeOnTheChildBeingUnpinned(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + c := &Client{cl: cl} + child := authz.SessionRef{Namespace: "ns", Name: "child"} + rels := []authz.Relation{authz.SlotPinRelation("git_repo", "acme/app", child)} + + require.NoError(t, c.CopySlotTuples(context.Background(), child, rels, []string{"git_repo", "crm_company"})) + + writes := srv.recordedWrites() + require.Len(t, writes, 1) + pre := writes[0].GetOptionalPreconditions() + require.Len(t, pre, 2, "one precondition per copied type") + var guarded []string + for _, p := range pre { + assert.Equal(t, v1.Precondition_OPERATION_MUST_NOT_MATCH, p.GetOperation()) + f := p.GetFilter() + assert.Empty(t, f.GetOptionalResourceId(), "any instance of the type, not just the copied one") + assert.Equal(t, authz.SlotPinRelationName, f.GetOptionalRelation()) + assert.Equal(t, "agentsession", f.GetOptionalSubjectFilter().GetSubjectType()) + assert.Equal(t, child.String(), f.GetOptionalSubjectFilter().GetOptionalSubjectId(), + "the guard is on the CHILD's pin, not the parent's") + guarded = append(guarded, f.GetResourceType()) + } + assert.ElementsMatch(t, []string{"git_repo", "crm_company"}, guarded) +} + +// TestCopySlotTuples_ChildPinnedConcurrentlyWrapsErrSlotPinned: a precondition +// failure means the child committed a pin of a copied type after the caller +// read the child's pins; it must surface as ErrSlotPinned so the next pass +// re-reads and skips that type, and anything else stays a plain error. +func TestCopySlotTuples_ChildPinnedConcurrentlyWrapsErrSlotPinned(t *testing.T) { + child := authz.SessionRef{Namespace: "ns", Name: "child"} + rels := []authz.Relation{authz.SlotPinRelation("git_repo", "acme/app", child)} + cases := []struct { + name string + writeErr error + wantPinned bool + }{ + {name: "precondition failure: wraps ErrSlotPinned", writeErr: status.Error(codes.FailedPrecondition, "pinned"), wantPinned: true}, + {name: "backend failure: plain error, not ErrSlotPinned", writeErr: status.Error(codes.Unavailable, "down"), wantPinned: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + c := &Client{cl: cl} + srv.writeErr = tc.writeErr + + err := c.CopySlotTuples(context.Background(), child, rels, []string{"git_repo"}) + require.Error(t, err) + assert.Equal(t, tc.wantPinned, errors.Is(err, authz.ErrSlotPinned)) + }) + } +} + +// TestDeleteSlotGrants_DeletesPinsBeforeListingAndRevokingGrants: once the pin +// is gone, every racing WriteGrantsPinned fails its MUST_MATCH, so the grant +// list read AFTER the pin delete is final. Revoking grants first left a window +// for a pinned write to land unlisted and outlive the teardown. +func TestDeleteSlotGrants_DeletesPinsBeforeListingAndRevokingGrants(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")) + + writes := srv.recordedWrites() + require.Len(t, writes, 2, "one pin delete, then one grant revoke") + require.Len(t, writes[0].Updates, 1) + assert.Equal(t, authz.SlotPinRelationName, writes[0].Updates[0].Relationship.GetRelation(), + "the pin delete must come first") + require.Len(t, writes[1].Updates, 1) + assert.Equal(t, "slot_grant_contact_access", writes[1].Updates[0].Relationship.GetRelation()) +} + +// Before any class has become valid, SpiceDB has no agentsession definition at +// all, so the reads behind a sweep fail FailedPrecondition. Nothing can have +// been granted against a definition that does not exist, so both sweeps are +// trivially satisfied — failing closed there refused every first session on a +// fresh install. Any other read failure still fails the sweep. +func TestSweeps_AbsentSchemaDefinitionIsNothingToDelete(t *testing.T) { + absent := status.Error(codes.FailedPrecondition, "object definition `agentsession` not found") + cases := []struct { + name string + readErr error + wantErr bool + }{ + {name: "definition absent: nothing to sweep, no error", readErr: absent}, + {name: "store down: the sweep fails closed", readErr: status.Error(codes.Unavailable, "down"), wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + c := &Client{cl: cl} + srv.readErr = tc.readErr + for _, sweep := range []func(context.Context, string, string) error{c.DeleteSlotGrants, c.DeleteDataSlotGrants} { + err := sweep(context.Background(), "ns", "s") + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + } + assert.Empty(t, srv.recordedWrites()) + }) + } +} diff --git a/pkg/authz/spicedb/slot_pin.go b/pkg/authz/spicedb/slot_pin.go new file mode 100644 index 00000000..ed157887 --- /dev/null +++ b/pkg/authz/spicedb/slot_pin.go @@ -0,0 +1,312 @@ +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. +// +// Idempotent at the target: if the precondition fails because the pin already +// names toID, the move is treated as a no-op (nil), not drift. See the +// SlotPinner.MovePin contract for why — a plan-scoped approval re-attempts the +// same move once per covered phase, and all but the first find it already done. +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 { + // fromID is no longer the pin. Idempotency: if the pin ALREADY names + // toID, a prior move (an earlier covered phase of this same approval, or + // a retry) landed this exact repoint — so this is a no-op, not drift. + // Read fully consistently and treat pin==toID as success; anything else + // is a genuine drift the approver must be told about. The read runs only + // on this already-rare precondition-failure path. + cur, rerr := w.ReadPin(ctx, resourceType, scope) + if rerr != nil { + return fmt.Errorf("move pin %s:%s -> %s:%s for %s: checking whether the move already landed: %w", + resourceType, fromID, resourceType, toID, scope, rerr) + } + if cur == toID { + // Already at the target. The real move revoked the displaced + // instance's grants; a no-op does not re-revoke (see the contract). + // The caller's WriteGrantsPinned(toID) binds same-instance next. + return nil + } + 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..31ab72b6 --- /dev/null +++ b/pkg/authz/spicedb/slot_pin_test.go @@ -0,0 +1,451 @@ +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) +} + +// TestMovePin_AlreadyAtTargetIsIdempotentNoOp: the MUST_MATCH(fromID) fails +// because the pin ALREADY names toID — a prior covered phase of the same +// approval landed this exact move. That is a no-op, not drift: MovePin reads the +// pin back, sees it is already the target, and returns nil so the caller's +// WriteGrantsPinned can bind same-instance. Without this, a plan-scoped approval +// would drift-fail every covered phase after the first. +func TestMovePin_AlreadyAtTargetIsIdempotentNoOp(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + srv.writeErr = status.Error(codes.FailedPrecondition, "fromID no longer pinned") + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + // The read-back finds the pin already on the target. + srv.readRels = []*v1.Relationship{pinTuple("git_repo", "repoB", scope)} + w := &RelationWriter{c: &Client{cl: cl}} + + err := w.MovePin(context.Background(), "git_repo", "repoA", "repoB", nil, scope) + require.NoError(t, err, "a move whose target is already pinned is an idempotent no-op") +} + +// TestMovePin_DriftToAThirdInstanceWrapsErrSlotPinned: MUST_MATCH(fromID) fails +// and the read-back finds the pin on an instance that is NEITHER fromID nor +// toID. That is genuine drift — the card showed a move from fromID, and the pin +// is somewhere else entirely — so it must surface as ErrSlotPinned, not be +// absorbed as a no-op. +func TestMovePin_DriftToAThirdInstanceWrapsErrSlotPinned(t *testing.T) { + cl, srv := newRecordingWriterClient(t) + srv.writeErr = status.Error(codes.FailedPrecondition, "fromID no longer pinned") + scope := authz.SessionRef{Namespace: "ns", Name: "s"} + srv.readRels = []*v1.Relationship{pinTuple("git_repo", "repoC", scope)} + w := &RelationWriter{c: &Client{cl: cl}} + + err := w.MovePin(context.Background(), "git_repo", "repoA", "repoB", nil, scope) + require.Error(t, err) + assert.ErrorIs(t, err, authz.ErrSlotPinned, "a pin on a third instance is drift, not a no-op") +} + +// 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..37c8957a 100644 --- a/pkg/authz/spicedb/writer_test.go +++ b/pkg/authz/spicedb/writer_test.go @@ -46,13 +46,33 @@ 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 + // readErr, when non-nil, is returned by every ReadRelationships call. + readErr error } 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 +80,27 @@ 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) + readErr := s.readErr + s.mu.Unlock() + if readErr != nil { + return readErr + } + 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 +123,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..fa10f2bf 100644 --- a/pkg/channels/channelsd/e2e/e2e_test.go +++ b/pkg/channels/channelsd/e2e/e2e_test.go @@ -93,6 +93,14 @@ 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 } + +// DeleteDataSlotGrants satisfies pipeline.Authz: the mint sweep's data-slot +// half. Same reasoning as DeleteSlotGrants above. +func (s *stubAuthz) DeleteDataSlotGrants(_ 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..777aee8d 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), 3, "both sweeps and the bind must have run") + assert.Equal(t, []string{"sweep", "dataSweep", "grant"}, az.slotCallOrder[:3], + "the slot and data-slot sweeps must run BEFORE the first bind") + + 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..5c0c127b 100644 --- a/pkg/channels/channelsd/pipeline/backfill.go +++ b/pkg/channels/channelsd/pipeline/backfill.go @@ -10,7 +10,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 +187,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 +259,72 @@ 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. + p.publishSlotBindRefusedNotice(ctx, sess, 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 RULINGS in err — authz.SlotPinRefusals, which also covers +// the multi-arrival "cannot hold N distinct instances at once" refusal. A +// transient SpiceDB fault 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. When both arrive in one call (one type refused, another +// blipped), the refusal is still announced and only its own text is quoted: the +// fault must neither suppress it nor leak its store detail into the notice. +// +// 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). The caller +// has already logged err in full. +func (p *Pipeline) publishSlotBindRefusedNotice(ctx context.Context, sess *spiceboxv1alpha1.AgentSession, err error) { + refusals := authz.SlotPinRefusals(err) + if len(refusals) == 0 || p.NATS == nil { + return + } + ref := channelevents.SessionRef{Namespace: sess.Namespace, Name: sess.Name} + // The route follows the REFUSED types' rebind policy only: a type that bound + // beside a refused rebind:never type must not reopen the amendment route. + if perr := slotBindRefusedNotice(authz.SlotRefusalText(refusals), authz.AllRebindNever(refusals)).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()) + } +} + +// 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/mint_sweep_test.go b/pkg/channels/channelsd/pipeline/mint_sweep_test.go new file mode 100644 index 00000000..533540e5 --- /dev/null +++ b/pkg/channels/channelsd/pipeline/mint_sweep_test.go @@ -0,0 +1,138 @@ +package pipeline + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/channels/channelkinds" +) + +// TestMint_AdmissionSweep covers the channelsd mint sweep, which runs BEFORE +// the Create and only while the session does not exist yet. A minted session +// is pre-stamped with the operator's finalizer, so the operator never sweeps +// it: this sweep is the only one it gets, and it must be fail-closed and +// retry-safe, and must never touch a session that already exists. +func TestMint_AdmissionSweep(t *testing.T) { + const key = "thread:C1:1" + plainEvent := func(ch *spiceboxv1alpha1.Channel) channelkinds.InboundEvent { + return channelkinds.InboundEvent{ + Channel: ch, + ExternalIDs: channelkinds.ExternalIdentity{Kind: "fake", ExternalID: "U1", Email: "a@example.com"}, + ChannelKey: key, + MessageText: "hello", + } + } + + cases := []struct { + name string + // channel builds the inbound Channel; nil means newChannel. + channel func() *spiceboxv1alpha1.Channel + // existing, when set, pre-creates an object under the minted name — + // another replica's (or an earlier delivery's) live session. It carries + // no channel labels, so Deliver's correlating lookup misses it and the + // fresh-session path runs, exactly as for a replica that lost the race. + existing bool + setup func(az *fakeAuthz) + event func(ch *spiceboxv1alpha1.Channel) channelkinds.InboundEvent + wantErr bool + wantOut channelkinds.Outcome + wantSweeps int + wantData int + wantCreate bool + wantParked bool + }{ + { + name: "fresh mint: both sweeps run, then the session is created pre-stamped with the finalizer", + event: plainEvent, + wantOut: channelkinds.OutcomeRouted, + wantSweeps: 1, wantData: 1, wantCreate: true, + }, + { + name: "slot sweep fails: fail closed, InternalError and NO session created", + setup: func(az *fakeAuthz) { az.deleteSlotGrantsErr = errors.New("spicedb down") }, + event: plainEvent, + wantErr: true, + wantOut: channelkinds.OutcomeInternalError, + wantSweeps: 1, wantData: 0, wantCreate: false, + }, + { + name: "data-slot sweep fails: fail closed, InternalError and NO session created", + setup: func(az *fakeAuthz) { az.deleteDataSlotGrantsErr = errors.New("spicedb down") }, + event: plainEvent, + wantErr: true, + wantOut: channelkinds.OutcomeInternalError, + wantSweeps: 1, wantData: 1, wantCreate: false, + }, + { + name: "session already exists (AlreadyExists race loser / redelivery): no sweep of the live session", + existing: true, + event: plainEvent, + wantOut: channelkinds.OutcomeRouted, + wantSweeps: 0, wantData: 0, wantCreate: true, + }, + { + name: "parked start: swept before Create like any other mint — the operator will not sweep it", + channel: func() *spiceboxv1alpha1.Channel { return newOrgScopedChannel("c1") }, + setup: func(az *fakeAuthz) { + startSessionRefused(az) + az.lookupSubjectsResult = []string{"YWRtaW5AZXhhbXBsZS5jb20"} + }, + event: func(ch *spiceboxv1alpha1.Channel) channelkinds.InboundEvent { + return channelkinds.InboundEvent{Channel: ch, ExternalIDs: guestIdentity("U_GUEST"), ChannelKey: key, MessageText: "hello"} + }, + wantOut: channelkinds.OutcomeDeniedByPermission, + wantSweeps: 1, wantData: 1, wantCreate: true, wantParked: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ch := newChannel("c1") + if tc.channel != nil { + ch = tc.channel() + } + objs := []client.Object{ch} + name := makeSessionName(ch, key) + if tc.existing { + objs = append(objs, &spiceboxv1alpha1.AgentSession{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ch.Namespace}, + Spec: spiceboxv1alpha1.AgentSessionSpec{Class: "ac1"}, + }) + } + p, az, _, _, cli := newPipeline(t, objs...) + ev := tc.event(ch) + if tc.setup != nil { + tc.setup(az) + } + + dec, err := p.Deliver(context.Background(), ev) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.wantOut, dec.Outcome) + assert.Equal(t, tc.wantSweeps, az.deleteSlotGrantsCalls, "DeleteSlotGrants calls") + assert.Equal(t, tc.wantData, az.deleteDataSlotGrantsCalls, "DeleteDataSlotGrants calls") + + var got spiceboxv1alpha1.AgentSession + getErr := cli.Get(context.Background(), client.ObjectKey{Namespace: ch.Namespace, Name: name}, &got) + if !tc.wantCreate { + assert.True(t, getErr != nil, "no session may exist after a failed sweep") + return + } + require.NoError(t, getErr) + if !tc.existing { + assert.Contains(t, got.Finalizers, spiceboxv1alpha1.FinalizerAgentSession, + "a minted session must carry the operator's finalizer, or the operator's own sweep races the mint-time binds") + } + assert.Equal(t, tc.wantParked, spiceboxv1alpha1.StartApprovalPending(&got), "parked") + }) + } +} diff --git a/pkg/channels/channelsd/pipeline/pipeline.go b/pkg/channels/channelsd/pipeline/pipeline.go index f7da98fb..5dbc16ac 100644 --- a/pkg/channels/channelsd/pipeline/pipeline.go +++ b/pkg/channels/channelsd/pipeline/pipeline.go @@ -81,6 +81,23 @@ 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, BEFORE it creates the session — 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 + // DeleteDataSlotGrants removes every data-slot grant + // (pt_tag:#granted_to@agentsession:) a session holds. The mint + // sweep calls it beside DeleteSlotGrants for the same reason: it is keyed by + // ns/name, and DeleteSlotGrants does not reach it. Present on + // *spicedb.Client (same method finalize + the operator's admission sweep + // use). + DeleteDataSlotGrants(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 +1521,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 runs + // its admission slot-tuple sweep before EnsureFinalizer whenever the + // finalizer is ABSENT, and skips it when a trusted creator + // pre-stamped it. Left unstamped, that sweep would race the mint-time + // slot binds this path performs right after the session exists + // (BindTriggerSlots, seedThreadSlots): the two are unordered, so the + // sweep could delete the legitimate authority — including the pin — + // those binds just wrote, deterministically when the operator is + // briefly backlogged. With the finalizer present the operator never + // sweeps a channelsd mint; this path sweeps stale tuples itself, + // BEFORE the Create (see sweepBeforeMint below). Only the operator + // and channelsd service accounts may add this finalizer at all — + // config/admission/agentsession-finalizer-policy.yaml enforces it — + // so the skip cannot be forged by an ordinary session creator. + Finalizers: []string{spiceboxv1alpha1.FinalizerAgentSession}, Labels: map[string]string{ spiceboxv1alpha1.LabelChannelName: ev.Channel.Name, spiceboxv1alpha1.LabelChannelKind: ev.Channel.Spec.Kind, @@ -1583,6 +1615,15 @@ func (p *Pipeline) Deliver(ctx context.Context, ev channelkinds.InboundEvent) (c if startParked { sess.Annotations[spiceboxv1alpha1.AnnotationStartApprovalRequestRef] = startRequestRef } + // Admission sweep for a channelsd mint, run BEFORE the Create. See + // sweepBeforeMint. Fail CLOSED: no session is created over a name whose + // predecessor's authority may still be live, and the inbound errors so a + // redelivery retries the whole mint — sweep included. + if err := p.sweepBeforeMint(ctx, sess.Namespace, sess.Name); err != nil { + log.FromContext(ctx).Info("mint admission sweep failed; refusing to create the session over unverified authority", + "session", sess.Namespace+"/"+sess.Name, "err", err.Error()) + return channelkinds.InboundDecision{Outcome: channelkinds.OutcomeInternalError}, err + } created := false if err := p.K8s.Create(ctx, sess); err != nil { if !apierrors.IsAlreadyExists(err) { @@ -2614,6 +2655,56 @@ func authorSubject(ev channelkinds.InboundEvent) identity.Subject { return canonicalID(ev.ExternalIDs).Subject() } +// sweepBeforeMint is the admission sweep for a channelsd mint: it wipes +// whatever a DEAD PREDECESSOR with this same ns/name left in SpiceDB — slot +// grants, the never-expiring slot pin, and data-slot grants, all keyed by +// ns/name rather than UID — before the session exists. A stale pin would refuse +// the new session its own first bind, and a stale grant would resolve for the +// new session: leaked authority under a reused name (makeSessionName is +// deterministic). +// +// This IS the operator's admission sweep, MOVED to the creator: this path +// pre-stamps the operator's finalizer, which makes the operator skip its own. +// +// BEFORE the Create, and only while the session does not exist, so it is +// retry-safe on its own. The previous rule — sweep after Create, gated on +// having won the Create — ran exactly once: a crash or a failed sweep between +// Create and the sweep left a pre-stamped (so operator-skipped), unswept +// session, and a redelivery hit AlreadyExists and never swept either. Here a +// crash anywhere before the Create leaves no session, so the redelivery sweeps +// again; a crash after it leaves a session that was already swept. A session +// that already exists (an AlreadyExists race loser, a redelivery after a +// successful mint, a parked start awaiting approval) is LIVE and owns its own +// tuples — it is never swept here. p.K8s is the uncached client, so the +// existence check reads the apiserver, not a lagging cache. +// +// RESIDUAL: two replicas minting the same session concurrently can both find +// it absent; the loser's sweep can then land after the winner's Create and +// mint-time binds, deleting them. That only removes authority the winner just +// wrote — the session re-asks — and never leaves a predecessor's behind. +// +// A nil Authz is a no-op (nothing binds, so there is nothing to sweep). +func (p *Pipeline) sweepBeforeMint(ctx context.Context, ns, name string) error { + if p.Authz == nil { + return nil + } + var existing spiceboxv1alpha1.AgentSession + err := p.K8s.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &existing) + switch { + case err == nil: + return nil + case !apierrors.IsNotFound(err): + return fmt.Errorf("mint admission sweep: check whether %s/%s exists: %w", ns, name, err) + } + if err := p.Authz.DeleteSlotGrants(ctx, ns, name); err != nil { + return fmt.Errorf("mint admission sweep: delete stale slot grants/pins %s/%s: %w", ns, name, err) + } + if err := p.Authz.DeleteDataSlotGrants(ctx, ns, name); err != nil { + return fmt.Errorf("mint admission sweep: delete stale data-slot grants %s/%s: %w", ns, name, err) + } + return nil +} + // makeSessionName produces a deterministic-ish name based on the channel // and key. Truncated + suffixed with a hash so k8s names stay valid. func makeSessionName(ch *spiceboxv1alpha1.Channel, key string) string { diff --git a/pkg/channels/channelsd/pipeline/pipeline_test.go b/pkg/channels/channelsd/pipeline/pipeline_test.go index ce132fdf..36ef19ac 100644 --- a/pkg/channels/channelsd/pipeline/pipeline_test.go +++ b/pkg/channels/channelsd/pipeline/pipeline_test.go @@ -46,9 +46,20 @@ type fakeAuthz struct { grantedSlotsSession string grantedSlotsExpiry time.Time grantSlotsErr error - checkResult bool - checkErr error - checkInteractFn func(canonicalID string) (bool, 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 + // deleteDataSlotGrantsCalls / deleteDataSlotGrantsErr back + // DeleteDataSlotGrants — the data-slot half of the same sweep. + deleteDataSlotGrantsCalls int + deleteDataSlotGrantsErr error + checkResult bool + checkErr error + checkInteractFn func(canonicalID string) (bool, error) // delegations models the SpiceDB lineage that agentsession#converse // resolves over: one entry per delegation edge, "/" @@ -236,6 +247,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 +257,29 @@ 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 +} + +// DeleteDataSlotGrants models the data-slot half of the mint admission sweep. +func (f *fakeAuthz) DeleteDataSlotGrants(_ context.Context, _, _ string) error { + f.deleteDataSlotGrantsCalls++ + f.slotCallOrder = append(f.slotCallOrder, "dataSweep") + return f.deleteDataSlotGrantsErr +} + // 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..c8134e9c --- /dev/null +++ b/pkg/channels/channelsd/pipeline/slot_bind_refused_notice_test.go @@ -0,0 +1,240 @@ +package pipeline + +import ( + "context" + "encoding/json" + "errors" + "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: pinRefusal(refusal, "", "github_pull_request")} + 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: pinRefusal(refusal, authz.SlotRebindNever, "web_target")} + 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") +} + +// TestBindTriggerSlots_PinRefusalWithStoreFault_NoticeQuotesOnlyTheRefusal: +// GrantSlots can join a genuine pin refusal for one type with a transient store +// fault for another. The refusal is still an answer the session must hear — the +// fault must not suppress it — but only the refusal's own text may be quoted: +// the fault's raw store endpoints and gRPC status are operator-only. +func TestBindTriggerSlots_PinRefusalWithStoreFault_NoticeQuotesOnlyTheRefusal(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) + + joined := errors.Join( + pinRefusal("slot already pinned to a different instance: pinned to github_pull_request:acme/original", "", "github_pull_request"), + authz.NewSlotBindFailure(authz.SlotFailureFault, "", + fmt.Errorf("%w: write slot grants: spicedb unavailable: dial tcp 10.0.0.1:50051", authz.ErrSlotStore), "label"), + ) + az := &fakeAuthz{grantSlotsErr: fmt.Errorf("spicedb: grant slots: %w", joined)} + 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 store fault on another type must not suppress the refusal") + require.NotNil(t, notices[0].Excerpt) + assert.Contains(t, notices[0].Excerpt.Content, "github_pull_request:acme/original") + assert.NotContains(t, notices[0].Excerpt.Content, "dial tcp", "store detail must stay operator-only") + assert.NotContains(t, notices[0].Excerpt.Content, "spicedb", "store detail must stay operator-only") +} + +// The NextStep routes by the REFUSED types' rebind policy, not every binding in +// the call: a rebind:never refusal must route to a new conversation only, never +// to an amendment GrantSlots would refuse every time. +func TestSlotBindRefusedNotice_RebindNeverRefusalRoutesToNewConversation(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: pinRefusal("slot already pinned to a different instance: this session is pinned to "+ + "github_pull_request:acme/original for its lifetime", authz.SlotRebindNever, "github_pull_request")} + 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) + assert.Contains(t, notices[0].NextStep, "start a new conversation") + assert.NotContains(t, notices[0].NextStep, "propose an updated plan") +} + +// pinRefusal is the per-type refusal GrantSlots produces, as the channelsd +// pipeline receives it. +func pinRefusal(text, rebind, resourceType string) error { + return authz.NewSlotBindFailure(authz.SlotFailureRefused, rebind, + fmt.Errorf("%s: %w", text, authz.ErrSlotPinned), resourceType) +} 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..beee7651 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,10 @@ 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. + p.publishSlotBindRefusedNotice(ctx, sess, 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 f7d83082..2e147430 100644 --- a/pkg/controllers/agentsession/controller.go +++ b/pkg/controllers/agentsession/controller.go @@ -513,6 +513,27 @@ type SpiceDBDeleter interface { DeleteDataSlotGrants(ctx context.Context, ns, name string) error } +// sweepStaleSlotTuples removes every slot grant, slot pin AND data-slot grant +// held under (ns, name) — the subject-side tuples finalize() removes on +// teardown, minus the agentsession-as-resource relations. Run BEFORE a session +// with this name can write its own: the admission sweep, and ReconcileRestart +// just before it creates a non-takeover fork child. All three kinds are keyed +// by (ns, name), not UID, so a reused name otherwise inherits a dead +// predecessor's never-expiring pin and its still-live grants. Both errors are +// returned; the caller fails closed. A nil SpiceDBDeleter is a no-op. +func (r *Reconciler) sweepStaleSlotTuples(ctx context.Context, ns, name string) error { + if r.SpiceDBDeleter == nil { + return nil + } + if err := r.SpiceDBDeleter.DeleteSlotGrants(ctx, ns, name); err != nil { + return fmt.Errorf("sweep slot grants/pins %s/%s: %w", ns, name, err) + } + if err := r.SpiceDBDeleter.DeleteDataSlotGrants(ctx, ns, name); err != nil { + return fmt.Errorf("sweep data-slot grants %s/%s: %w", ns, name, err) + } + return nil +} + // ScopeDeleter is the controller's narrow view of memory teardown, retained for // an ephemeral retention GC. The finalizer deliberately deletes NONE of a // session's memory: the tamper-evident audit log is PERMANENT and a session @@ -1151,8 +1172,67 @@ 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. + // + // The ONLY skip is a finalizer already present at first reconcile, which + // means a TRUSTED creator pre-stamped it and owns the sweep itself: + // + // - channelsd's mint pre-stamps the finalizer and runs this same sweep + // itself, before it creates the session and so ahead of its mint-time + // binds (sweepBeforeMint, pipeline.go); sweeping again here would wipe + // the pin and grants it wrote after the Create. + // - the operator's own fork path (BuildChildSession) pre-stamps it on a + // restart or inherit child, because the parent's ReconcileRestart copies + // the parent's slot grants AND pin onto the child's (ns, name) right + // after creating it (restart.go step 6c) — before this reconcile runs. + // Sweeping here would be the last writer and silently strip the fork of + // its inherited authority. A fork child's name is NOT fresh: it is a + // deterministic hash (deterministicForkName / deterministicInheritName in + // restart_publish.go), so repeating a restart after the child was + // deleted reuses it. ReconcileRestart therefore runs this same sweep on + // the target name itself, just before it creates the child — the + // operator still owns the sweep for these children, it just runs it + // earlier, ahead of the copy. A TAKEOVER child is not pre-stamped: it + // copies no grants, so it takes this sweep like any other new session. + // + // That skip is unforgeable only because config/admission/ + // agentsession-finalizer-policy.yaml (a ValidatingAdmissionPolicy) refuses + // any create or update that ADDS this finalizer unless the caller is the + // operator or channelsd service account. Without it, anyone able to create + // an AgentSession could pre-stamp the finalizer in their manifest and + // inherit a dead predecessor's never-expiring slot_pin — slot tuples are + // keyed by (ns, name), not UID. Nothing user-writable gates the sweep: + // spec.forkedFrom once did, but it is CR-creator controlled and nothing + // verifies the parent it names, so a forkedFrom session created without a + // pre-stamped finalizer is swept like any other. + if !controllerutil.ContainsFinalizer(&sess, spiceboxv1alpha1.FinalizerAgentSession) && + r.SpiceDBDeleter != nil { + if err := r.sweepStaleSlotTuples(ctx, sess.Namespace, sess.Name); err != nil { + log.FromContext(ctx).Error(err, "admission sweep: delete stale slot tuples 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..a4ce8316 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 @@ -1493,12 +1527,15 @@ func TestFinalize_DeletesSpiceDBRelationships(t *testing.T) { // `granted_to` is not a `slot_grant_` relation (so the slot sweep // drops it client-side). Without this call a child that was handed data // keeps `access` on those tags for as long as the retained CR exists. - assert.Equal(t, 1, deleter.dataSlotsCalls, "finalize must also collect the session's data slot grants") + // Two, like slot grants: the admission sweep removes a dead predecessor's + // data-slot grants under this name too. + assert.Equal(t, 2, deleter.dataSlotsCalls, "the admission sweep and finalize must each collect the session's data slot grants") 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", "dataSlots", "slots", "dataSlots", "session"}, deleter.order, + "the admission sweep's slot + data-slot calls land 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 +1547,248 @@ 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.Equal(t, 1, deleter.dataSlotsCalls, + "the admission sweep must also remove data-slot grants — they are keyed by (ns, name) like slot grants") + assert.Equal(t, sess.Name, deleter.dataSlotsName) + 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_AdmissionSweepRunsForAnUnverifiedForkedFrom is the reviewer's +// repro for the sweep bypass. spec.forkedFrom is written by whoever creates the +// CR and nothing verifies the parent it names, so it must not suppress the +// sweep: a session created with forkedFrom pointing at a session that does not +// exist, and no pre-stamped finalizer, must still have the stale pin and grants +// a dead predecessor left under its (ns, name) wiped. +func TestReconcile_AdmissionSweepRunsForAnUnverifiedForkedFrom(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-forkedfrom-sweep") + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + sess := validSession("sess-forkedfrom-sweep", "ac-forkedfrom-sweep") + sess.Spec.ForkedFrom = "no-such-parent" + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession claiming a fork parent") + // The stale pin + grant a dead predecessor with this same name left behind. + deleter.seedStaleSlotTuples(sess.Namespace, sess.Name) + + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(sess)}) + require.NoError(t, err, "Reconcile (admission)") + + assert.Equal(t, 1, deleter.slotGrantsCalls, + "a self-declared forkedFrom must not skip the admission sweep") + assert.False(t, deleter.hasStaleSlotTuples(sess.Namespace, sess.Name), + "the predecessor's stale slot_pin/slot_grant_* tuples must be gone") +} + +// TestReconcile_AdmissionSweepSparesAForkChildsCopiedTuples: the parent's +// ReconcileRestart copies slot grants + pin onto the fork child's (ns, name) +// right after creating it, BEFORE the child's own first reconcile runs. If the +// admission sweep ran on the child it would be the LAST writer and wipe that +// copied authority. What prevents it is the finalizer BuildChildSession +// pre-stamps — not spec.forkedFrom, which a CR creator controls — so this +// builds the child exactly as the restart path does. +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) + + parent := validSession("sess-fork-no-sweep-parent", "ac-fork-no-sweep") + child := agentsession.BuildChildSession(parent, &spiceboxv1alpha1.PendingRestart{ + Mode: spiceboxv1alpha1.PendingRestartModeInherit, + NewUserText: "continue", + TargetSessionName: "sess-fork-no-sweep-child", + }) + require.Contains(t, child.Finalizers, spiceboxv1alpha1.FinalizerAgentSession, + "fixture sanity: BuildChildSession pre-stamps the finalizer") + 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) + + _, _ = r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(child)}) + + 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_AdmissionSweepRunsForATakeoverChild: a takeover copies no slot +// grants, so BuildChildSession does not pre-stamp its finalizer and the child +// takes the ordinary admission sweep. Takeover is the mode where a DIFFERENT +// user inherits the name, so a dead predecessor's pin and grants under that +// deterministic name must not survive into it. +func TestReconcile_AdmissionSweepRunsForATakeoverChild(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-takeover-sweep") + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + parent := validSession("sess-takeover-sweep-parent", "ac-takeover-sweep") + child := agentsession.BuildChildSession(parent, &spiceboxv1alpha1.PendingRestart{ + Mode: spiceboxv1alpha1.PendingRestartModeTakeover, + NewUserText: "taking over", + TargetSessionName: "sess-takeover-sweep-child", + }) + require.NotContains(t, child.Finalizers, spiceboxv1alpha1.FinalizerAgentSession, + "fixture sanity: a takeover child is not pre-stamped") + require.NoError(t, env.Client.Create(ctx, child), "create takeover-child AgentSession") + deleter.seedStaleSlotTuples(child.Namespace, child.Name) + + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(child)}) + require.NoError(t, err, "Reconcile (admission)") + + assert.Equal(t, 1, deleter.slotGrantsCalls, "a takeover child must take the admission sweep") + assert.Equal(t, 1, deleter.dataSlotsCalls, "including its data-slot grants") + assert.False(t, deleter.hasStaleSlotTuples(child.Namespace, child.Name), + "a predecessor's tuples under the takeover child's name must be gone") +} + +// 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. Only +// the operator and channelsd may create a session that way; the admission +// policy enforcing that is tested in pkg/platform/manifests +// (TestAgentSessionFinalizerPolicy). envtest here does not load it. +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.go b/pkg/controllers/agentsession/restart.go index 453c1d9b..79a4090d 100644 --- a/pkg/controllers/agentsession/restart.go +++ b/pkg/controllers/agentsession/restart.go @@ -46,6 +46,12 @@ const restorePollInterval = 5 * time.Second // EnsureChildSession creates the deterministic child AgentSession // from BuildChildSession. Idempotent: on AlreadyExists, the existing // child is re-fetched and returned. Used by the restart reconciler. +// +// The child is created with the AgentSession finalizer already present, so +// its own first reconcile skips the admission sweep that would otherwise +// delete the slot grants and pin step 6c copies onto it. Only the operator's +// service account may create a session that way (the finalizer admission +// policy), which is why this create must stay on the operator's client. func EnsureChildSession(ctx context.Context, c client.Client, parent *spiceboxv1alpha1.AgentSession, pr *spiceboxv1alpha1.PendingRestart) (*spiceboxv1alpha1.AgentSession, error) { desired := BuildChildSession(parent, pr) if err := c.Create(ctx, desired); err != nil { @@ -64,7 +70,7 @@ func EnsureChildSession(ctx context.Context, c client.Client, parent *spiceboxv1 // not exist yet. A present child means the fork's seeding phase already // committed, so ReconcileRestart must resume at the idempotent tail rather than // replaying writes into a scope the child's runner now owns. -func findChildSession(ctx context.Context, c client.Client, ns, name string) (*spiceboxv1alpha1.AgentSession, error) { +func findChildSession(ctx context.Context, c client.Reader, ns, name string) (*spiceboxv1alpha1.AgentSession, error) { var child spiceboxv1alpha1.AgentSession err := c.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &child) switch { @@ -582,6 +588,40 @@ func (r *Reconciler) ReconcileRestart(ctx context.Context, sess *spiceboxv1alpha return false, ctrl.Result{}, fmt.Errorf("ReconcileRestart: lineage: %w", err) } + // Step 5a: sweep the target name before the child exists. A + // non-takeover child is created with the AgentSession finalizer + // pre-stamped (BuildChildSession), so its own first reconcile skips + // the admission sweep — it must, or it would wipe the grants step 6c + // copies. But the child's name is a deterministic hash, not a fresh + // one: repeating a restart after the child was deleted reuses it, and a + // dead predecessor's never-expiring pin (and live grants) would then + // sit under the new child unswept. Sweeping HERE, only while the child + // does not exist yet, covers that without ever sweeping a live child: + // a re-run of this reconcile finds the child and skips this block. A + // takeover child is not pre-stamped and takes the ordinary admission + // sweep instead. Fail closed: an unswept name must not be handed to a + // child. + // + // The child == nil above came from the CACHED client, which can lag a + // child this reconcile created on an earlier pass. Re-check uncached + // (APIReader, when wired) so a cache blink can never sweep a live + // child's own pins and grants. + if !takeover { + live := client.Reader(r.Client) + if r.APIReader != nil { + live = r.APIReader + } + existing, ferr := findChildSession(ctx, live, sess.Namespace, pr.TargetSessionName) + if ferr != nil { + return false, ctrl.Result{}, fmt.Errorf("ReconcileRestart: confirm child absent before sweep: %w", ferr) + } + if existing == nil { + if err := r.sweepStaleSlotTuples(ctx, sess.Namespace, pr.TargetSessionName); err != nil { + return false, ctrl.Result{}, fmt.Errorf("ReconcileRestart: sweep child name: %w", err) + } + } + } + // Step 5: ensure child session exists. Created AFTER its memory is // seeded so a freshly-started runner cannot race the copy above. child, err = EnsureChildSession(ctx, r.Client, sess, pr) @@ -633,6 +673,12 @@ func (r *Reconciler) ReconcileRestart(ctx context.Context, sess *spiceboxv1alpha // through slot_grant->interact, so it must not go live ahead of the // membership (and blocklist) it resolves against. // + // The copy survives the child's first reconcile only because + // BuildChildSession pre-stamps the AgentSession finalizer on a non-takeover + // child: a session whose finalizer is absent at first reconcile gets the + // admission sweep on its ns/name, which would delete everything copied + // here. Step 5a swept that name before the child was created instead. + // // Best-effort: a failure here costs the child a re-approval, so it must not // abort a fork whose memory and participants are already in place. if !takeover && r.SlotGrantCopier != nil { @@ -644,12 +690,18 @@ func (r *Reconciler) ReconcileRestart(ctx context.Context, sess *spiceboxv1alpha authz.SessionRef{Namespace: sess.Namespace, Name: sess.Name}, authz.SessionRef{Namespace: child.Namespace, Name: child.Name}, authz.SlotGrantExpiry(time.Now(), sessionExpiration)) - if cerr != nil { + switch { + case cerr != nil: log.FromContext(ctx).Info("ReconcileRestart: slot grants not carried to the child; it will re-ask", "session", sess.Namespace+"/"+sess.Name, "child", child.Name, "err", cerr.Error()) - } else if copied > 0 { + case copied.Grants > 0 || copied.Pins > 0 || len(copied.SkippedTypes) > 0: + // SkippedTypes is expected on every pass after the first (the child + // holds the pin this copy landed earlier), and on any pass after the + // child's own runner committed a pin of its own — see + // authz.CopySlotGrants for why the child's pin wins. log.FromContext(ctx).Info("ReconcileRestart: carried slot grants to the child", - "session", sess.Namespace+"/"+sess.Name, "child", child.Name, "grants", copied) + "session", sess.Namespace+"/"+sess.Name, "child", child.Name, + "grants", copied.Grants, "pins", copied.Pins, "skippedTypes", copied.SkippedTypes) } } diff --git a/pkg/controllers/agentsession/restart_decide.go b/pkg/controllers/agentsession/restart_decide.go index a8f027fd..a5101e67 100644 --- a/pkg/controllers/agentsession/restart_decide.go +++ b/pkg/controllers/agentsession/restart_decide.go @@ -178,12 +178,31 @@ func BuildChildSession(parent *spiceboxv1alpha1.AgentSession, pr *spiceboxv1alph teamID + ":" + channelID + ":" + threadTS } } + // Pre-stamped — for every mode EXCEPT takeover — so the child's first + // reconcile skips the admission sweep: ReconcileRestart copies the parent's + // slot grants AND pin onto this (ns, name) right after creating it + // (restart.go step 6c), and a sweep on the child's first reconcile would + // wipe them. ReconcileRestart sweeps the target name itself just before + // creating the child, so the skip does not leave a reused name unswept. + // Only the operator and channelsd service accounts may add this finalizer + // (config/admission/agentsession-finalizer-policy.yaml); the operator + // creates this child, so the policy admits it. + // + // A takeover copies NO grants (step 6c skips it), so a pre-stamp would buy + // nothing — and takeover is the mode where a DIFFERENT user inherits + // whatever is stale under the name. Leaving the finalizer off hands the + // child to the ordinary admission sweep. + var finalizers []string + if pr.Mode != spiceboxv1alpha1.PendingRestartModeTakeover { + finalizers = []string{spiceboxv1alpha1.FinalizerAgentSession} + } return &spiceboxv1alpha1.AgentSession{ ObjectMeta: metav1.ObjectMeta{ Name: pr.TargetSessionName, Namespace: parent.Namespace, Labels: childLabels, Annotations: childAnnotations, + Finalizers: finalizers, }, Spec: spiceboxv1alpha1.AgentSessionSpec{ Class: parent.Spec.Class, diff --git a/pkg/controllers/agentsession/restart_decide_test.go b/pkg/controllers/agentsession/restart_decide_test.go index 08db7979..ddb9825e 100644 --- a/pkg/controllers/agentsession/restart_decide_test.go +++ b/pkg/controllers/agentsession/restart_decide_test.go @@ -115,6 +115,34 @@ func TestBuildChildSession_CarriesForwardSpec(t *testing.T) { assert.Equal(t, "U1", child.Annotations[v1alpha1.AnnotationStartedByExternalID]) } +// TestBuildChildSession_PreStampsSessionFinalizer pins which restart children +// are created with the AgentSession finalizer already present. ReconcileRestart +// copies the parent's slot grants and pin onto a restart/inherit child right +// after creating it (restart.go step 6c); a child whose finalizer were absent at +// first reconcile would take the admission sweep and lose them. A takeover +// child copies NO grants, so it is deliberately NOT pre-stamped: it takes the +// ordinary admission sweep, which is what protects the different user who +// inherits the name from whatever is stale under it. +func TestBuildChildSession_PreStampsSessionFinalizer(t *testing.T) { + for _, tc := range []struct { + name string + mode string + want []string + }{ + {name: "restart-from-here: child carries the AgentSession finalizer", mode: v1alpha1.PendingRestartModeRestart, want: []string{v1alpha1.FinalizerAgentSession}}, + {name: "inherit: child carries the AgentSession finalizer", mode: v1alpha1.PendingRestartModeInherit, want: []string{v1alpha1.FinalizerAgentSession}}, + {name: "takeover: child carries no finalizer, so it takes the admission sweep", mode: v1alpha1.PendingRestartModeTakeover, want: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + parent := &v1alpha1.AgentSession{ObjectMeta: metav1.ObjectMeta{Name: "parent", Namespace: "ns"}} + child := agentsession.BuildChildSession(parent, &v1alpha1.PendingRestart{ + Mode: tc.mode, TargetSessionName: "parent-fk1", + }) + assert.Equal(t, tc.want, child.Finalizers) + }) + } +} + // TestBuildChildSession_ClearsThreadTSAndChannelKeyLabel verifies that a // restart-from-here (-fk) fork's ChannelBinding has thread_ts (and the // parent-thread permalink) stripped (so the Slack sender posts at channel diff --git a/pkg/controllers/agentsession/restart_envtest_test.go b/pkg/controllers/agentsession/restart_envtest_test.go index a37a4f0c..7cc2158f 100644 --- a/pkg/controllers/agentsession/restart_envtest_test.go +++ b/pkg/controllers/agentsession/restart_envtest_test.go @@ -471,20 +471,34 @@ 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...) +// ListSlotPins returns the pins naming ns/name as their subject: CopySlotGrants +// reads the CHILD's pins too, and a fake that answered the parent's pins for +// every session would make the child look already pinned. +func (c *recordingSlotCopier) ListSlotPins(_ context.Context, ns, name string) ([]authz.Relation, error) { + var out []authz.Relation + for _, p := range c.pins { + if p.SubjectID == ns+"/"+name { + out = append(out, p) + } + } + return out, nil +} + +func (c *recordingSlotCopier) CopySlotTuples(_ context.Context, _ authz.SessionRef, rels []authz.Relation, _ []string) error { + c.copied = append(c.copied, rels...) return nil } @@ -568,9 +582,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 @@ -642,10 +655,38 @@ func TestEnvtest_Inherit_CarriesSlotGrantsToTheChild(t *testing.T) { ForkNoticePublish: func(_ context.Context, _, _, _, _ string) error { return nil }, } + // A dead predecessor's tuples under the child's deterministic name. + deleter := &fakeSpiceDBDeleter{} + deleter.seedStaleSlotTuples(ns, "parent-inherit-slots-child") + r.SpiceDBDeleter = deleter + + // Captured before the first pass so the re-run below still carries the + // PendingRestart marker and walks the full path to the child lookup. + rerun := parent.DeepCopy() + _, _, err := r.ReconcileRestart(ctx, parent) require.NoError(t, err) - assert.Equal(t, ns+"/parent-inherit-slots-child", copier.grantedTo, + assert.Equal(t, 1, deleter.slotGrantsCalls, "the child's name must be swept before the child is created") + assert.Equal(t, "parent-inherit-slots-child", deleter.slotGrantsName) + assert.Equal(t, 1, deleter.dataSlotsCalls, "the pre-create sweep covers data-slot grants too") + assert.False(t, deleter.hasStaleSlotTuples(ns, "parent-inherit-slots-child"), + "a reused fork name must not hand the child a predecessor's pin") + 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) + + // A re-run finds the child and must NOT sweep it: the child is live by now + // and its tuples (the copy above, and anything its runner bound) are its own. + copier.copied = nil + if _, _, rerr := r.ReconcileRestart(ctx, rerun); rerr != nil { + // The stale copy may lose a later status write; only the sweep count is + // under test here. + t.Logf("re-run ReconcileRestart: %v", rerr) + } + assert.Equal(t, 1, deleter.slotGrantsCalls, "a re-run must never sweep a live child") + assert.NotEmpty(t, copier.copied, "fixture sanity: the re-run reached step 6c, past the child lookup") + 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/subagentrequest/child_sweep_test.go b/pkg/controllers/subagentrequest/child_sweep_test.go new file mode 100644 index 00000000..000e2ae1 --- /dev/null +++ b/pkg/controllers/subagentrequest/child_sweep_test.go @@ -0,0 +1,68 @@ +package subagentrequest + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" +) + +type recordingSweeper struct { + calls []string + slotErr error +} + +func (s *recordingSweeper) DeleteSlotGrants(_ context.Context, ns, name string) error { + s.calls = append(s.calls, "slot:"+ns+"/"+name) + return s.slotErr +} + +func (s *recordingSweeper) DeleteDataSlotGrants(_ context.Context, ns, name string) error { + s.calls = append(s.calls, "data:"+ns+"/"+name) + return nil +} + +// The subagent child's name is swept of a dead predecessor's slot and +// data-slot tuples before it is created, and only then — an existing child is +// a retry and already holds the grants this controller wrote, so sweeping it +// would strip them. A failed sweep fails closed. +func TestSweepAbsentChild(t *testing.T) { + child := &v1.AgentSession{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "req-child"}} + cases := []struct { + name string + existing []client.Object + slotErr error + wantSwept bool + wantErr bool + wantCalls []string + }{ + {name: "absent child: both sweeps run, swept", wantSwept: true, + wantCalls: []string{"slot:ns/req-child", "data:ns/req-child"}}, + {name: "existing child: never swept", existing: []client.Object{child.DeepCopy()}}, + {name: "sweep error: fails closed", slotErr: errors.New("spicedb down"), wantErr: true, + wantCalls: []string{"slot:ns/req-child"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(tc.existing...).Build() + sw := &recordingSweeper{slotErr: tc.slotErr} + r := &Reconciler{Client: c, SlotSweeper: sw} + + swept, err := r.sweepAbsentChild(context.Background(), child.DeepCopy()) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.wantSwept, swept) + assert.Equal(t, tc.wantCalls, sw.calls) + }) + } +} diff --git a/pkg/controllers/subagentrequest/controller.go b/pkg/controllers/subagentrequest/controller.go index f0352d93..f1e1bf7a 100644 --- a/pkg/controllers/subagentrequest/controller.go +++ b/pkg/controllers/subagentrequest/controller.go @@ -24,6 +24,7 @@ import ( "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -236,6 +237,17 @@ type Reconciler struct { Authz LineageWriter MaxDelegationDepth int + // SlotSweeper, when set, sweeps the child's (ns, name) of slot and + // data-slot tuples a dead predecessor left behind, BEFORE the child is + // created — and the child is then created with the session finalizer + // pre-stamped, so its own first reconcile does not run the admission sweep. + // That sweep would otherwise race the data-slot grants bindDataSlots writes + // onto the child right after Create, and could wipe them. The operator SA + // is allowed to pre-stamp it (config/admission/agentsession-finalizer-policy.yaml). + // Nil keeps the child unstamped and leaves the sweep to the AgentSession + // reconciler. + SlotSweeper SlotSweeper + // WorkshopBuild is the ONE relaxation of step 0's same-namespace rule: a // SubagentRequest whose parent is in a different namespace is admitted // only when sr.Namespace is a workshop provisioned for exactly that @@ -999,6 +1011,18 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu } } + if r.SlotSweeper != nil { + swept, err := r.sweepAbsentChild(ctx, child) + if err != nil { + // Fail closed: creating the child over a predecessor's unswept + // authority is the leak the sweep exists to prevent. Retried. + return ctrl.Result{}, err + } + if swept { + controllerutil.AddFinalizer(child, v1.FinalizerAgentSession) + } + } + if err := r.Create(ctx, child); err != nil && !apierrors.IsAlreadyExists(err) { if apierrors.IsInvalid(err) || apierrors.IsForbidden(err) { // Same hang shape loadParent's ParentClassMissing case exists to @@ -1095,6 +1119,40 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, r.Status().Update(ctx, &sr) } +// SlotSweeper deletes every slot and data-slot tuple naming a session. +// *spicedb.Client satisfies it. +type SlotSweeper interface { + DeleteSlotGrants(ctx context.Context, ns, name string) error + DeleteDataSlotGrants(ctx context.Context, ns, name string) error +} + +// sweepAbsentChild sweeps the child's name when the child does not exist yet, +// reporting whether it swept. An existing child — an AlreadyExists retry — is +// never swept: it may already hold the grants this controller wrote for it. +// Read uncached when an APIReader is wired, so a lagging cache cannot report a +// live child absent. +func (r *Reconciler) sweepAbsentChild(ctx context.Context, child *v1.AgentSession) (bool, error) { + reader := client.Reader(r.Client) + if r.APIReader != nil { + reader = r.APIReader + } + var existing v1.AgentSession + err := reader.Get(ctx, client.ObjectKeyFromObject(child), &existing) + if err == nil { + return false, nil + } + if !apierrors.IsNotFound(err) { + return false, fmt.Errorf("subagentrequest: check child %s/%s before sweep: %w", child.Namespace, child.Name, err) + } + if err := r.SlotSweeper.DeleteSlotGrants(ctx, child.Namespace, child.Name); err != nil { + return false, fmt.Errorf("subagentrequest: sweep stale slot tuples for child %s/%s: %w", child.Namespace, child.Name, err) + } + if err := r.SlotSweeper.DeleteDataSlotGrants(ctx, child.Namespace, child.Name); err != nil { + return false, fmt.Errorf("subagentrequest: sweep stale data-slot grants for child %s/%s: %w", child.Namespace, child.Name, err) + } + return true, nil +} + func (r *Reconciler) buildChild(sr *v1.SubagentRequest, parent, root *v1.AgentSession) *v1.AgentSession { // The child's tree root is the parent's root if the parent carries one, // and the parent's own name otherwise (a parent without the label IS a 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..815357e2 100644 --- a/pkg/memory/kinds/plangateaudit/kind.go +++ b/pkg/memory/kinds/plangateaudit/kind.go @@ -109,6 +109,36 @@ 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"` + + // PinnedImmovable, when non-empty, is the instance a `rebind: never` slot is + // CURRENTLY pinned to when the card names a DIFFERENT one. Unlike MovedFrom, + // this is NOT a move the approval will execute: a never slot's pin is fixed + // for the session's life, so the instance this phase names cannot be granted + // here at all. Recorded so the card says exactly that — "pinned to X, cannot + // change it; start a new session" — instead of rendering the named instance + // as a plain first-fill the human would wrongly read as grantable. DISPLAY + // ONLY and mutually exclusive with MovedFrom: the decision path never derives + // a PriorID from it (there is no move to make), so the bind takes the + // ordinary first-fill path and GrantSlots refuses the instance with the + // new-session route. Additive, like MovedFrom. + PinnedImmovable string `json:"pinnedImmovable,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/cloud/component.go b/pkg/platform/cloud/component.go index 3e7c28d0..b6af871c 100644 --- a/pkg/platform/cloud/component.go +++ b/pkg/platform/cloud/component.go @@ -257,6 +257,10 @@ func resolveDocGVR(gvk schema.GroupVersionKind) (schema.GroupVersionResource, bo return schema.GroupVersionResource{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingwebhookconfigurations"}, false, nil case "MutatingWebhookConfiguration": return schema.GroupVersionResource{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "mutatingwebhookconfigurations"}, false, nil + case "ValidatingAdmissionPolicy": + return schema.GroupVersionResource{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingadmissionpolicies"}, false, nil + case "ValidatingAdmissionPolicyBinding": + return schema.GroupVersionResource{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingadmissionpolicybindings"}, false, nil case "StorageClass": return schema.GroupVersionResource{Group: "storage.k8s.io", Version: "v1", Resource: "storageclasses"}, false, nil case "NetworkPolicy": diff --git a/pkg/platform/manifests/agentsession_admission_cel_test.go b/pkg/platform/manifests/agentsession_admission_cel_test.go new file mode 100644 index 00000000..105bdaa4 --- /dev/null +++ b/pkg/platform/manifests/agentsession_admission_cel_test.go @@ -0,0 +1,166 @@ +package manifests + +import ( + "strings" + "testing" + + "github.com/google/cel-go/cel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// These tests evaluate the CEL that ships in the install bundle — the exact +// strings the apiserver will run — rather than a Go re-statement of it. They +// bind every variable as dyn, which is enough to exercise presence (has()) and +// value logic; the apiserver's typed environment is exercised by the envtest +// suites (TestAgentSessionForkedFromImmutableCEL in pkg/apis/v1alpha1 and +// TestAgentSessionFinalizerPolicy in this package). + +// evalBool compiles expr against dyn-typed vars and evaluates it. +func evalBool(t *testing.T, expr string, vars map[string]any) bool { + t.Helper() + opts := make([]cel.EnvOption, 0, len(vars)) + for name := range vars { + opts = append(opts, cel.Variable(name, cel.DynType)) + } + env, err := cel.NewEnv(opts...) + require.NoError(t, err, "build CEL env") + ast, iss := env.Compile(expr) + require.NoError(t, iss.Err(), "compile %q", expr) + prg, err := env.Program(ast) + require.NoError(t, err, "program %q", expr) + out, _, err := prg.Eval(vars) + require.NoError(t, err, "eval %q", expr) + b, ok := out.Value().(bool) + require.True(t, ok, "%q must evaluate to bool, got %T", expr, out.Value()) + return b +} + +// bundledDoc returns the one bundled document of kind with name. +func bundledDoc(t *testing.T, kind, name string) *unstructured.Unstructured { + t.Helper() + for _, d := range allBundledDocs(t) { + if d.GetKind() == kind && d.GetName() == name { + return d + } + } + require.Failf(t, "missing bundled doc", "%s %s is not in the install bundle", kind, name) + return nil +} + +// TestAgentSessionForkedFromRuleRefusesAnyChange pins spec.forkedFrom's +// spec-level transition rule: it may be neither added, removed nor repointed +// after create. A field-level rule could not catch add/remove (it never runs +// when the field is absent on one side), which is why the rule lives on spec. +func TestAgentSessionForkedFromRuleRefusesAnyChange(t *testing.T) { + var rule string + for _, r := range validationRules(t, bundledDoc(t, "CustomResourceDefinition", "agentsessions.agentprimitives.authzed.com")) { + if strings.Contains(r.expr, "forkedFrom") { + require.Empty(t, rule, "expected exactly one forkedFrom rule, found a second at %s", r.path) + rule = r.expr + } + } + require.NotEmpty(t, rule, "the AgentSession CRD carries no spec.forkedFrom rule") + + none := map[string]any{"class": "demo-agent"} + forkA := map[string]any{"class": "demo-agent", "forkedFrom": "session-a"} + forkB := map[string]any{"class": "demo-agent", "forkedFrom": "session-b"} + + cases := []struct { + name string + old, new map[string]any + want bool + }{ + {name: "unset to unset: allowed", old: none, new: none, want: true}, + {name: "A to A: allowed (a re-apply is a no-op)", old: forkA, new: forkA, want: true}, + {name: "unset to A: refused (adding after create)", old: none, new: forkA, want: false}, + {name: "A to unset: refused (removing)", old: forkA, new: none, want: false}, + {name: "A to B: refused (repointing)", old: forkA, new: forkB, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, evalBool(t, rule, map[string]any{"self": tc.new, "oldSelf": tc.old})) + }) + } +} + +// TestAgentSessionFinalizerPolicyCEL pins the shipped finalizer admission +// policy's logic: its matchCondition selects exactly the writes that ADD the +// AgentSession finalizer, and its validation admits only the operator and +// channelsd service accounts. +func TestAgentSessionFinalizerPolicyCEL(t *testing.T) { + const name = "agentsession-finalizer.agentprimitives.authzed.com" + pol := bundledDoc(t, "ValidatingAdmissionPolicy", name) + binding := bundledDoc(t, "ValidatingAdmissionPolicyBinding", name) + + policyName, _, _ := unstructured.NestedString(binding.Object, "spec", "policyName") + assert.Equal(t, name, policyName, "the binding must bind this policy") + actions, _, _ := unstructured.NestedStringSlice(binding.Object, "spec", "validationActions") + assert.Equal(t, []string{"Deny"}, actions, "the binding must deny, not merely warn or audit") + failurePolicy, _, _ := unstructured.NestedString(pol.Object, "spec", "failurePolicy") + assert.Equal(t, "Fail", failurePolicy, "the policy must fail closed") + assert.Empty(t, pol.GetNamespace(), "a ValidatingAdmissionPolicy is cluster-scoped") + assert.Empty(t, binding.GetNamespace(), "a ValidatingAdmissionPolicyBinding is cluster-scoped") + + conds, _, err := unstructured.NestedSlice(pol.Object, "spec", "matchConditions") + require.NoError(t, err) + require.Len(t, conds, 1, "one matchCondition: the write adds the finalizer") + match := conds[0].(map[string]any)["expression"].(string) + vals, _, err := unstructured.NestedSlice(pol.Object, "spec", "validations") + require.NoError(t, err) + require.Len(t, vals, 1, "one validation: the caller is a trusted service account") + validation := vals[0].(map[string]any)["expression"].(string) + + const fin = "agentsession.agentprimitives.authzed.com/finalizer" + obj := func(finalizers ...any) map[string]any { + md := map[string]any{"name": "s"} + if finalizers != nil { + md["finalizers"] = finalizers + } + return map[string]any{"metadata": md} + } + req := func(op string) map[string]any { + return map[string]any{"operation": op, "userInfo": map[string]any{"username": "alice"}} + } + + matchCases := []struct { + name string + op string + object map[string]any + old any + want bool + }{ + {name: "CREATE with the finalizer: evaluated", op: "CREATE", object: obj(fin), old: nil, want: true}, + {name: "CREATE with only another finalizer: not evaluated", op: "CREATE", object: obj("other/finalizer"), old: nil, want: false}, + {name: "CREATE with no finalizers: not evaluated", op: "CREATE", object: obj(), old: nil, want: false}, + {name: "UPDATE adding it to none: evaluated", op: "UPDATE", object: obj(fin), old: obj(), want: true}, + {name: "UPDATE adding it beside another: evaluated", op: "UPDATE", object: obj("other/finalizer", fin), old: obj("other/finalizer"), want: true}, + {name: "UPDATE keeping it: not evaluated", op: "UPDATE", object: obj(fin), old: obj(fin), want: false}, + {name: "UPDATE removing it: not evaluated", op: "UPDATE", object: obj(), old: obj(fin), want: false}, + } + for _, tc := range matchCases { + t.Run("match: "+tc.name, func(t *testing.T) { + got := evalBool(t, match, map[string]any{"object": tc.object, "oldObject": tc.old, "request": req(tc.op)}) + assert.Equal(t, tc.want, got) + }) + } + + userCases := []struct { + name string + username string + want bool + }{ + {name: "operator SA: admitted", username: "system:serviceaccount:agentprimitives-system:spicebox-operator", want: true}, + {name: "channelsd SA: admitted", username: "system:serviceaccount:agentprimitives-system:spicebox-channelsd", want: true}, + {name: "a human user: refused", username: "alice", want: false}, + {name: "a same-named SA in another namespace: refused", username: "system:serviceaccount:team-a:spicebox-operator", want: false}, + {name: "webd SA: refused", username: "system:serviceaccount:agentprimitives-system:spicebox-webd", want: false}, + } + for _, tc := range userCases { + t.Run("validation: "+tc.name, func(t *testing.T) { + r := map[string]any{"operation": "CREATE", "userInfo": map[string]any{"username": tc.username}} + assert.Equal(t, tc.want, evalBool(t, validation, map[string]any{"request": r})) + }) + } +} diff --git a/pkg/platform/manifests/agentsession_finalizer_policy_test.go b/pkg/platform/manifests/agentsession_finalizer_policy_test.go new file mode 100644 index 00000000..066d13ce --- /dev/null +++ b/pkg/platform/manifests/agentsession_finalizer_policy_test.go @@ -0,0 +1,161 @@ +//go:build integration + +package manifests_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/controllers/testenv" + "github.com/authzed/openagentprimitives/pkg/platform/manifests" +) + +const ( + operatorSAUser = "system:serviceaccount:agentprimitives-system:spicebox-operator" + channelsdSAUser = "system:serviceaccount:agentprimitives-system:spicebox-channelsd" + untrustedUser = "alice@example.com" +) + +// impersonatingClient returns a client acting as username. The system:masters +// group spares the test an RBAC fixture per user: RBAC is not what is under +// test, and admission policies apply to masters exactly as to anyone else — +// which is the property this test relies on. +func impersonatingClient(t *testing.T, env *testenv.Env, username string) client.Client { + t.Helper() + cfg := rest.CopyConfig(env.Cfg) + cfg.Impersonate = rest.ImpersonationConfig{UserName: username, Groups: []string{"system:masters", "system:authenticated"}} + c, err := client.New(cfg, client.Options{Scheme: env.Scheme}) + require.NoError(t, err, "build impersonating client for %s", username) + return c +} + +func finalizerTestSession(name string, withFinalizer bool) *spiceboxv1alpha1.AgentSession { + s := &spiceboxv1alpha1.AgentSession{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: spiceboxv1alpha1.AgentSessionSpec{ + Class: "demo-agent", + Prompt: spiceboxv1alpha1.PromptSource{Inline: "hello"}, + }, + } + if withFinalizer { + s.Finalizers = []string{spiceboxv1alpha1.FinalizerAgentSession} + } + return s +} + +// installFinalizerPolicy creates the ValidatingAdmissionPolicy and binding +// exactly as the install bundle ships them, then waits for the apiserver to +// start enforcing — policy activation is eventually consistent, so a test that +// asserted immediately would race the policy's own load. +func installFinalizerPolicy(t *testing.T, ctx context.Context, env *testenv.Env) { + t.Helper() + docs, err := manifests.Split(manifests.Install) + require.NoError(t, err, "split install bundle") + installed := 0 + for _, d := range docs { + switch d.GetKind() { + case "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding": + default: + continue + } + if d.GetName() != "agentsession-finalizer.agentprimitives.authzed.com" { + continue + } + require.NoError(t, env.Client.Create(ctx, d), "create %s %s", d.GetKind(), d.GetName()) + installed++ + } + require.Equal(t, 2, installed, "the bundle must ship the finalizer policy AND its binding") + + untrusted := impersonatingClient(t, env, untrustedUser) + probe := finalizerTestSession("policy-activation-probe", true) + deadline := time.Now().Add(30 * time.Second) + for { + // Dry-run: admission runs in full but nothing is persisted, so a probe + // that lands before the policy is active leaves nothing behind. + err := untrusted.Create(ctx, probe.DeepCopy(), client.DryRunAll) + if apierrors.IsForbidden(err) { + return + } + require.Truef(t, err == nil, "unexpected error while waiting for the policy to activate: %v", err) + require.Truef(t, time.Now().Before(deadline), "the finalizer policy did not start enforcing within 30s") + time.Sleep(200 * time.Millisecond) + } +} + +// TestAgentSessionFinalizerPolicy proves the shipped admission policy makes +// the operator's admission-sweep skip unforgeable: only the operator and +// channelsd service accounts may add the AgentSession finalizer, and writes +// that do not add it are untouched. +func TestAgentSessionFinalizerPolicy(t *testing.T) { + env := testenv.Start(t) + ctx := context.Background() + installFinalizerPolicy(t, ctx, env) + + untrusted := impersonatingClient(t, env, untrustedUser) + + t.Run("untrusted CREATE with the finalizer: refused as Forbidden", func(t *testing.T) { + err := untrusted.Create(ctx, finalizerTestSession("untrusted-prestamped", true)) + require.Error(t, err) + assert.True(t, apierrors.IsForbidden(err), "expected Forbidden, got: %v", err) + assert.Contains(t, err.Error(), "only the agentprimitives operator and channelsd may add") + }) + + t.Run("untrusted CREATE without the finalizer: allowed", func(t *testing.T) { + assert.NoError(t, untrusted.Create(ctx, finalizerTestSession("untrusted-plain", false))) + }) + + for _, tc := range []struct { + name, user string + }{ + {name: "channelsd CREATE with the finalizer: allowed", user: channelsdSAUser}, + {name: "operator CREATE with the finalizer: allowed", user: operatorSAUser}, + } { + t.Run(tc.name, func(t *testing.T) { + c := impersonatingClient(t, env, tc.user) + assert.NoError(t, c.Create(ctx, finalizerTestSession(fmt.Sprintf("trusted-%d", time.Now().UnixNano()), true))) + }) + } + + t.Run("untrusted UPDATE adding the finalizer: refused; operator UPDATE adding it: allowed", func(t *testing.T) { + sess := finalizerTestSession("update-adds", false) + require.NoError(t, untrusted.Create(ctx, sess), "create without the finalizer") + + var fresh spiceboxv1alpha1.AgentSession + require.NoError(t, untrusted.Get(ctx, client.ObjectKeyFromObject(sess), &fresh)) + fresh.Finalizers = append(fresh.Finalizers, spiceboxv1alpha1.FinalizerAgentSession) + err := untrusted.Update(ctx, fresh.DeepCopy()) + require.Error(t, err, "an untrusted update adding the finalizer must be refused") + assert.True(t, apierrors.IsForbidden(err), "expected Forbidden, got: %v", err) + + // The operator's EnsureFinalizer path: same write, trusted caller. + operator := impersonatingClient(t, env, operatorSAUser) + assert.NoError(t, operator.Update(ctx, fresh.DeepCopy()), "the operator adds the finalizer after its sweep") + }) + + t.Run("untrusted UPDATEs that keep or remove the finalizer: allowed", func(t *testing.T) { + channelsd := impersonatingClient(t, env, channelsdSAUser) + sess := finalizerTestSession("update-keeps", true) + require.NoError(t, channelsd.Create(ctx, sess), "channelsd mint with the finalizer pre-stamped") + + var fresh spiceboxv1alpha1.AgentSession + require.NoError(t, untrusted.Get(ctx, client.ObjectKeyFromObject(sess), &fresh)) + if fresh.Labels == nil { + fresh.Labels = map[string]string{} + } + fresh.Labels["touched"] = "yes" + require.NoError(t, untrusted.Update(ctx, &fresh), "an unrelated update leaving the finalizer in place must not be evaluated") + + fresh.Finalizers = nil + assert.NoError(t, untrusted.Update(ctx, &fresh), "removing the finalizer is not adding it") + }) +} diff --git a/pkg/platform/manifests/install.yaml b/pkg/platform/manifests/install.yaml index 2d06755c..e36d676f 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 @@ -4629,6 +4707,12 @@ spec: a rule that only refuses REMOVING parent still lets it be repointed at a different session, which silently moves a running session's approval and transcript-read standing to a different set of humans. + + forkedFrom is pinned harder than parent: it may be neither added, removed nor + repointed after create, because only the operator's fork path sets it, and + only at create (BuildChildSession). A field-level rule cannot express this — + it never runs when the field is absent on either side — hence the spec-level + transition rule comparing presence as well as value. properties: agentIdentity: description: |- @@ -4701,7 +4785,10 @@ spec: forkedFrom: description: |- ForkedFrom names the prior AgentSession (same namespace) this - session was forked from via Restart-from-here. Immutable. + session was forked from via Restart-from-here. Immutable after + create (CEL rule on AgentSessionSpec). Whoever creates the CR picks + the value and nothing verifies that the named session exists, so it + must never gate anything security-relevant. Distinct from ChannelBinding.InheritFrom (set on auto-inherit after archive); ForkedFrom is set on explicit user-triggered restart. @@ -4898,6 +4985,10 @@ spec: x-kubernetes-validations: - message: spec.parent is immutable once set rule: '!has(oldSelf.parent) || (has(self.parent) && self.parent == oldSelf.parent)' + - message: 'spec.forkedFrom is immutable: it cannot be added, removed + or changed after create' + rule: has(self.forkedFrom) == has(oldSelf.forkedFrom) && (!has(self.forkedFrom) + || self.forkedFrom == oldSelf.forkedFrom) status: properties: activeWidgets: @@ -9082,6 +9173,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 @@ -30850,6 +30998,46 @@ spec: name: update-graph name: config --- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: agentsession-finalizer.agentprimitives.authzed.com +spec: + failurePolicy: Fail + matchConditions: + - expression: has(object.metadata.finalizers) && 'agentsession.agentprimitives.authzed.com/finalizer' + in object.metadata.finalizers && (request.operation == 'CREATE' || !has(oldObject.metadata.finalizers) + || !('agentsession.agentprimitives.authzed.com/finalizer' in oldObject.metadata.finalizers)) + name: adds-agentsession-finalizer + matchConstraints: + resourceRules: + - apiGroups: + - agentprimitives.authzed.com + apiVersions: + - '*' + operations: + - CREATE + - UPDATE + resources: + - agentsessions + validations: + - expression: request.userInfo.username in [ 'system:serviceaccount:agentprimitives-system:spicebox-operator', + 'system:serviceaccount:agentprimitives-system:spicebox-channelsd'] + messageExpression: '''only the agentprimitives operator and channelsd may add + the agentsession.agentprimitives.authzed.com/finalizer finalizer; '' + request.userInfo.username + + '' may not. Create the AgentSession without it and the operator adds it after + sweeping stale slot grants.''' + reason: Forbidden +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: agentsession-finalizer.agentprimitives.authzed.com +spec: + policyName: agentsession-finalizer.agentprimitives.authzed.com + validationActions: + - Deny +--- apiVersion: agentprimitives.authzed.com/v1alpha1 kind: SpiceboxToolchain metadata: diff --git a/pkg/platform/oap/install/apply.go b/pkg/platform/oap/install/apply.go index e4d67341..dad7728a 100644 --- a/pkg/platform/oap/install/apply.go +++ b/pkg/platform/oap/install/apply.go @@ -468,28 +468,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) @@ -504,15 +521,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 d7c7cafe..72860f42 100644 --- a/site/content/docs/crd-agentsession.mdx +++ b/site/content/docs/crd-agentsession.mdx @@ -33,7 +33,7 @@ status update. | `spec.budget.sessionExpiration` | `string` | SessionExpiration is a hard wall-clock lifetime cap measured from status.startedAt (e.g. "24h"), regardless of how much the agent ran. The operator fails the session once exceeded, even while it is idle or asleep. Zero = never expires (no default). Distinct from MaxDuration, which counts only active run-time. | | `spec.class` \* | `string` | Class names the AgentClass in the same namespace. | | `spec.forkedAtTurn` | `integer (int32)` | ForkedAtTurn is the index of the last turn copied from ForkedFrom (inclusive). The new user text became turn ForkedAtTurn+1. | -| `spec.forkedFrom` | `string` | ForkedFrom names the prior AgentSession (same namespace) this session was forked from via Restart-from-here. Immutable. Distinct from ChannelBinding.InheritFrom (set on auto-inherit after archive); ForkedFrom is set on explicit user-triggered restart. | +| `spec.forkedFrom` | `string` | ForkedFrom names the prior AgentSession (same namespace) this session was forked from via Restart-from-here. Immutable after create (CEL rule on AgentSessionSpec). Whoever creates the CR picks the value and nothing verifies that the named session exists, so it must never gate anything security-relevant. Distinct from ChannelBinding.InheritFrom (set on auto-inherit after archive); ForkedFrom is set on explicit user-triggered restart. | | `spec.inputChannel` | `object` | InputChannel records the channel-driven origin — the Channel whose listener (or scheduler, for bento) produced the inbound message that created this session. Set by channelsd at session creation; nil for kubectl-driven sessions. | | `spec.inputChannel.capabilities` \* | `[]string` | Capabilities lists the format capabilities advertised by the kind (e.g., {"text","markdown"}). The runner consumes these to shape the respond_to_user tool's JSON schema. | | `spec.inputChannel.external` | `map[string]string` | External carries kind-specific opaque routing metadata that the kind's Sender consumes to render outbound (e.g., Slack channel_id, thread_ts). Treated as opaque by the rest of the system. | @@ -569,6 +569,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/harness.go b/test/e2e/harness.go index adbbe7c6..048b8436 100644 --- a/test/e2e/harness.go +++ b/test/e2e/harness.go @@ -1333,6 +1333,7 @@ func (h *Harness) startManager(t *testing.T, env *testenv.Env, spdbCli *spicedb. Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Authz: spdbCli, + SlotSweeper: spdbCli, MaxDelegationDepth: 3, DefaultMaxDelegatedAgents: 8, // Grader, publisher and source lookup mirror internal/cmd/operator. 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