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_granttable, 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 generalizedgranttable that attaches scopes to a structural relationship rather than connecting two raw resources directly. (3) The resulting permission object was moved entirely behindGrantService- 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/:idreturnsuserIntegrationIdas 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
Collectionnever returns itsTeams' fields inline; reading aTeamnever returns itsCompany'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
| Table | Represents | Key fields |
|---|---|---|
company_user | A user is a member of a company. | id, userId → user.id, companyId → company.id |
team_user | A user is a member of a team. | id, userId → user.id, teamId → team.id |
collection_team | A collection is assigned to (shared with) a team. | id, teamId → team.id, collectionId → collection.id |
collection_user_integration | An integration is placed in a collection. | id, collectionId → collection.id, userIntegrationId → user_integration.id |
user_integration_embedding_config | An 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:
| Table | Represents | Key fields |
|---|---|---|
direct_grant | A 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
| Field | Type | Notes |
|---|---|---|
id | uuid | |
joinType | string | Which structural table joinId points into - "companyUser", "teamUser", "collectionTeam", "collectionUserIntegration", "userIntegrationEmbeddingConfig", "directGrant". |
joinId | uuid | The specific row's id in that table. |
scope | string | One scope per row, not an array - see "Why one scope per row" below. |
provenance | string | "manual" today; a future "synced:google-workspace" etc. |
createdAt/updatedAt/deletedAt | timestamps | Standard 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→joinIdis the team's id (adding a member to a team).companyUser.create→joinIdis 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.DirectGrantwas deliberately kept polymorphic, with no natural group or hierarchy underneath it - which means it also has no parent to anchor a.createscope'sjoinIdto. Resolved:directGrant.createlives inglobal.scopes(nojoinId), 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 -directGrantsis the one exception without a{ resourceId, scopes }shape for its own.createscope, 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.manageis valid for ateamUsergrant, a nonsensical scope for that relationship type is rejected at write time. GrantServiceis the only thing that ever reads or writes the structural tables and thegranttable. No other service touches them directly.GrantServicestill performs, at write time, what a cross-table foreign key can't: confirmingjoinIdactually resolves to a real row of the statedjoinType, 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:
- Self-service, individually authorized - personal Gmail, personal Notion. The credential can only be created by the account holder.
- 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
UserIntegrationrow 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.
userId | companyId | externalUserId | Meaning |
|---|---|---|---|
| set | null | set | Self-service Gmail - individual owns it entirely |
| set | null | null | Self-service FTP - individual owns it, no external identity concept |
| null | set | set | Domain-wide Workspace row for an employee not yet matched to a Yew account |
| set | set | set | Domain-wide Workspace row matched to a known Yew user |
| null | set | null | Company-managed resource with no per-person identity at all |
| null | null | - | 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, aCompany, ...) callgrantService.purgeForResource({ type, id })- one method that finds and removes every structural row referencing that resource across every structural table, and everygrantrow attached to any of them, in one transaction.TeamService.deleteOne()calls this once; it doesn't enumerateteam_user/collection_teamitself 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 anygrantrows pointing at it. This is what closes the gap the old checklist had: removing a singleteam_userrow without deleting the wholeTeamwas 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 validationGrantServiceneeds 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:
| API | Notes |
|---|---|
CompanyUserController, TeamUserController, CollectionTeamController, CollectionUserIntegrationController | Thin 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.companyIdworks 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 andgrantrows 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
UserIntegrationControllersettinguser_integration.companyId- an ownership change (see below), separate fromGrantControllerentirely. 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 (
CompanyUservs.UserCompany) is back in play for the structural tables above -company_userandteam_useralready follow it;collection_teamandcollection_user_integrationwere chosen to follow it too (cbeforet,cbeforeu). - Privilege escalation on grant creation - still open, carried over
from the previous review.
GrantControllerstill 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.actionstrings (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 agrantrow is legal for that row'sjoinType, 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.
provenanceon 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:
parentTeamIdexists on every team now, alwaysnull. 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 whoseresourceIdis 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
resourceIdisn't known yet - it depends on a DB read, a derived value, or combining several inputs (Example A's integration lookup, Example C'scollectionTeam.idthat 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/getAccessQuerydo when agrantrow'sjoinIdno longer resolves to a real row - fail closed (no grant), fail open (unrestricted), or throw. A dangling row shouldn't be possible now thatGrantServiceowns 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
.createscope'sjoinId, and whether join-table scopes can be granted above a single instance at all (section 2, "Scopes on the join tables themselves") -collectionTeam.createandcollectionUserIntegration.createcould 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 includingteamUser.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
granttable, 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.
- a composite index on
- 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
UserIntegrationrows (section 2) - how/whenuserIdactually 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) forterraform 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.