Skip to main content

Permissions

Status: proposed design, not yet implemented. This describes the planned V2 permission system (see docs/docs/roadmap/detailed-roadmap.md

  • permissions is the prerequisite unlock for the MCP server and conversational assistant). Nothing in this document is live in the codebase today. It's written here rather than in plans/ because it's intended to become the reference doc once built, not just a reasoning trail - but until it's implemented, treat every code sample as a proposal, not current behavior.

This document reflects three deliberate architecture pivots. (1) Earlier drafts proposed one dedicated, foreign-key-constrained join table per relationship type - replaced with a single, generalized permission_grant table, because the resource graph's future shape wasn't known. (2) That generalized table turned out to hide the resource hierarchy from the system entirely, so a grant on a Company couldn't propagate down to its Teams - nothing declared which resource types contained which others. Fixed by splitting storage in two: real, FK-constrained structural tables for the hierarchy (section 2), and a generalized grant table that attaches scopes to a structural relationship rather than connecting two raw resources directly. (3) The resulting permission object was moved entirely behind GrantService - no other service reads it directly, only two methods (section 3). If you're looking for any earlier design, it's in this file's git history, not here.

This document is about authorization - what a given principal is allowed to do. It's a different concern from authorization.md, which (despite the filename) is actually about authentication - cookie-based sessions, how a request proves who it's from. This document assumes that question is already answered and covers what happens next: given a known principal, what can they see or do.

1. API security: no cross-resource joins, ever​

Rule: no API in this system may join, populate, or resolve a foreign key into another resource's data, at any depth. An endpoint returns its own resource's fields plus foreign key IDs as plain strings - never the referenced object, expanded or embedded.

Why this is a permission rule, not just a style rule: every resource is responsible for its own authorization check, and only its own. If a child resource's API were allowed to join in its parent's data, the parent's fields would be returned to a caller whose permissions were only ever checked against the child - the parent's own authorization gate never ran. Once that's allowed once, "does this user have access to X" no longer has one answer; it depends on which path was used to reach X. Banning joins entirely removes that ambiguity structurally, not by convention: a resource's API has no code path capable of returning another resource's data, so there's nothing to review for later.

Concrete example: UserIntegrationContent has a foreign key to UserIntegration, which can be shared into a team via a Collection - UserIntegration → Collection → Team, not owned by a team directly.

  • GET /v1/user-integration-content/:id returns userIntegrationId as a plain string. It does not return the integration's name, which collections it's in, or anything else about it - even though that data is one lookup away and would be convenient to include.
  • To find out anything about that integration, the caller makes a separate call: GET /v1/user-integration/:id. That call re-checks permissions independently - against the integration's own authorization logic, not "well, they could already see a piece of its content."
  • This holds at every hop. Reading a Collection never returns its Teams' fields inline; reading a Team never returns its Company's fields inline.

Accepted cost: a caller walking content → integration → collection → team → company makes five separate, independently-authorized requests instead of one joined query. That's the price of the guarantee that access never travels across a foreign key implicitly.

This rule holds regardless of how relationships are stored (section 2) - it's about API surface, not schema.

2. Storage: real structural relationships, plus a generalized grant table attached to them​

Why this is a second pivot, not a rename​

The previous draft of this section put everything - both the resource hierarchy (Team belongs to a Company, an Integration belongs to a Collection) and the flexible access layer (who currently has what scope) - into one generalized permission_grant table, keyed by raw (subjectType, subjectId, resourceType, resourceId). That solved the schema-proliferation problem, but it created a new one: nothing in the system declared which resource types contain which others, so a broad grant on a Company couldn't propagate down to the Teams and Collections inside it - there was no graph to walk, only disconnected facts. See docs/docs/what-we-picked/why-generalized-permissions.md for the fuller reasoning trail; this section supersedes its storage description.

The fix separates two things that got conflated:

  • Structure - which resource types contain or compose which others. This is close to the stable core of what Yew is; it doesn't change often. Modeled as real, FK-constrained join tables again, so it's an actual traversable graph, not a claim.
  • Access - who currently has what scope. This is the genuinely uncertain, evolving part. Stays generalized, but now attaches to a structural relationship instead of connecting two raw resources directly.

Structural join tables - real foreign keys, no scopes​

TableRepresentsKey fields
company_userA user is a member of a company.id, userId → user.id, companyId → company.id
team_userA user is a member of a team.id, userId → user.id, teamId → team.id
collection_teamA collection is assigned to (shared with) a team.id, teamId → team.id, collectionId → collection.id
collection_user_integrationAn integration is placed in a collection.id, collectionId → collection.id, userIntegrationId → user_integration.id
user_integration_embedding_configAn embedding config produces embeddings for an integration.Already exists, unchanged - see section on ownership below.

