You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Since #31, owner-annotated zod resources scope reads by default: OwnerScopeHook injects WHERE ownerColumn = actor.id on every list/read (opt-out per resource with ownerScope: false). Secure by default — but it surfaces two visibility scenarios the hook cannot express today:
1. Admin sees everything on the same route. The ACL layer already models this (readAny vs readOwn possession), but the scope hook filters unconditionally — an admin with a readAny grant still only sees their own rows. This is a consequence of a deliberate boundary: the actor that reaches hooks is narrowed to { id, type }; roles are dropped on purpose so hooks compile outside HTTP. Hooks must not learn about roles.
Today's answer is a separate admin surface (/admin/pets + AdminGuard + an unscoped service — see examples/sample-server/src/admin/). It works and has clean audit boundaries, but duplicates routes.
2. Hierarchical / group scoping. "Dealer users see all users of their dealer; admin sees all dealers." That is scoping by a different column (dealerId) against a different actor attribute (actor.metadata.dealerId) — not expressible with OwnerScopeHook, whose compared value is always actor.id. Today's answer is a custom hook per resource (the reminder-owner-scope.hook.ts / pet-owner-or-shared.hook.ts pattern), which works but is per-consumer boilerplate for a very common shape.
Observation
All three visibility rules are the same abstraction:
Who
Sees
Rule
regular user
own rows
userId = actor.id (today's default)
dealer user
dealer's rows
dealerId = actor.metadata.dealerId
admin
everything
no filter (possession any)
A scope policy = (column, actor attribute, skip condition). OwnerScopeHook is the special case (ownerColumn, actor.id, never).
Sketch (for discussion, not a commitment)
zodResource({name: 'User',schema: userSchema,scope: {column: 'dealerId',actor: (a)=>a.metadata?.dealerId,// instead of actor.idhonorPossession: true,// ACL readAny (admin) skips the filter},});
honorPossession would work without breaking the roles-don't-reach-hooks boundary: the ACL guard already computes possession per request; it would record possession: 'any' in the request context overlay, and the scope hook reads that flag (not roles, not identity). Authorization stays in the guard; the hook stays a filter.
Open questions
Is possession-aware skipping worth it, or is the separate-admin-surface pattern the better permanent answer (explicit audit boundary, distinct swagger/rate-limits)?
Should scope.actor accept arbitrary selectors, or only whitelisted actor fields (id, metadata.<key>)? Arbitrary functions make the policy non-serializable and harder to audit.
Per-operation granularity: scope list/read but not update? Today the hook covers all read paths uniformly.
Interaction with ownerStamp — group-scoped resources probably still stamp the individual creator. Two columns, two policies?
Does this stay a zod-layer convenience, or move down to defineResource so classic resources get it too?
Related: the tenancy stance in .code-analysis/context.md (no first-class multi-tenancy; Actor.metadata is the designated bag for tenant/dealer ids). This proposal deliberately does NOT introduce tenancy — it generalizes row-visibility filters that consumers already write by hand.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Context
Since #31, owner-annotated zod resources scope reads by default:
OwnerScopeHookinjectsWHERE ownerColumn = actor.idon every list/read (opt-out per resource withownerScope: false). Secure by default — but it surfaces two visibility scenarios the hook cannot express today:1. Admin sees everything on the same route. The ACL layer already models this (
readAnyvsreadOwnpossession), but the scope hook filters unconditionally — an admin with areadAnygrant still only sees their own rows. This is a consequence of a deliberate boundary: the actor that reaches hooks is narrowed to{ id, type }; roles are dropped on purpose so hooks compile outside HTTP. Hooks must not learn about roles.Today's answer is a separate admin surface (
/admin/pets+AdminGuard+ an unscoped service — seeexamples/sample-server/src/admin/). It works and has clean audit boundaries, but duplicates routes.2. Hierarchical / group scoping. "Dealer users see all users of their dealer; admin sees all dealers." That is scoping by a different column (
dealerId) against a different actor attribute (actor.metadata.dealerId) — not expressible withOwnerScopeHook, whose compared value is alwaysactor.id. Today's answer is a custom hook per resource (thereminder-owner-scope.hook.ts/pet-owner-or-shared.hook.tspattern), which works but is per-consumer boilerplate for a very common shape.Observation
All three visibility rules are the same abstraction:
userId = actor.id(today's default)dealerId = actor.metadata.dealerIdany)A scope policy = (column, actor attribute, skip condition).
OwnerScopeHookis the special case(ownerColumn, actor.id, never).Sketch (for discussion, not a commitment)
honorPossessionwould work without breaking the roles-don't-reach-hooks boundary: the ACL guard already computes possession per request; it would recordpossession: 'any'in the request context overlay, and the scope hook reads that flag (not roles, not identity). Authorization stays in the guard; the hook stays a filter.Open questions
scope.actoraccept arbitrary selectors, or only whitelisted actor fields (id,metadata.<key>)? Arbitrary functions make the policy non-serializable and harder to audit.ownerStamp— group-scoped resources probably still stamp the individual creator. Two columns, two policies?defineResourceso classic resources get it too?Related: the tenancy stance in
.code-analysis/context.md(no first-class multi-tenancy;Actor.metadatais the designated bag for tenant/dealer ids). This proposal deliberately does NOT introduce tenancy — it generalizes row-visibility filters that consumers already write by hand.All reactions