Skip to main content

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 in permissions.md against real cases rather than abstract rules. Every scenario should eventually get a concrete answer: the GrantService call(s) that implement it, and the permission object's affected slice before and after. Where a scenario can't be answered without resolving one of permissions.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​

  1. Give Alice search.read company-wide at Acme Corp.
  2. Give Alice company.billing at Acme Corp (a scope explicitly called out as non-inheritable in permissions.md - confirm it stays put).
  3. Revoke Alice's search.read at Acme Corp - confirm nothing else on her companyUser row is affected.
  4. Give Alice search.read at two different companies she belongs to
    • confirm companies[] shows two independent entries.
  5. 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​

  1. Give Alice search.read on team_sales specifically (she's a member).
  2. Give Alice integration.read on team_sales, scoped only to that team, while she has no scopes on team_marketing, a different team in the same company.
  3. Give Alice a scope on team_sales via company-wide search.read that's marked inheritable - confirm it surfaces under teams[] for every team in the company, not just ones she's an explicit member of.
  4. Revoke Alice's only scope on team_sales - confirm the teamUser membership row isn't affected, only the grant row is.
  5. Remove Alice from team_sales entirely (delete the teamUser row) - confirm every scope she had there disappears, not just the ones explicitly checked.

C. Core resource scopes - collection​

  1. Give Alice collection.manage on col_payroll_expense specifically.
  2. Give Alice search.read on a collection through team membership (the collection is assigned to team_sales via collectionTeam, Alice is on team_sales) - no direct grant on the collection at all.
  3. Give Alice access to a collection via a DirectGrant, with no team or company relationship involved at all - the contractor case.
  4. A collection assigned to two different teams - confirm a member of either team sees it in collections[], and revoking the collectionTeam assignment from one team doesn't affect the other.

D. Core resource scopes - userIntegration​

  1. Give Alice integration.read on one specific self-service integration she owns (userId set, companyId null) - 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.)
  2. Give Alice integration.read on an admin-provisioned integration (companyId set, userId null - not yet matched to a Yew account).
  3. Same as 16, but for a row that has been matched (userId and companyId both set) - confirm both the owning user and anyone else with team/company-level access can each independently reach it.
  4. Give Alice access to one integration via Collection → Team chain, three hops deep - confirm canPerform resolves it without a direct grant anywhere close to the integration itself.

E. Core resource scopes - embeddingConfig​

  1. Give Alice embeddingConfig.read at the company level (the default case per the earlier discussion - most access is company-wide).
  2. 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.
  3. Revoke Alice's company-wide embeddingConfig.read - confirm her DirectGrant-based single-config access from scenario 20 survives independently.

F. Join-table scopes - companyUser ("all teams" territory starts here)​

  1. Give Alice companyUser.create at Acme Corp - she can add new members to the company itself.
  2. Give Alice teamUser.create at 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.)
  3. Revoke Alice's companyUser.create - confirm existing company memberships she already created aren't retroactively affected.
  4. Give Bob companyUser.create at Acme but not company.billing - confirm he can add members but not see/change billing.

G. Join-table scopes - teamUser (the "one team" half of the pair)​

  1. Give Alice teamUser.create on team_sales specifically - she can add members to that one team, not team_marketing.
  2. Confirm scenario 26's grant is anchored to the team's id, per the .create-scope parent-context rule, not to any existing teamUser row (there may be none yet for a brand new team).
  3. Give Alice teamUser.delete on team_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).
  4. Alice has teamUser.create on team_sales only - attempt (and confirm rejection of) her adding a member to team_marketing.
  5. 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)​

  1. Give Alice permission to share any collection with team_sales specifically - anchored to the team side.
  2. Give Alice permission to share col_payroll_expense specifically with any team - anchored to the collection side.
  3. 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.
  4. Revoke a collectionTeam assignment (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​

  1. Give Alice permission to add any integration to col_payroll_expense specifically (collection-anchored).
  2. Give Alice permission to add ui_finance_drive to any collection (integration-anchored) - same two-sided ambiguity as H, different table.
  3. Remove ui_finance_drive from a collection - confirm it's still directly readable if Alice also has a DirectGrant on it.

J. Join-table scopes - userIntegrationEmbeddingConfig​

  1. Give Alice permission to attach ec_openai_default to any integration she has integration.update on.
  2. Give Alice permission to attach any embedding config to ui_finance_drive specifically.
  3. Confirm attaching a config (scenario 38/39) requires both integration.update on the integration and embeddingConfig.read on the config, per the two-independent-checks rule already stated in permissions.md section 2.

K. Join-table scopes - DirectGrant​

  1. Give Alice a DirectGrant scoped to integration.read on one integration, with zero team/company relationship - the founding example, confirm it still works cleanly end to end.
  2. Give Alice DirectGrant.create - can she create one-off grants for other people, or only receive them? (Not addressed anywhere yet.)
  3. Revoke a DirectGrant - confirm it doesn't touch anything in teams[]/companies[], since it was never routed through either.
  4. 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​

  1. Give Alice integration.read at the company level, marked inheritable - confirm it appears for every integration under that company, including ones added after the grant was created.
  2. Give Alice company.billing, marked non-inheritable - confirm it does not appear on any team/collection/integration beneath it.
  3. 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.
  4. 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).
  5. 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​

  1. 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.
  2. 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).
  3. 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.
  4. A user leaves a company (their companyUser row is deleted) - confirm every downstream scope they had (team, collection, integration) is gone, including ones derived via propagation.

N. Overlapping and conflicting grants​

  1. Alice has search.read via team membership and a separate DirectGrant for the same integration with the same scope - confirm this doesn't produce a duplicate or an error, just one effective scope.
  2. Alice has integration.read via 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).
  3. 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​

  1. 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.
  2. 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.
  3. 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​

  1. Delete a team with active teamUser and collectionTeam rows - confirm both are cleaned up, plus any grant rows pointing at them or at the team itself (the parent-anchored .create-scope gap flagged as still open).
  2. Delete a company - confirm the cascade reaches all the way down to integrations and embedding configs it owned, not just its direct teams.
  3. Delete a UserIntegration that has a DirectGrant on it - confirm the DirectGrant row is cleaned up too (already listed in the cascade checklist) - and confirm the scope-registry validation would have rejected creating a new DirectGrant against a nonexistent integration in the first place.
  4. Simulate a grant row left orphaned by a bug (points at a joinId that no longer exists) - what does canPerform do when it encounters this: fail closed (deny), fail open (allow), or error? (Not specified anywhere yet - a real gap.)

Q. API keys (Principal question)​

  1. Grant an API key search.read on one specific integration - confirm/deny this is even possible under the current schema (it isn't, per the open Principal question - teamUser.userId etc. are real FKs to user.id specifically).
  2. 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).
  3. Revoke an API key entirely - confirm cascade cleanup reaches everything it was granted, the same as deleting a user.

R. Privilege escalation​

  1. Alice has teamUser.create on team_sales only - attempt to have her grant herself team.manage on team_sales - confirm this is rejected (the still-open "actor must already hold what they're granting" question from the earlier review - flagged, not answered).
  2. Alice has broad teamUser.create at 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.
  3. 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.md before 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.