None of these carry a scopes column - they only assert that a relationship exists, with real, database-enforced foreign keys and standard soft-delete (deletedAt). What a relationship grants is a separate concern, attached via the grant table below.

This is also what makes hierarchy propagation possible at all: Company → Team → Collection → UserIntegration is now an actual chain GrantService can walk with real joins (team.companyId, then collection_team, then collection_user_integration) to compute, for example, everything a company-wide grant should cover - not something it has to be told about resource-by-resource.

DirectGrant is the one deliberate exception, kept polymorphic on purpose:

TableRepresentsKey fields
direct_grantA one-off relationship with no natural group or hierarchy underneath it - sharing one integration with a contractor on no team, the case that started this whole line of the design.id, userId → user.id, resourceType, resourceId

Every other relationship in the system has an obvious structural home; this one deliberately doesn't, which is exactly why it's the one place a raw polymorphic reference is still the right call rather than a compromise.

The grant table - a scope attached to a structural relationship​

FieldTypeNotes
iduuid
joinTypestringWhich structural table joinId points into - "companyUser", "teamUser", "collectionTeam", "collectionUserIntegration", "userIntegrationEmbeddingConfig", "directGrant".
joinIduuidThe specific row's id in that table.
scopestringOne scope per row, not an array - see "Why one scope per row" below.
provenancestring"manual" today; a future "synced:google-workspace" etc.
createdAt/updatedAt/deletedAttimestampsStandard soft-delete. createdAt already records when a scope was granted; a separate grantedAt would be redundant.

Unique constraint: one active row per (joinType, joinId, scope) where deletedAt IS NULL.

Notice what's not here anymore: no subjectType/subjectId at all. Who a grant applies to is entirely implied by whichever structural row joinId references - a teamUser row's subject is inherently a user, by construction, because that's what the team_user table's own foreign key says. This also closes a real gap the previous draft had: it never formally added "team" as a valid grant subject even though an example in this document already depended on one (a team being granted access to a collection) - that inconsistency can't happen anymore, because subjects were never a thing the grant table declared in the first place.

Why one scope per row​

An array can only tell you that a set of scopes changed at some point - it can't tell you which scope was added or removed, or when. One row per scope makes every grant and every revocation its own auditable fact: soft-deleting a single row records exactly when that one scope was removed, independent of any other scope still active on the same relationship. This is also most of the way toward the "audit logs (all actions logged)" requirement already planned for V4 - a side effect of choosing this shape now, not separate work later.

Scopes on the join tables themselves, not just what they imply​

Every table in the system gets its own scope namespace - not just the resource tables (company, team, collection, ...) but the structural join tables too (companyUser, teamUser, collectionTeam, collectionUserIntegration). A grant on a teamUser-typed row can carry search.read (what a team member can do because they're on the team) or teamUser.create (who's allowed to add a new member to that team) - two different kinds of scope, same joinType, genuinely different meaning.

This creates a real wrinkle worth stating precisely, not glossing over: joinId means "the specific existing row" for scopes about an established relationship (search.read on an existing teamUser row). It can't mean that for a .create scope - there's no row yet, that's the point of asking whether one can be created. For .create (and by extension .delete) scopes, joinId instead refers to whichever side of the relationship is the natural parent context the new row would be created within:

  • teamUser.create → joinId is the team's id (adding a member to a team).
  • companyUser.create → joinId is the company's id, same reasoning.
  • collectionTeam.create, collectionUserIntegration.create - genuinely ambiguous, not resolved here. Sharing a collection with a team could reasonably be scoped either by "who can add collections to this team" or "who can share this collection with any team" - different permission models, not just a naming choice. See open questions.
  • directGrant.create - a different problem than the two above, not an anchoring choice at all. DirectGrant was deliberately kept polymorphic, with no natural group or hierarchy underneath it - which means it also has no parent to anchor a .create scope's joinId to. Resolved: directGrant.create lives in global.scopes (no joinId), same as any other scope with no structural home. This is why the permission object's join-table properties (section 3) aren't fully uniform - directGrants is the one exception without a { resourceId, scopes } shape for its own .create scope, since there's no resource for that scope to be anchored to.

Referential integrity, restated​

