Appearance
Permission Model
Companion to Users & Access: the mechanism behind Permission Sets, assignments, granting, and evaluation. See Initial Permission Sets for the concrete sets EM3 ships with. This is Porsenna's application of the cross-project Permission Model concept — it records only Porsenna's actual answers and its deviations from the concept, not a restatement of it. See Users & Access for the actors (user, group, groupMembership), the invite/lifecycle model, and the API; this page holds only the mechanism detail linked from there.
The capability catalog
Built: hand-written, reconciled by a build-time test. Each feature module exports its own map of permission codes; a single catalog function assembles every module's map into one registry, plus a short, explicitly-named list of codes accepted "ahead" of their endpoint (a code seeded or referenced before the handler that will carry it exists yet — e.g. a read endpoint not built yet, or a CLI-provisioned capability with no HTTP inbound to attach a code to). Every endpoint that requires a capability declares its code directly on the handler; a repo-wide test reconciles the two sides and fails on any of: a code a handler declares but the catalog doesn't have; a catalog code no handler uses and that isn't in the declared-ahead list; a declared-ahead entry that has rotted (its code absent from the catalog, or it quietly gained a real endpoint and should have moved out of the list); or a seeded Permission Set granting a code the catalog doesn't have. A permissionCode is checked against the catalog this way — by the reconciling test — rather than by a runtime validator on write; see API-A for where the code itself is accepted.
Bootstrap capabilities are part of the same catalog, marked rather than special-cased — see Bootstrap capabilities below.
Permission sets and rules
A permissionSet is the bundle; its rules are triples of permissionCode, effect (allow/deny), and scope — see permissionSet.rules. This matches the concept's shape directly (name, description, active flag, rules stored as one structured document on the set for atomicity — the concept's own recommended default).
Breadth ("scope" in Porsenna's naming)
Decided: only one of the concept's three breadths is implemented today, by deliberate choice. The principle of a breadth dimension is kept (a future value can still be added the same way), but the set is currently a single value:
all("every record the permission code covers") — implemented, narrowed by whatever restriction the holder carries. This is Porsenna's only breadth in practice.resource("one specifically named resource") — not implemented: no column identifies which resource and no endpoint reads such a value, exactly the trap the concept warns against by name: "Do not accept a breadth you do not implement... an unimplemented value is rejected on write, and on evaluation is treated as absent, and logged." See PermissionRuleScope. A future feature that needs to restrict a rule to one named resource needs a fresh design (a real resource-identifying column, a real consumer) rather than this value.own("only records the actor owns") — not implemented; see PermissionRuleScope for the full reasoning (unused anywhere in committed docs; the one ownership-based rule that exists, Report Builder's creator-only edit, is a bespokecreatedBycheck outside this model, the pattern any future ownership need follows).
Assignments and scope binding
An assignment is a bare link, exactly as the concept requires — deny never appears on an assignment, only inside a set's own rules (see Deny lives in one layer only in the concept). Porsenna implements scope binding as the concept's nullable scope column, not the join-table alternative:
- On permissionSetUserAssignment,
tenantIdset means "this tenant";tenantIdnull means "platform-wide" (the concept's "everywhere"). - On permissionSetGroupAssignment, scope is always exactly one tenant, derived from the group's own (immutable) tenant — a group cannot have platform-wide reach. This scope binding is read only when propagating a group's grant, not at evaluation time — see Group access propagation below.
Deviation from the concept, recorded deliberately: Porsenna layers a second, project-specific narrowing mechanism on top of scope binding that the concept does not describe at all — the restriction collection (building and clientGroup dimensions, cascading through linked resources, potentially more than one relationship hop). Where the concept's own breadth stops at "every record" vs. "one named resource," Porsenna needed a middle ground — "every record among a holder-specific subset" — and restrictions are how that's expressed, independently of scope/breadth. This is additive to the concept, not a replacement for any of its mechanisms, and is fully designed (see the restriction collection page and Permission Evaluation for the full evaluation-time discussion), just not something the concept anticipated.
Group access propagation
permissionSetUserAssignment is the only table evaluation reads. Every effective grant — direct or group-derived — is materialized as a row on permissionSetUserAssignment, and evaluation is a single flat read of that one table for the actor; there is no join to groupMembership or permissionSetGroupAssignment on the request path. groupMembership and permissionSetGroupAssignment are the authoring mechanism — administrators manage a group's membership and its granted Permission Sets through them — but neither is read at evaluation time.
Propagation is synchronous, in the same transaction as the triggering write, and all-or-nothing. A membership added, a Permission Set added to or removed from a group, or a membership's restriction changed each write (or delete-and-recreate, for a restriction change) every affected permissionSetUserAssignment row before the triggering transaction commits — there is no separate async job, no propagation lag, and no partial application: either every affected row is written, or none are. Groups are expected to stay small (tens of members, not hundreds), so this is not a performance concern in practice.
Provenance: sourceGroupId. A propagated row carries sourceGroupId set to the originating group; a genuine direct grant carries it null. Paired with permissionSetId, sourceGroupId traces a propagated row back to the exact permissionSetGroupAssignment that produced it (a group can hold more than one Permission Set). This is what lets propagation know, on a membership or group-grant removal, exactly which rows are its own to delete — never one an administrator created directly.
Collision with an existing direct grant. The existing uniqueness constraint (one permissionSetUserAssignment row per user/tenant/Permission Set) means a propagated write and a direct write can target the same row. A genuine direct grant always wins: propagation skips a row that already has sourceGroupId null, and a direct grant created against a row that is currently propagated clears that row's sourceGroupId to null, "claiming" it — from then on it survives that group's own reconciliation exactly like any other direct grant. See permissionSetUserAssignment for the full detail.
Auditing propagated changes. Both sides are audited: the triggering change is audited on permissionSetGroupAssignment or groupMembership (subject: the group, or the member), and each propagated permissionSetUserAssignment row's own create/update/delete is also audited under that user's subject, attributed to the actor who made the triggering group-side change — never a synthetic "system" actor. A user's own access history is therefore complete on its own, even for access they hold purely through a group. See Auditing, Reporting & Measurement for the full auditing picture.
Role hierarchy for granting
An actor can only grant a Permission Set to someone else — inviting a new user, assigning a set to an existing user, or assigning a set to a group — if that set's level (see permissionSet) is no higher than the highest level among every Permission Set the actor themselves currently holds, direct assignments and every group they belong to combined (the same "additive across every source" pool the actor's own effective access is computed from). This is a real authorization control, not a UI nicety, so it is enforced server-side, on every endpoint that grants a Permission Set to someone: POST /v1/users, POST /v1/permission-set-user-assignments, and POST /v1/groups/{groupId}/permission-set-assignments (🚫 not in MVP — see api-a.md). It also has to apply to the two endpoints that can create or raise a set's own level — POST /v1/permission-sets (🚫 not in MVP) and PATCH /v1/permission-sets/{permissionSetId} — reject setting a level above the caller's own highest level, or the whole rule is trivially bypassed by minting a new, higher-level set and granting it to yourself. The two all-access seeded sets (platform-admin, tenant-admin) are seeded above every level anything else can be given, so they stay uncreatable by this route.
An actor may grant a Permission Set at exactly their own level, or create one at that same level — a peer is allowed; only something stronger than the actor's own highest level is off-limits. The comparison is <=, not strict <.
Restriction ceiling when granting access
The level ceiling above (an actor can grant a Permission Set at or below their own level) is not by itself enough — an actor could still grant narrower access than their own level implies but wider access than their own restriction covers, e.g. a Building Manager restricted to Building A creating another Building Manager restricted to Building A and Building B, which they themselves cannot touch. The restriction the actor sets on a new grant must be a subset of the actor's own effective restriction, for that dimension, in that scope — never wider.
The actor's own effective restriction, for a dimension, in a scope, is the union of that dimension's values across every currently-active permissionSetUserAssignment the actor holds in that scope — direct or propagated via sourceGroupId, exactly the same "additive across every source" pool the level ceiling already reads from (see Role hierarchy for granting, above), now narrowed to rows carrying a restriction in the relevant dimension. "Scope" is the tenant for building (an actor's building restriction is per-tenant, since building restrictions only ever apply to tenant-scoped rows) and the whole platform for clientGroup (platform-level rows only). If any of the actor's rows in that scope carries no entry for the dimension (i.e. that source is unrestricted), the actor is unrestricted overall in that scope and the ceiling does not apply — an unrestricted source dominates a union, same logic as level. A new grant's restriction values must all be members of this set (⊆, not = — an actor restricted to buildings A, B, C may grant just A); an empty/omitted restriction on the new grant is rejected unless the actor is themselves unrestricted in that scope.
This is a same-dimension union across the actor's own sources, scoped to this one ceiling check — it does not decide how a holder's own effective access is computed for evaluation generally. Combining different dimensions on one holder does not arise here either, for the same reason it does not arise anywhere else — see restriction collection.
Enforced everywhere a restriction can be set on a grant to someone else: POST /v1/users (both direct and group grants), POST /v1/permission-set-user-assignments, and POST /v1/groups/{groupId}/members. POST /v1/groups/{groupId}/permission-set-assignments (🚫 not in MVP) grants a Permission Set to a group, not a restriction to a person, so it carries no restrictions field and this check does not apply to it. Applies to the clientGroup dimension the same way it applies to building — an Admin-manažer-restricted actor can only grant clientGroup values that are a subset of their own.
Permission Set deletion guard
🚫 Not in MVP. No Permission Set deletion surface exists in MVP — this guard has nothing to enforce against until DELETE /v1/permission-sets/{permissionSetId} is in scope.
A permissionSet cannot be deleted while anyone holds it — a live permissionSetUserAssignment row (direct or propagated) or a live permissionSetGroupAssignment row (even one whose group currently has no members). This is a separate axis from active: deactivating a set blocks new assignments but leaves existing ones alone, while deleting it requires those existing assignments to be gone first — there is no cascading auto-revoke on delete. Enforced on DELETE /v1/permission-sets/{permissionSetId} (see api-a.md), whose error response lists the users and groups currently holding the set so the caller knows exactly what to unassign first.
Auto-provisioning for unrestricted organization-visibility sets
🚫 Not in MVP. Permission Set creation is out of scope (see above), so this mechanism never triggers today — all needed Permission Sets and groups are seeded directly instead.
Creating a permissionSet with visibility = organization and restrictedToTenantId left null — the deliberate "reusable across any tenant" case — is usable in every tenant from creation, with no tenant needing to assign it first. Creation itself provisions one group per currently-active tenant (named after the set), each holding a fresh permissionSetGroupAssignment for it, all in the same transaction as the set's own insert — a name collision with an existing group in any tenant fails the whole creation, no partial provisioning. The same back-fill runs again whenever a tenant later becomes active, for every unrestricted organization set that already exists at that point. Both the creating actor (at creation time) and whoever activates a tenant (at back-fill time) are recorded as createdBy on the groups and assignments they trigger — never a synthetic system actor, consistent with group access propagation above. This is create-time (and activation-time) provisioning only: clearing restrictedToTenantId on an already-existing locked set via PATCH does not retroactively back-fill anything. See permissionSet for the full detail and api-a.md for the write-time behavior on POST /v1/permission-sets. The back-fill runs as a sibling step, alongside platform-admin convergence, right after a tenant reaches active in the real provisioning workflow (tenant's own status enum transitions provisioning → active) — not folded into that convergence step itself, since it writes a different table (group / permissionSetGroupAssignment) for a different concern. See Tenant Provisioning.
This is a narrower, distinct mechanism from a group/Permission-Set "template" idea considered and deliberately deferred elsewhere in this analysis (see Users & Access) — it reuses one shared Permission Set across tenants rather than cloning Permission Set content per tenant.
Admin-manažer client-group requirement
Real server-side validation, not a UI-only convention: an Admin-manažer grant — a permissionSetUserAssignment row for the Admin-manažer Permission Set — must carry at least one clientGroup restriction entry. An unrestricted Admin-manažer grant would have platform-wide reach to create and manage clients, which defeats the reason this role is scoped by client group at all. Enforced on both endpoints that can produce such a row: POST /v1/users (the direct grant path) and POST /v1/permission-set-user-assignments — either rejects with 400 ERR_USERS_ACCESS_CLIENT_GROUP_REQUIRED when the referenced Permission Set is Admin-manažer and restrictions contains no clientGroup entry. This check only fires for the Admin-manažer set specifically; every other Permission Set keeps restrictions: [] meaning unrestricted, as already documented.
UI-only restrictions
To keep the invite and assignment screens simple, several restrictions are enforced only in the product's own screens — the endpoints above (and the underlying schema) keep allowing what they already allow; nothing here is a new database constraint or a new write-time validation, and a caller going around these screens (a different client, or the raw API) is not stopped by any of them (the one exception, #3, is called out below since it actually is a real constraint):
- One directly-assigned Permission Set per user.
permissionSetUserAssignmenthas no uniqueness constraint onuserIdalone and the schema is unchanged —POST /v1/users'sdirectgrant type still technically accepts severalpermissionSetIdsin one call, andPOST /v1/permission-set-user-assignmentsstill accepts being called again for the same user. The Invite (Porsenna) and Invite (External/Admin-manažer) screens simply never offer more than one role, and once a Porsenna or Admin-manažer user already holds a direct assignment, the product only offers "change role" (revoke + re-grant), never "add another." A Porsenna employee's direct role is always global (tenantIdnull) and unrestricted; a tenant/building/client-group-restricted direct grant (the Admin-manažer pattern) is expressed as restriction entries inside that one row, never as several rows. This restriction counts only genuine direct rows (sourceGroupIdnull, see Group access propagation) — a propagated row from group membership never counts against it and is never offered as something to "change" on these screens. - One Permission Set per group.
permissionSetGroupAssignmentexplicitly supports a group holding several sets at once ("additive, same as direct assignments") and that stays true at the schema and endpoint level. The "Assign role" screen for a group only offers "change role" once the group already has one. 🚫 Not in MVP — the endpoints this screen would call (POST/DELETE/GETon a group's Permission Set assignments) aren't in scope; every group's assignment is fixed at seed time for now. - One group per tenant per user. Unlike the two above, this one already is a real backend constraint, not a new UI-only convention —
groupMembershiphas a unique index on(userId, tenantId), returning409 ERR_USERS_ACCESS_GROUP_MEMBERSHIP_CONFLICT. Listed here only for completeness, since it produces the same one-role-via-group experience as the two conventions above. - Admin-manažer vs. tenant-role exclusivity. For a non-Porsenna user, the product only ever grants one of two paths, never both: an Admin-manažer direct grant (client-group-restricted, per the requirement above), or ordinary tenant-side access via group membership (see permissionSetGroupAssignment). The screens never offer inviting or assigning the second path to a user who already holds the other. This is UI-only, same as #1 and #2:
groupMembershipand a genuine direct permissionSetUserAssignment row are independent facts, access really is additive across every row a user holds (own direct grants and every propagated row alike, see Group access propagation), and nothing here stops a caller going around these screens from giving one user both.
Rule precedence
Decided, in the concept's own terms: deny-first. PermissionRuleEffect states it plainly: "When a user's assigned Permission Sets produce both an allow and a deny for the same permission code, deny always wins" — unconditionally, regardless of breadth, and across every set the actor holds (own assignments and every group's assignments alike), not just within one set. That is the concept's deny-first ordering, not specificity-first: a deny anywhere ends the question, and cannot be carved back open by a narrower allow. Consequence, per the concept: granting one holder an exception to a broad deny requires removing the deny from whatever bundle carries it and re-expressing it for everyone else who should still be denied — bundles fragment rather than layering exceptions.
Evaluating a request
Matches the concept's steps, simplified by Group access propagation: read every permissionSetUserAssignment row that applies to the actor (in-scope and platform-wide) — this single read already reflects both direct grants and every group they belong to, with no separate group join required — collect every rule naming the capability across the resulting sets, apply deny-first precedence, and narrow by any restriction that applies to the winning rule's holder. Default deny — no rule naming the capability at all — is the base case, not a fallback. Full algorithm, the three restriction-enforcement modes, and worked examples: Permission Evaluation.
Bootstrap capabilities — decided. The concept calls for a small, closed, un-evaluated set (who am I, which scopes may I reach, sync my identity) so a client has something to call before it holds any real grant. Porsenna marks these in the catalog itself, as a boolean on the entry rather than a separate list: a bootstrap code is granted to every authenticated caller by construction and is never checked against a Permission Set. GET /v1/me/tenants already behaves this way today — session-only, no permission code beyond authentication — and is the first entry the catalog marks as bootstrap. Identity itself (the sign-in exchange, establishing who the caller is) belongs to the User Authentication concept, not this one, and is not enumerated as a capability here — the bootstrap set only covers what an already-authenticated caller may reach before holding any real grant.
Enforcement on both sides
Server-side: every write endpoint in API-A states its required permission code directly.
Decided: a 403 names the missing permission code. Every 403 raised by PermissionGuard carries the missing permission code as an additive details.requiredPermission field on the shared @em3/http error envelope ({ status, error: { code, message, details }, requestId }) — the existing four fields are unchanged in shape, this only populates details. Disclosure is uniform across platform and tenant codes: the guard chain (JwtAuthGuard → TenantContextGuard → PermissionGuard → ClientModuleGuard → ClientExpirationReadGuard, apps/shared-libs/auth/src/auth.module.ts:94-99) runs PermissionGuard before any module- or client-state check, so naming the code reveals nothing a caller couldn't already infer from which endpoint they called. apps/platform-backend shares the tenant-facing backend's OIDC audience, so PermissionGuard is the only barrier between them — a known, latent risk accepted as part of this decision rather than solved by it.
This applies only to a missing-permission-code rejection. ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH (role hierarchy, see Role hierarchy for granting) and ERR_USERS_ACCESS_RESTRICTION_EXCEEDS_GRANTER (restriction ceiling, see Restriction ceiling when granting access) are not permission-code checks — the actor already holds the code and is rejected for a different reason — so neither gets this field.
Client-side, decided: the client is handed the actor's already-decided capability codes — a flat list of the codes they currently hold, evaluated once server-side — and only ever checks membership in that list. It never re-fetches rules, never re-runs precedence, and never asks "would this specific scope be allowed" on its own.
Decided: a new, dedicated GET /v1/me/capabilities endpoint — not piggybacked on GET /v1/me/tenants. Takes the same optional tenantId query parameter every other scoped read in this catalog uses (see GET /v1/permission-model/diagnose) — omitted means platform scope — and returns the actor's flat list of capability codes for that scope. Self-service only, same shape as GET /v1/me/tenants: no permission code beyond authentication, and no way to ask for anyone else's list.
Piggybacking onto GET /v1/me/tenants was rejected: that endpoint is called before a tenant is picked, to populate the switcher, over every tenant the actor belongs to — and since auto-provisioning for unrestricted organization-visibility sets can now make an org-wide actor an effective member of most or all tenants, computing and returning a capability list per tenant at that point would mean real, wasted evaluation work for a screen that only needs tenantId and status. A dedicated endpoint is called once, when the client actually enters a tenant (or once at platform scope on session start) — and its (actorId, tenantId) / platform-wide shape maps directly onto the cache key already decided in Caching, so serving it is a single cache read in the common case.
Diagnostics
Built: GET /v1/permission-model/diagnose — answers whether an actor may perform a capability in a scope, and returns the winning rule, the Permission Set it came from, and how the actor holds it (direct grant, or which group). Also answers, given a resolved target record and dimension, whether that record is reachable under the winning rule holder's restriction. See API-A for the full request/response shape and what it deliberately leaves unresolved (multi-hop cascade resolution is left to the caller).
Role permission matrix screen
New, read-only wireframe screen: under Roles, a matrix view lists every permission code in the catalog as a row and every active Permission Set as a column (ordered by level/rank, matching the Roles list), with each cell showing that set's rule for that code — allow, deny, or — when the set carries no rule for that code at all (not the same as an explicit deny; see Rule precedence). It exists purely for visibility/audit — "which sets can do this, and which deny it" at a glance — not for editing; a cell's rule is still changed from that role's own rules screen (PATCH /v1/permission-sets/{permissionSetId}).
GET /v1/permission-sets stays a lean assignment-picker list (id, key, name, visibility, level) — it does not return rules, active, or restrictedToTenantId, which invite/assignment dropdowns and filters have no use for and shouldn't pay for on every call. The Role Detail screen reads one set's full record through GET /v1/permission-sets/{permissionSetId}.
Decided: a dedicated bulk endpoint for the matrix, not one call per set. The matrix always needs every active Permission Set's rules at once, plus the full catalog code list for its rows — paging through the per-set endpoint one call per set (tens of calls every time the screen opens) is needless chattiness for a screen that never wants a partial view. A new GET /v1/permission-sets/matrix returns the whole matrix's data in a single response instead.