Permissions Scenarios
Status: validation exercise for
permissions.md, not itself a design doc. This is a matrix of concrete scenarios - "give Alice permission to X" - used to stress-test the design inpermissions.mdagainst real cases rather than abstract rules. Every scenario should eventually get a concrete answer: theGrantServicecall(s) that implement it, and the permission object's affected slice before and after. Where a scenario can't be answered without resolving one ofpermissions.md's open questions first, that's flagged explicitly rather than guessed at.This pass drafts the questions only - the matrix, organized so coverage is systematic rather than accidental. Answers (grant calls, before/after object state) are the next pass, not this one.
Methodology
Each scenario is drawn from a grid across four axes, plus a smaller set of curated edge cases a grid alone wouldn't surface:
- Resource/table type - the 5 resources (
company,team,collection,userIntegration,embeddingConfig) and the 6 structural/grant tables (companyUser,teamUser,collectionTeam,collectionUserIntegration,userIntegrationEmbeddingConfig,DirectGrant). - Action - create, read, update, delete/revoke.
- Scope of restriction - global/company-wide vs. one specific instance (a single team, a single collection, etc.) - this is the "all teams vs. one team" axis named directly.
- Principal type - a user (primary case) vs. an API key (secondary -
every apiKey scenario is already flagged against the open Principal
question in
permissions.md).
Format for each scenario, once answered:
### N. <plain-English scenario>
**Action(s):** <GrantService calls>
**Before:** <affected slice of the permission object>
**After:** <affected slice of the permission object>
**Notes:** <anything the scenario exposes - a gap, an ambiguity, confirmation the design already handles it cleanly>
A. Core resource scopes - company
- Give Alice
search.readcompany-wide at Acme Corp. - Give Alice
company.billingat Acme Corp (a scope explicitly called out as non-inheritable inpermissions.md- confirm it stays put). - Revoke Alice's
search.readat Acme Corp - confirm nothing else on hercompanyUserrow is affected. - Give Alice
search.readat two different companies she belongs to- confirm
companies[]shows two independent entries.
- confirm
- Bob (not a member of Acme Corp at all) - confirm his permission
object has no
companies[]entry for Acme, not an empty-scopes one.
B. Core resource scopes - team
- Give Alice
search.readonteam_salesspecifically (she's a member). - Give Alice
integration.readonteam_sales, scoped only to that team, while she has no scopes onteam_marketing, a different team in the same company. - Give Alice a scope on
team_salesvia company-widesearch.readthat's marked inheritable - confirm it surfaces underteams[]for every team in the company, not just ones she's an explicit member of. - Revoke Alice's only scope on
team_sales- confirm theteamUsermembership row isn't affected, only thegrantrow is. - Remove Alice from
team_salesentirely (delete theteamUserrow) - confirm every scope she had there disappears, not just the ones explicitly checked.
C. Core resource scopes - collection
- Give Alice
collection.manageoncol_payroll_expensespecifically. - Give Alice
search.readon a collection through team membership (the collection is assigned toteam_salesviacollectionTeam, Alice is onteam_sales) - no direct grant on the collection at all. - Give Alice access to a collection via a
DirectGrant, with no team or company relationship involved at all - the contractor case. - A collection assigned to two different teams - confirm a member of
either team sees it in
collections[], and revoking thecollectionTeamassignment from one team doesn't affect the other.
D. Core resource scopes - userIntegration
- Give Alice
integration.readon one specific self-service integration she owns (userIdset,companyIdnull) - confirm ownership alone doesn't imply the scope; it still needs a grant, or does it? (Flag if ownership vs. access isn't resolved for the owner's own read access.) - Give Alice
integration.readon an admin-provisioned integration (companyIdset,userIdnull - not yet matched to a Yew account). - Same as 16, but for a row that has been matched (
userIdandcompanyIdboth set) - confirm both the owning user and anyone else with team/company-level access can each independently reach it. - Give Alice access to one integration via Collection → Team chain,
three hops deep - confirm
canPerformresolves it without a direct grant anywhere close to the integration itself.
E. Core resource scopes - embeddingConfig
- Give Alice
embeddingConfig.readat the company level (the default case per the earlier discussion - most access is company-wide). - Give Alice access to exactly one embedding config via
DirectGrant, with no broader company-level embedding-config access at all - the "one config to edit" case named explicitly when this was proposed. - Revoke Alice's company-wide
embeddingConfig.read- confirm herDirectGrant-based single-config access from scenario 20 survives independently.
F. Join-table scopes - companyUser ("all teams" territory starts here)
- Give Alice
companyUser.createat Acme Corp - she can add new members to the company itself. - Give Alice
teamUser.createat the company level - can she now add members to every team in the company? (This is the exact propagation-into-join-table-scopes question flagged as open - answer depends on resolving it first.) - Revoke Alice's
companyUser.create- confirm existing company memberships she already created aren't retroactively affected. - Give Bob
companyUser.createat Acme but notcompany.billing- confirm he can add members but not see/change billing.
G. Join-table scopes - teamUser (the "one team" half of the pair)
- Give Alice
teamUser.createonteam_salesspecifically - she can add members to that one team, notteam_marketing. - Confirm scenario 26's grant is anchored to the team's id, per the
.create-scope parent-context rule, not to any existingteamUserrow (there may be none yet for a brand new team). - Give Alice
teamUser.deleteonteam_sales- she can remove members from that team. Confirm this is a separate grant from.create(one row per scope, per the durable-storage design). - Alice has
teamUser.createonteam_salesonly - attempt (and confirm rejection of) her adding a member toteam_marketing. - Compare scenario 26 (one team) against scenario 23 (company-wide) side by side once 23 is resolved - this pair is the user's own motivating example for this whole exercise.
H. Join-table scopes - collectionTeam (the two-sided ambiguity)
- Give Alice permission to share any collection with
team_salesspecifically - anchored to the team side. - Give Alice permission to share
col_payroll_expensespecifically with any team - anchored to the collection side. - Confirm scenarios 31 and 32 require two different answers to the
still-open "which side anchors
collectionTeam.create" question - they can't both resolve to the same grant shape. - Revoke a
collectionTeamassignment (the collection is no longer shared with that team) - confirm this is different from revoking the permission to create such an assignment (scenario 31/32).
I. Join-table scopes - collectionUserIntegration
- Give Alice permission to add any integration to
col_payroll_expensespecifically (collection-anchored). - Give Alice permission to add
ui_finance_driveto any collection (integration-anchored) - same two-sided ambiguity as H, different table. - Remove
ui_finance_drivefrom a collection - confirm it's still directly readable if Alice also has aDirectGranton it.
J. Join-table scopes - userIntegrationEmbeddingConfig
- Give Alice permission to attach
ec_openai_defaultto any integration she hasintegration.updateon. - Give Alice permission to attach any embedding config to
ui_finance_drivespecifically. - Confirm attaching a config (scenario 38/39) requires both
integration.updateon the integration andembeddingConfig.readon the config, per the two-independent-checks rule already stated inpermissions.mdsection 2.
K. Join-table scopes - DirectGrant
- Give Alice a
DirectGrantscoped tointegration.readon one integration, with zero team/company relationship - the founding example, confirm it still works cleanly end to end. - Give Alice
DirectGrant.create- can she create one-off grants for other people, or only receive them? (Not addressed anywhere yet.) - Revoke a
DirectGrant- confirm it doesn't touch anything inteams[]/companies[], since it was never routed through either. - Two
DirectGrants for the same user on the same resource with different scopes - confirm both surface, not just the most recent.
L. Propagation and inheritance
- Give Alice
integration.readat the company level, marked inheritable - confirm it appears for every integration under that company, including ones added after the grant was created. - Give Alice
company.billing, marked non-inheritable - confirm it does not appear on any team/collection/integration beneath it. - Give Alice an inheritable scope at the team level (not company) - confirm it propagates to collections/integrations under that team, not just company-level grants.
- A scope inheritable from company level and also directly granted at the integration level with different scopes - confirm both are present (additive, not one overriding the other).
- Change a scope's registry entry from inheritable to non-inheritable - what happens to already-computed cache entries that assumed the old behavior? (Likely a cache-invalidation-at-scale question, not addressed yet.)
M. Revocation
- Revoke one scope from a principal who has three scopes on the same resource - confirm only that one row is soft-deleted, the other two survive untouched.
- Revoke a company-level grant that was propagating down to twelve integrations - confirm all twelve lose access on the next check, not before (cache staleness window).
- Revoke access, then immediately re-grant the same scope - confirm
the unique constraint (
joinType, joinId, scope, active rows only) allows a new row after the old one's soft-deleted. - A user leaves a company (their
companyUserrow is deleted) - confirm every downstream scope they had (team, collection, integration) is gone, including ones derived via propagation.
N. Overlapping and conflicting grants
- Alice has
search.readvia team membership and a separateDirectGrantfor the same integration with the same scope - confirm this doesn't produce a duplicate or an error, just one effective scope. - Alice has
integration.readvia Team A's collection assignment and no access via Team B, but is a member of both teams - confirm access is still granted (any path succeeding is enough). - Two different admins independently grant Alice the same scope on the same resource via two different paths (one direct, one via team) - revoke one - confirm the other still holds.
O. Cross-company isolation
- Alice is a member of Company A only - confirm she has zero entries anywhere in her permission object referencing Company B's teams, collections, or integrations, even ones with identical names.
- An admin of Company A attempts to grant access to a resource owned by
Company B - confirm
GrantService's write-time validation (section 2, "confirming both belong to a consistent tenant/company") actually rejects this. - Two companies each have a team named
Sales- confirm no accidental collision in scope resolution; team identity is by id, never by name.
P. Cascade delete and orphan handling
- Delete a team with active
teamUserandcollectionTeamrows - confirm both are cleaned up, plus anygrantrows pointing at them or at the team itself (the parent-anchored.create-scope gap flagged as still open). - Delete a company - confirm the cascade reaches all the way down to integrations and embedding configs it owned, not just its direct teams.
- Delete a
UserIntegrationthat has aDirectGranton it - confirm theDirectGrantrow is cleaned up too (already listed in the cascade checklist) - and confirm the scope-registry validation would have rejected creating a newDirectGrantagainst a nonexistent integration in the first place. - Simulate a
grantrow left orphaned by a bug (points at ajoinIdthat no longer exists) - what doescanPerformdo when it encounters this: fail closed (deny), fail open (allow), or error? (Not specified anywhere yet - a real gap.)
Q. API keys (Principal question)
- Grant an API key
search.readon one specific integration - confirm/deny this is even possible under the current schema (it isn't, per the open Principal question -teamUser.userIdetc. are real FKs touser.idspecifically). - An API key created by Alice, later granted access independently of her - Alice's own access is later revoked - confirm the API key's access is unaffected (first-class principal, not a delegation).
- Revoke an API key entirely - confirm cascade cleanup reaches everything it was granted, the same as deleting a user.
R. Privilege escalation
- Alice has
teamUser.createonteam_salesonly - attempt to have her grant herselfteam.manageonteam_sales- confirm this is rejected (the still-open "actor must already hold what they're granting" question from the earlier review - flagged, not answered). - Alice has broad
teamUser.createat company level (once scenario 23 is resolved) - attempt to have her create a new team and immediately grant herself company-admin-level scopes on it - confirm whether this is possible and whether it should be. - An admin grants a scope they don't personally hold, using an "admin override" style broad permission - confirm whether this project's additive-only model even has a concept of "override," or whether admins simply hold the union of everything (likely, but not stated explicitly anywhere).
Gaps found (second pass - working scenarios end to end)
Working through representative scenarios from each category - actually
trying to write the GrantService call and the before/after object
state, not just posing the question - surfaced eight gaps. Three are new
sharpenings of existing open questions in permissions.md; five are new,
not previously named anywhere in that document.
1. Ownership does not imply access - and nothing says it should (new)
Scenario 15, worked through: Alice creates her own self-service Gmail
integration (user_integration.userId = alice, companyId = null).
canPerform({type:'user', id:'alice'}, 'integration.read', {type:'userIntegration', id:'ui_alice_gmail'})
Walking section 2's "Ownership is not part of this system" literally: a
userIntegration row's userId column is ownership, not access.
Nothing in the document creates a grant row when a UserIntegration is
created. So canPerform has nothing to find - userIntegrations: []
stays empty, and the person who just authorized their own Gmail account
cannot read it. That's almost certainly not the intended behavior.
Before: { "userIntegrations": [] }
After: unchanged - nothing in the current design writes a grant here.
Gap: the document never states that creating an owned resource
produces a baseline grant for the owner. This needs to be explicit -
most likely UserIntegrationController.createOne() (and the equivalent
for every user-owned resource) writes a grant row for the owner as part
of the same transaction that creates the row, the same way the six
cascade-delete call sites in section 2 are enumerated explicitly rather
than left implicit. Without this, ownership and access silently diverge
for the single most common case in the system - a person looking at
their own stuff.
2. Company-wide join-table scopes may not be expressible at all (sharpens an existing open question)
Scenario 23, worked through: give Alice the ability to add members to every team in Acme Corp, not just one.
The natural-sounding request - "grant teamUser.create at the company
level" - runs into the registry rule from section 2 directly: a scope's
legality is validated against its joinType, and teamUser.create's
joinId is defined to be a team's id (the parent-context rule).
There is no companyUser-typed grant row whose scope could be
teamUser.create, because teamUser.create was never declared legal for
joinType: "companyUser" - and if it were, GrantService still has no
defined way to resolve "a scope on this company's companyUser row"
into "therefore this scope applies to every teamUser row under every
team in this company," because the propagation rule (section 2,
"Propagation") is written in terms of resource scopes flowing down
the resource hierarchy (company -> team -> collection ->
integration), not join-table scopes flowing down it.
Before: no representation possible.
After: N/A.
Gap: this is more than "propagation for join-table scopes isn't
decided yet" (the existing open-questions wording). As written, the
schema has no joinType/scope combination that means "company-wide
teamUser.create." Fixing this needs one of: (a) extending the registry
so join-table scopes can be granted at an ancestor's joinType and
defining a propagation rule for that case, or (b) accepting that
join-table scopes are always single-instance-scoped, and "all teams"
requires granting teamUser.create on every existing teamUser-anchor
individually (and on every future team too, which raises its own
problem - a new team wouldn't retroactively get Alice's grant). Neither
option is implied by the current text; this needs an explicit decision,
not just a "propagation, TBD" flag.
3. Removing a single membership row isn't in the cascade-delete checklist (new)
Scenarios 10 and 53, worked through: TeamUserController removes one
team_user row (a person leaves/is removed from a team) without the
team or the user being deleted.
Section 2's cascade-delete checklist enumerates six call sites, all keyed
to deleting an owning resource (UserService.deleteOne(),
TeamService.deleteOne(), etc.). None of them is "a thin structural
controller (TeamUserController, CompanyUserController,
CollectionTeamController, CollectionUserIntegrationController)
deletes one row without its parent being deleted." But per section 2's
own parent-context rule, a grant row can have
joinType: "teamUser", joinId: <that team_user row's id> (e.g.
search.read derived from team membership, or teamUser.create/.delete
granted to that specific member). If the team_user row is deleted
without also purging those grant rows, they orphan silently - the exact
failure mode section 2 says ON DELETE CASCADE was rejected to prevent,
except here it happens through the application path that was supposed
to be the safe one.
Before: team_user row exists; grant rows with
joinType: "teamUser", joinId: <row.id> exist.
After (naive delete): team_user row gone; grant rows still
present, now pointing at nothing.
Gap: the cascade-delete checklist needs a seventh category - not
"resource deleted," but "structural relationship row deleted directly" -
covering all four thin CRUD controllers, not just the six owning-resource
services.
4. DirectGrant.create has no valid parent context (new)
Scenario 42, worked through: can Alice create DirectGrants for
other people?
Every other .create scope in section 2 resolves its joinId to a
parent - teamUser.create -> the team, companyUser.create -> the
company. DirectGrant was deliberately kept polymorphic specifically
because it has no natural group or hierarchy underneath it (section 2,
"structural join tables"). That same property means it has no parent to
anchor directGrant.create to either - there's no row, team, or company
that "owns" the concept of one-off grants in general.
Before/after: can't be constructed - there's no legal joinId for
this scope under the current rule.
Gap: the .create-scope parent-context rule (section 2) implicitly
assumes every joinType has a parent. DirectGrant is a real
counterexample. Likely resolution: directGrant.create has to live in
global.scopes (no joinId at all, company-wide or system-wide), which
means the permission object's "every join table gets a property, same
shape" rule (section 3) already has an unstated exception before it's
even built.
5. Orphaned grant rows have no defined resolution behavior (new)
Scenario 63. If gap #3 above happens (or any other bug leaves a
grant row's joinId pointing at nothing), what does canPerform do
when it encounters that row during a hierarchy walk?
Gap: not stated anywhere. Three real options - fail closed (treat as no grant, safest default), fail open (treat as unrestricted, actively dangerous), or throw/alert (surfaces the bug loudly but risks turning a data hygiene issue into a user-facing 500). The document should state this explicitly rather than leaving it to whoever implements the resolution query first. Recommend fail-closed and log/alert - silent fail-closed alone would hide the underlying cascade bug indefinitely.
6. Registry inheritability changes have no cache-invalidation path (new)
Scenario 49. Section 4's cache-invalidation story is entirely keyed
to grant()/revoke() calls - "on success, GrantService invalidates
its own internal cache entries for every affected principal." Flipping a
scope from inheritable to non-inheritable (or back) in the registry is a
code/config change, not a call to either method.
Gap: every already-cached principal who has that scope keeps the old propagation behavior baked into their cached entries until something else invalidates them - and nothing in section 3 or 4 says what that something is. This needs either a cache TTL independent of grant()/revoke() invalidation, or a documented rule that registry changes require a full cache flush on deploy.
7. Bare membership with no grants is ambiguous in the object shape (new)
Scenario 9. Alice is a member of team_sales (a team_user row
exists) but has never been granted any scope on it directly, and nothing
propagates to her from above.
Gap: does she appear in teams[] as
{ resourceId: 'team_sales', scopes: [] }, or does she not appear at
all? Section 3's comment says entries are "resolved by walking the
structural tables and the grant rows attached to them," which reads as
grant-driven - implying no entry at all for a scope-less membership. But
that means teams[] cannot answer "what teams does this user belong to"
(a real, likely-needed UI question - team pickers, "leave team" screens)
- only "what teams does this user have some scope on." If the frontend needs the former, it's not this object; that's worth stating rather than discovering when someone tries to render a team-membership list from it.
8. Company-wide inheritable grants may need to materialize one entry per descendant resource (scale note, not a correctness gap)
Scenario 8. A company-wide, inheritable search.read grant, for a
company with hundreds of teams/collections/integrations, has to surface
as an entry per resource in the flat teams[]/collections[]/
userIntegrations[] arrays (per section 3's uniform-shape comment) for
canPerform/getAccessQuery to resolve single-resource checks without
re-walking the hierarchy every time.
Note: not wrong, but worth flagging next to the existing "indexing
strategy for the grant table" open question - that item covers indexing
the durable grant table; this is about the cache's entry count
potentially being O(resources in company) per broad grant, for every
principal who holds one. Whether that's fine (cache is scoped per
principal, rebuilt lazily) or needs a smarter representation (e.g. an
explicit "unrestricted within this company" marker instead of an
enumerated list) is a real design choice once real company sizes are
known.
Still to determine
- Whether this list needs to grow toward ~100 with more granular variations per category, or whether the coverage above (69 questions + 8 worked gaps spanning every category) is already sufficient and the rest would just be repetition.
- The remaining scenarios (all except 8, 9, 10, 15, 23, 42, 49, 53, 63) still need their own worked answers - this pass targeted the ones most likely to surface gaps, not full coverage yet.
- Scenarios 23, 27, 30, 31-33, 36 all depend on resolving open questions
in
permissions.mdbefore they can be fully answered - gap #2 above shows 23 specifically may need a design decision, not just a default value, before it can be answered at all.