The structural tables now have real, database-enforced foreign keys - that part of the original integrity concern is fully resolved, not just mitigated. The grant table still has one polymorphic field (joinType), so it still can't have a single database-level foreign key for joinId - but what it needs to validate is narrower and safer than before: only "does a row of this specific type and id exist," a single lookup against a known table, not "does an arbitrary resource of an arbitrary type exist across the whole system."

  • A code-level registry (extending the already-planned central scope registry) defines which scopes are valid for each joinType - team.manage is valid for a teamUser grant, a nonsensical scope for that relationship type is rejected at write time.
  • GrantService is the only thing that ever reads or writes the structural tables and the grant table. No other service touches them directly.
  • GrantService still performs, at write time, what a cross-table foreign key can't: confirming joinId actually resolves to a real row of the stated joinType, confirming the scope is legal for that relationship type. Concentrated in one service, not duplicated - narrower work than the previous draft required, since the structural side no longer needs it at all.

Propagation: which scopes travel down the hierarchy​

Structure being real and traversable makes propagation possible; it doesn't automatically decide which scopes should use it. Not every scope granted at a company level obviously belongs on every integration underneath it. Default assumption, not yet fully settled: scopes marked inheritable in the scope registry propagate downward through the real structural chain (company → team → collection → integration); scopes that don't make sense below their own level (e.g. company.billing) are marked non-inheritable and stay put. This needs to be an explicit property of each scope in the registry, not an implicit assumption GrantService makes on its own - see open questions.

Ownership is not part of this system​

Not everything becomes a grant. There's a real distinction between ownership (a fixed, structural, 1:many property of a resource - who created it, who's billed for it) and access (a flexible, evolving, many-to-many relationship - who can currently see or use it). Ownership stays exactly what it was in the earlier design: conventional, FK-constrained columns, unaffected by this pivot.

UserIntegrationEntity and EmbeddingConfigEntity only have userId in the codebase today - confirmed by reading both entities directly. EmbeddingConfigEntity ownership moves to embedding_config.companyId (an embedding config usually represents a centrally-billed provider credential, not something an individual employee owns). UserIntegrationEntity needs three nullable fields to cover both integration-authorization models:

  1. Self-service, individually authorized - personal Gmail, personal Notion. The credential can only be created by the account holder.
  2. Admin-provisioned, domain-wide - Google Workspace via domain-wide delegation, a company Slack workspace. One admin action authorizes access to many people's data; concretely, one UserIntegration row per known domain member, not one row for the whole domain.
  • userId - set when this row is known to belong to a specific Yew account.
  • companyId - set when the company holds administrative/revocation authority over the credential (self-service: null; admin-provisioned: set).
  • externalUserId - set when the provider type has an inherent external-identity concept (Gmail: every message has a mailbox owner; FTP: a login credential isn't a person) - independent of the other two.
userIdcompanyIdexternalUserIdMeaning
setnullsetSelf-service Gmail - individual owns it entirely
setnullnullSelf-service FTP - individual owns it, no external identity concept
nullsetsetDomain-wide Workspace row for an employee not yet matched to a Yew account
setsetsetDomain-wide Workspace row matched to a known Yew user
nullsetnullCompany-managed resource with no per-person identity at all
nullnull-Invalid - no owner, must be rejected

Still open: when an unmatched domain-wide row (userId: null) is later matched, does userId get backfilled onto that same row (preserving its content's history)? Backfilling in place is the obvious answer; the actual matching mechanism isn't designed yet.

Also unaffected by this pivot: team.companyId, collection.companyId, and team.parentTeamId (the self-referencing, one-level-capped nesting field) all remain ordinary foreign keys - they're structural ownership, the same category as UserIntegration.companyId, not the flexible access layer.

ApiKeyEntity is new to this document (section 3, "Principals") - plain ownership, api_key.userId, not nullable, no company-provisioned variant like UserIntegration has. One user, many keys.

UserIntegrationEmbeddingConfigEntity (the existing table attaching an embedding config to an integration - already built, not a gap) stays a conventional FK-constrained join table too, for the same reason: it's a fixed composition relationship ("this config produces embeddings for this integration"), not an access relationship that needs to flex as the org grows.

Cascade deletes - one write path, so there's nothing to forget​

The structural tables having real foreign keys is a genuine improvement here, but it comes with a trap worth naming explicitly: Postgres-level ON DELETE CASCADE on the structural tables would silently remove, say, a team_user row when its Team is deleted - without ever running application code. If that happened, any grant row pointing at that team_user.id (via the polymorphic joinId) would be orphaned, because nothing triggered GrantService to clean it up. Deliberate decision: don't use ON DELETE CASCADE on the structural tables, even though it's available. Structural deletion stays application-level, so grant cleanup can never be silently bypassed by a database-level cascade happening outside GrantService's knowledge.

Resolved, previously three contradicting answers in this document: who is actually allowed to write to a structural table. Earlier drafts of this section said resource-owning services delete their own structural rows directly; the API table below once described thin controllers writing them directly too - both conflicted with "Referential integrity, restated," above, which already said GrantService is the only writer. That last statement is the one that stands. Concretely, this means the "six known call sites, don't forget a seventh" risk this section used to describe is gone by construction, not by discipline - there's only one place a structural row is ever written, so there's nothing to forget:

  • Whole-resource deletes (a Team, a Company, ...) call grantService.purgeForResource({ type, id }) - one method that finds and removes every structural row referencing that resource across every structural table, and every grant row attached to any of them, in one transaction. TeamService.deleteOne() calls this once; it doesn't enumerate team_user/collection_team itself anymore.
  • Single-relationship removal (removing one person from one team, unsharing one collection from one team) calls grantService.deleteRelationship(joinType, joinId) - deletes that one structural row and any grant rows pointing at it. This is what closes the gap the old checklist had: removing a single team_user row without deleting the whole Team was never in the six-call-site list, because that list only covered whole-resource deletes.
  • Creating a relationship goes through the matching grantService.createRelationship(joinType, data), for the same reason in reverse - one path, so a creation can't happen without going through whatever validation GrantService needs to do at write time (section 2, "Referential integrity, restated").

Resource-owning services and the thin structural controllers (TeamUserController, CompanyUserController, CollectionTeamController, CollectionUserIntegrationController) call these GrantService methods; none of them run their own INSERT/DELETE against a structural table directly anymore.

Grants on create - the owner gets full access, automatically​

Resolved - previously a real gap. Section 2 already draws a hard line between ownership (a fixed FK column) and access (a grant row). Taken literally, that meant a user who creates their own resource - a self-service integration, a collection - had no grant on it at all, and would fail their own canPerform check against the thing they just made. Decided: whichever resource-owning service creates a row also calls grantService.grant() to give the creator every scope on it, in the same transaction as the insert. Simple on purpose - "the creator gets everything, for now" - not a permission model to design further until a real product need (a narrower default) shows up. Because GrantService already owns every structural write (previous section), this happens naturally wherever createRelationship()/createOne()-style flows are implemented, not as a rule every service author has to remember separately.

New/modified APIs​

Structural relationships and scopes are managed by two different kinds of API now, matching the two different kinds of table:

APINotes
CompanyUserController, TeamUserController, CollectionTeamController, CollectionUserIntegrationControllerThin request handlers - add/remove a relationship. They don't write the structural tables themselves; each one calls grantService.createRelationship()/deleteRelationship() (section 2, "Cascade deletes") after checking the actor's own .create/.delete scope. No scope-grant logic at all; that's GrantController's job, not theirs. This is what keeps these from becoming the "N near-identical implementations" problem the earlier draft was trying to avoid - each one only does a permission check and a delegated call.
GrantController (/v1/grant)The one centralized API for scopes - create/read/delete, filterable by joinType/joinId. Every scope-validation rule lives here exactly once, regardless of which structural table the grant is attached to.
DirectGrantController (/v1/direct-grant)CRUD on DirectGrant rows - the one-off, no-hierarchy case. Scopes on these still go through GrantController (joinType: "directGrant").
CompanyController (/v1/company)Standard CRUD on the resource's own fields (name).
TeamController (/v1/team)Standard CRUD; parentTeamId optional on create.
CollectionController (/v1/collection)Standard CRUD, company-owned.
UserIntegrationController (existing)Add companyId/externalUserId to create/update DTOs (userId already exists).
EmbeddingConfigController (existing)userId → companyId (ownership change, not an addition).
A "my permissions" endpoint (e.g. GET /v1/me/permissions)The frontend needs some way to read the current user's own effective scopes to render UI conditionally - nothing else provides that.

Gaps this raised, and where they stand​

  • Company bootstrap - resolved. Every signup creates a company, always, no exception. A self-hosted/homelab user is simply the sole member and admin of a one-person company - this is why embedding_config.companyId works uniformly for a homelab user and a thousand-person business with no special case.
  • Orphaned resources on revocation - still open. If a grant is deleted, what happens to in-flight state (e.g. a search result someone had open)? Likely "nothing retroactive, just stops being visible on the next permission check," but not decided.
  • The object-builder itself - resolved. This is GrantService (section 3) - the single place structural rows and grant rows get resolved into checkable data, and the only thing ever allowed to do so.
  • The user→company ownership handoff - concrete, not abstract, and not actually a grant. "A user gives their integration to the company" is UserIntegrationController setting user_integration.companyId - an ownership change (see below), separate from GrantController entirely. What's still open is what UI/API action triggers it, and whether it's reversible.
  • Join-table naming applies again, differently than before. The earlier alphabetical-ordering rule (CompanyUser vs. UserCompany) is back in play for the structural tables above - company_user and team_user already follow it; collection_team and collection_user_integration were chosen to follow it too (c before t, c before u).
  • Privilege escalation on grant creation - still open, carried over from the previous review. GrantController still needs to check that an actor granting a scope actually holds that scope themselves, not just that they have some broad management scope on the relevant structural row. Not yet designed.

3. The permission object - internal to GrantService, not exposed​

No service other than GrantService ever reads this object. Earlier drafts of this document had the object attached to the request and read directly by every downstream service (teams[], effectiveScopes, etc. inspected inline). That's been tightened: the object is now purely GrantService's own internal cache, built by joining the structural tables (section 2) with the grant rows attached to them, used to answer the two methods below. Every other service interacts with authorization exclusively through those two methods - never by reading permission data itself. This is a stronger version of the same abstraction-boundary principle already in this document: not just "storage can change without touching read logic," but "the read representation can change too," since nothing outside GrantService depends on its shape at all.

This depends on GrantService staying an in-process NestJS provider, not a separately-deployed service. The two-method pattern below is essentially free (function calls) as an in-process dependency; it becomes two real network round-trips per authorization check if GrantService is ever split out as its own service. Worth stating as an explicit assumption now, so it isn't silently violated by an unrelated future infrastructure decision.

GrantService's two methods​

// Single-resource action checks - "can this principal do X to this
// specific thing" (or company-wide, if resourceType/resourceId are
// omitted). Returns a plain boolean, nothing else.
//
// principal is always { type: 'user'; id: string } - an API-key request
// is resolved to its owning user's id by auth-normalization before this
// is ever called (see "Principals," below). GrantService itself never
// branches on how the principal was authenticated.
canPerform(
principal: { type: 'user'; id: string },
scope: string,
resource?: { type: string; id: string },
): Promise<boolean>

// List/search endpoints - "what can this principal see." Returns a base
// query fragment (an ID allow-list, or an equivalent TypeORM query
// fragment) that the calling service extends with its own additional
// filters (date ranges, text search, pagination) - never replaces.
getAccessQuery(
principal: { type: 'user'; id: string },
resourceType: string,
scope: string,
): Promise<{ unrestricted: true } | { unrestricted: false; resourceIds: string[] }>

The return type is deliberately not just resourceIds: string[] - an empty array and "no restriction at all" (company-wide access) are opposite answers, and collapsing them into the same shape would force every caller to special-case "empty means unrestricted, or does it mean no access?" itself. unrestricted makes that an explicit, unambiguous branch instead.

These are two independent methods, not a required sequence. A single-resource action (delete a team) only ever needs canPerform - there's no query to build. A list endpoint only ever needs getAccessQuery - an empty result is the "no access" answer for filtering purposes, with no separate yes/no call needed first.

The internal cache, still worth documenting​

GrantService still needs some internal representation to make repeated canPerform/getAccessQuery calls fast rather than re-joining the structural tables and grant every time. Unlike the durable storage (one scope per row, for audit granularity - see section 2), this cache can denormalize freely, since it's rebuilt from storage and doesn't need to preserve row-level history itself - grouping every scope a principal has on a given resource together is the natural, efficient shape for lookups.

It carries its own version number, so it can be evolved like an internal API - GrantService itself can branch on version if this internal shape changes, though since nothing outside GrantService depends on it anymore, this matters far less than it did when the object was shared.

{
"version": 1,
"principalId": "usr_01h8z9k2",
"principalType": "user", // always "user" - an API key resolves to
// its owning user before this object is
// ever built; see Principals below
"computedAt": "2026-09-02T02:41:00Z",

// The one exception to the uniform shape below - global scopes have no
// resourceId to key on.
"global": { "scopes": [] },

// Every *resource* table follows the same shape: an array of
// { resourceId, scopes }, and every key is plural - a user can belong
// to more than one company, more than one team, and so on, so there's
// no singular-object special case to remember. Entries here are
// resolved by walking the structural tables and the grant rows
// attached to them at build time, merged regardless of how access was
// derived (a DirectGrant, a Team/Collection resolution, or a scope
// propagated down from a Company-level grant) - checking code only
// needs "do they have it," never "how did they get it."
"companies": [
{ "resourceId": "cmp_acme", "scopes": ["search.read"] }
],
"teams": [
{ "resourceId": "team_sales", "scopes": ["search.read", "integration.read"] }
],
"collections": [
{ "resourceId": "col_payroll_expense", "scopes": ["collection.manage"] }
],
"userIntegrations": [
{ "resourceId": "ui_finance_drive", "scopes": ["integration.read"] }
],
"embeddingConfigs": [
{ "resourceId": "ec_openai_default", "scopes": ["embeddingConfig.read"] }
],
// ApiKey is a resource like the others above (owned via api_key.userId),
// not a join table - listed further down, next to the join-table
// properties, because its scopes are about managing a key, never about
// what the key itself can do (section 3, "Principals").

// Every *join* table gets a property too, same shape, holding scopes
// that govern the relationship itself - not scopes on either side of
// it. "teams_sales has teamUser.create" answers "can this principal
// add a new member to team_sales," entirely separate from whatever
// scopes they have on the team resource itself (above).
"teamUsers": [
{ "resourceId": "team_sales", "scopes": ["teamUser.create", "teamUser.delete"] }
],
"companyUsers": [
{ "resourceId": "cmp_acme", "scopes": ["companyUser.create"] }
],
"collectionTeams": [
{ "resourceId": "team_sales", "scopes": ["collectionTeam.create"] }
],
"collectionUserIntegrations": [
{ "resourceId": "col_payroll_expense", "scopes": ["collectionUserIntegration.create"] }
],
"userIntegrationEmbeddingConfigs": [
{ "resourceId": "ui_finance_drive", "scopes": ["userIntegrationEmbeddingConfig.create"] }
],
// directGrants has no per-resource entries in the normal { resourceId,
// scopes } shape - DirectGrant has no parent to anchor a .create scope
// to (section 2), so directGrant.create lives in global.scopes instead.
// This array still exists for scopes on an *established* DirectGrant
// row (e.g. revoking one you didn't create), keyed by that row's id.
"directGrants": [],

// Not a join table - ApiKey rows belong to exactly one user and always
// inherit that user's live permissions (section 3, "Principals"), so
// there is nothing to grant an ApiKey directly. This array exists for
// scopes *about* a specific key - e.g. an admin's ability to revoke a
// key they didn't create - not for scopes an ApiKey holds itself.
"apiKeys": [
{ "resourceId": "key_finance_bot", "scopes": ["apiKey.revoke"] }
],

// Flattened union of global.scopes + every entry's scopes across every
// array above, resource and join alike. The fast "does this principal
// have X anywhere" check.
"effectiveScopes": ["search.read", "integration.read", "teamUser.create"]
}

Design decisions worth stating explicitly​

  • Scopes are resource.action strings (e.g. integration.read, team.manage). A central scope registry validates that every scope referenced by a route actually exists, and (extended per section 2) that every scope attached to a grant row is legal for that row's joinType, and whether it's inheritable down the structural hierarchy.
  • Additive only, no explicit deny. The effective permission for a scope is "does it appear anywhere in the object," full stop - highest access wins, matching GitHub's model rather than AWS IAM's layered allow/deny evaluation. Revisit only if a real case for a deny path shows up - don't add it speculatively.
  • provenance on every grant, even though nothing writes anything but "manual" today - this is what makes a future permission-sync integration (mirroring a connected source's own ACLs, the way Glean does) additive later rather than a redesign.
  • Nested teams: parentTeamId exists on every team now, always null. When nesting is turned on, it's capped at one level (a team with a parent cannot itself be a parent) - a cycle would require a third hop the cap makes structurally impossible, so no cycle-detection logic is needed.
  • Not included here on purpose: feature/package entitlements. A separate system, checked at a handful of feature-gate boundaries company-wide, not per-resource per-request like this object.

Principals: an API key is a user's key, not a separate principal type​

Resolved - closes what was previously an open question, rather than picking one of its three options. An ApiKey binds 1:many to a User (api_key.userId, a plain FK) and inherits that user's permissions live - it is not an independently-grantable principal, and it is not a snapshot taken at creation time. An API-key-authenticated request resolves, via the same auth-normalization step as a cookie session, to { type: 'user', id: apiKey.userId } - the exact same principal canPerform/getAccessQuery already accept. GrantService never sees an API key as such; by the time a request reaches it, there is no difference between "Alice, logged in" and "Alice, via one of her API keys."

This is what makes the option list the previous draft of this section carried (parallel api_key_team/api_key_company tables, a nullable dual-FK on every structural table, a unified principal table) moot rather than answered - none of it is needed, because an API key never becomes a row in a structural table at all. The tradeoff, named honestly: this is live-binding, not a fixed grant - an API key's access rises and falls with whatever its owning user currently has, which most likely matches expectations ("this key acts as me") but is worth stating explicitly rather than assuming. A user who needs a key with narrower access than their own creates a second, dedicated user for that purpose - a real cost (service accounts show up in member lists, need something for fields like password/session that they'll never use), accepted deliberately rather than building a parallel identity model for a need nothing has demonstrated yet.

Validated against precedent, not just decided in the abstract: this is exactly how Nextcloud's app passwords work - many per user, created by the user themselves, authenticating as that exact account with that account's exact permissions, no independent scoping. Worth borrowing a few of its concrete details when ApiKey is actually built: stored hashed (shown once, never retrievable after creation), individually named and revocable, tracked with last-used IP/timestamp for audit, and auto-expiring rather than living forever by default.

4. Using GrantService: checking a request​

A few concrete examples of how a route calls the two methods from section 3. No route reads permission data directly - every check is one of these two calls.

Example A: reading a single resource by ID​

GET /v1/user-integration/:id - checking integration.read for a specific integration shared into team_sales via a Collection (the owning user or company granted it, an admin put it in a Collection, the Collection is assigned to team_sales):

const allowed = await grantService.canPerform(
{ type: 'user', id: principalId },
'integration.read',
{ type: 'userIntegration', id: integrationId },
);
if (!allowed) throw new ForbiddenError();

canPerform is responsible for resolving every path that could grant this: a DirectGrant on the integration itself; a grant on a collectionTeam row for a Collection this integration is in, assigned to one of the principal's teams; or an inheritable scope granted higher up - on the principal's companyUser row for the company this integration's Collection ultimately belongs to - propagated down through the real structural chain. The caller doesn't know or care which path matched; that resolution, including the hierarchy walk, is entirely internal to GrantService.

Example B: filtering a list/search endpoint​

SearchService.search() - a query can span content from many integrations. getAccessQuery resolves an ID allow-list before the search runs, never joined into the search query itself (see section 1):

const access = await grantService.getAccessQuery(
{ type: 'user', id: principalId },
'userIntegration',
'search.read',
);
if (!access.unrestricted) {
// pass access.resourceIds to Elasticsearch/Postgres as
// inUserIntegrationIds - the same mechanism search.service.ts already
// uses for the request-level inUserIntegrationIds parameter today.
}
// access.unrestricted === true -> admin-style company-wide access,
// skip the filter entirely rather than resolving a redundant allow-list.

A request can optionally narrow further with an explicit inCollectionIds filter (the finance-team example - searching only the "payroll expense" collection) - the calling service intersects that with resourceIds itself; getAccessQuery doesn't need to know about it.

Example C: an action that changes the underlying grants​

A company admin sharing a collection with team_sales - two steps, one structural (does the relationship exist), one scope-based (what does it grant), matching the two kinds of table from section 2. Both steps are GrantService calls now - CollectionTeamController never writes collection_team itself (section 2, "Cascade deletes"):

// 1. Does the actor have collectionTeam.create for this team? (Anchoring
// shown here as team-anchored - still one of the two open options, see
// section 5.)
const allowed = await grantService.canPerform(
{ type: 'user', id: actorId },
'collectionTeam.create',
{ type: 'team', id: 'team_sales' },
);
if (!allowed) throw new ForbiddenError();

// 2. GrantService creates the structural row itself...
const collectionTeam = await grantService.createRelationship('collectionTeam', {
teamId: 'team_sales',
collectionId,
});

// 3. ...and attaches the actual scope to it. Two different scope
// namespaces at play here, deliberately: collectionTeam.create (checked
// above) governs who may create the relationship at all; search.read
// (granted here) governs what team_sales's members can now do because of
// it - see section 2, "Scopes on the join tables themselves."
await grantService.grant({
joinType: 'collectionTeam',
joinId: collectionTeam.id,
scope: 'search.read',
});

grant()/revoke() are the only way any grant row is ever written; createRelationship()/deleteRelationship()/purgeForResource() are the only way any structural row is ever written - all internal to GrantService, same as everything else in section 3. On success, GrantService invalidates its own internal cache entries for every affected principal (every member of team_sales, not just the actor) - the delete-then-recompute pattern: a mutation clears the affected principals' cached data and lets it rebuild lazily on their next canPerform/getAccessQuery call, rather than leaving it stale.

Where the check itself runs: guard vs. inline​

Not every canPerform/getAccessQuery call belongs in the same place in a request's lifecycle - the two methods say what gets checked, not where. Two cases:

  • Coarse checks that need no request-specific data - the resource-omitted form of canPerform(principal, scope) (company-wide or global), or any check whose resourceId is already sitting in the route params with nothing else to compute first. These run as a NestJS guard, before the handler body executes at all - reject fast, no controller logic needs to run to find out the request is unauthorized.
  • Specific checks whose resourceId isn't known yet - it depends on a DB read, a derived value, or combining several inputs (Example A's integration lookup, Example C's collectionTeam.id that doesn't exist until step 2 creates it). These can't be a pre-handler guard because the data the check needs doesn't exist yet; they're an explicit inline call in the controller or service body, at the point the data becomes available.

Rule of thumb: if the check's resourceId is fully determined by the route itself, it's a guard; if it's determined by something the handler has to do first, it's inline. Most single-resource GET/DELETE routes are guard-shaped; most multi-step write flows (Example C) have at least one inline check.

5. Open questions, not yet decided​

Not all of these block writing code. Every route only ever calls canPerform/getAccessQuery (section 3) - as long as that stays true, GrantService's internal answers to most of these can start naive and improve later without touching a single caller. Split into two groups on that basis, rather than treated as equally urgent.

Decide now - these affect correctness or the write-path shape, not just behavior that can improve later​

  • What canPerform/getAccessQuery do when a grant row's joinId no longer resolves to a real row - fail closed (no grant), fail open (unrestricted), or throw. A dangling row shouldn't be possible now that GrantService owns every structural write (section 2, "Cascade deletes"), but "shouldn't happen" isn't the same as "has a defined answer if it does." Recommend fail-closed and log/alert, so a real bug degrades to over-restrictive rather than a silent access leak - but this needs to be a stated rule, not whatever the first implementation happens to do.
  • Whether a bare membership row with no grants attached appears in the permission object at all (section 3) - i.e. does teams[] answer "what teams is this principal on" or only "what teams does this principal have some scope on"? Affects whether the frontend can use this object for membership UI (team pickers, "leave team") or needs a separate call for that.

Safe to default naively and revisit - GrantService's two-method boundary protects every caller from these changing later​

  • Which scopes propagate down the structural hierarchy and which don't (section 2, "Propagation") - v0 default: nothing propagates, every grant applies only to the exact structural row it's attached to. Turning propagation on later is a GrantService-internal change.
  • Which side of a two-parent join table anchors a .create scope's joinId, and whether join-table scopes can be granted above a single instance at all (section 2, "Scopes on the join tables themselves") - collectionTeam.create and collectionUserIntegration.create could reasonably be scoped by either side, and even once a side is picked, there's currently no way to express "this scope, for every team in the company" the way a plain resource scope can via propagation - a join-table scope only ever applies to one instance. v0 default: single-instance only, no company/team-wide variant, for every join-table scope including teamUser.create/companyUser.create (which don't have the anchoring ambiguity, but do have this same single-instance limit today).
  • The exact shape/location of the scope-legality registry (which scopes are valid for each joinType, and which are inheritable) - v0 default: plain TypeScript config, no introspectable table.
  • Indexing/performance strategy for the grant table, and how much the cache's per-principal entry count grows for a broad, inheritable grant in a large company (moot until propagation, above, is turned on)
    • a composite index on (joinType, joinId) covers today's naive, non-propagating v0; revisit once real query patterns exist.
  • Where GrantService's internal cache lives - Redis, decided - and its exact invalidation triggers. grant()/revoke()/ createRelationship()/deleteRelationship()/purgeForResource() invalidate the affected principals' entries (section 4); a registry change to a scope's inheritability does not currently invalidate anything, so v0 needs at least a TTL as a backstop until a real invalidation path is designed.
  • How entitlements (feature/package permissions) actually get checked at request time, and whether that belongs in the same auth-normalization middleware step as this object or a fully separate one - deliberately out of scope for this document, but the seam needs designing before either system is built.
  • The unmatched → matched backfill mechanism for admin-provisioned UserIntegration rows (section 2) - how/when userId actually gets set once someone signs up.
  • Whether the user→company ownership handoff is reversible, and what UI/API action actually triggers setting user_integration.companyId.
  • ACL sync (mirroring a connected source's own ACLs rather than owning permissions independently, the way Glean does) - not designed here. Flagged as worth reconsidering at V5, once SSO/SCIM-scale organization management exists - see docs/docs/roadmap/README.md's V5 section. Not a rejection, just not now.
  • A Terraform provider for these resources (V5) would want a stable, human-meaningful identifier per resource (a slug) for terraform import/idempotent re-apply to work - worth keeping in mind when these entities are actually built.

See also permissions-scenarios.md - a scenario-matrix validation pass against this document; its "Gaps found" section is where several of the items above were first surfaced.