Skip to content
Updated Sep 25, 2026 by Barča Dvořáková · Owner: analysisactiveentity Edit on GitHub

Entity: permissionSet ​

Entity Type: Database table

Description: A named, reusable bundle of permission rules that can be assigned either to a user directly (see permissionSetUserAssignment) or to a group (see permissionSetGroupAssignment). A Permission Set is either global (defined once, available for assignment in any tenant, seeded from code at deploy time — e.g. Tenant Administrator, Manager, Worker, Reader) or organization-visibility (a custom, tenant-created set). Unlike earlier drafts of this entity, permissionSet carries no stored tenant ownership at all — there is no tenantId column. An organization-visibility set's assignability for a given tenant is derived, not stored: it is assignable in a tenant once it already has at least one live assignment there (via permissionSetUserAssignment or permissionSetGroupAssignment), or it can be assigned there for the first time regardless (creation and first assignment are independent steps — a freshly created set with no assignment anywhere is simply not yet assignable-by-listing to anyone until someone assigns it). This is what lets one shared "default" set be reused, unduplicated, by several tenants' groups, while a genuinely bespoke set never surfaces as assignable outside the tenant(s) that actually use it.

Resolved via an optional exclusivity lock: a caller could otherwise directly assign an organization-visibility set that is already in live, exclusive use by a different tenant, since the derived-assignability rule only curates what GET /v1/permission-sets shows, not what a write endpoint accepts. restrictedToTenantId (below) closes this for any set that actually needs exclusivity, while leaving the deliberate "one set, several tenants" reuse case (left null) untouched — a set can be freely reused unless someone explicitly locks it.

Auto-provisioned across every tenant when created unrestricted. Creating a set with visibility = organization and restrictedToTenantId left null — the deliberately unlocked, reuse-across-every-tenant case above — does not wait for a tenant to assign it once before it is usable there. Instead, creation itself provisions one group per currently-active tenant (name equal to the Permission Set's own name), each holding a fresh permissionSetGroupAssignment for this set. The groups are created empty — no members — ready for a tenant admin to add people. If the set's name already collides with an existing group name in any active tenant (group enforces (tenantId, name) uniqueness), the entire creation is rejected — no partial provisioning. This is create-time only: later clearing restrictedToTenantId on an existing set via PATCH does not retroactively back-fill groups for tenants that don't already have one. A tenant that becomes active after the set was created is back-filled at that point instead — every currently-active, unrestricted organization set gets its group provisioned for the newly-active tenant, the same way. All of these auto-created groups and group-assignments are audited as createdBy the actor who performed the triggering action (the Permission Set's creator, or — for the later back-fill — whoever activated the tenant). See POST /v1/permission-sets in api-a.md for the write-time detail; the later, tenant-activation-triggered back-fill runs as a sibling step alongside platform-admin convergence in the real provisioning workflow — see Tenant Provisioning.

Each rule bundled in a Permission Set states which permission it grants or denies, and at what scope (see PermissionRuleEffect, PermissionRuleScope).

The rules are stored as a single JSONB column rather than a separate table: they are always fetched and written together with their Permission Set and never queried independently, so this is not a discretionary boundary decision for this analysis — it is the shape already in place.

Two globally-seeded Permission Sets (platform-level "Platform Administrator" and tenant-level "Tenant Administrator") are all-access: holding one of them grants unrestricted access within its scope, bypassing rule-by-rule evaluation entirely.

Deletion is blocked while assigned. A Permission Set cannot be soft-deleted (deletedAt set) while it is held by anyone — a live permissionSetUserAssignment row (direct or propagated via sourceGroupId), or a live permissionSetGroupAssignment row, even one whose group currently has no members (the group still holds the grant). This is separate from, and stricter than, active — deactivating leaves existing assignments untouched and only blocks new ones; deletion requires every existing assignment to be revoked first. There is no cascading auto-revoke: the caller unassigns everywhere, then deletes. See DELETE /v1/permission-sets/{permissionSetId} in api-a.md for the write-time check and the error shape (it lists the users and groups currently holding the set).

Role hierarchy (level): every Permission Set carries a level — a plain integer, higher means more privileged, set explicitly by whoever creates or edits the set (no automatic derivation from rules). It exists to answer one question: can actor X grant Permission Set Y to someone? An actor may only invite a user into, or assign, a Permission Set whose level is no higher than the highest level among every Permission Set that actor currently holds (direct assignments and every group they belong to, combined — a peer-level grant is allowed, only something stronger is not — see permission-model). The two all-access seeded sets are seeded with the highest level in use, so nothing created afterwards can be "above" them.

Data Attributes Table ​

Attribute NameDescriptionData TypeDefault ValueRequired (= Nullable)UniqueFormatValidationsIndexExample
idPrimary key of the entity.UUIDGenerated in code (app layer)YesYesUUID v7-Primary Key018ed0b3-c298-7c7a-96d5-8b36f5a7f8d2
keyStable machine key, set for global Permission Sets only (e.g. tenant-admin). Used to reference a seeded set from code.String-NoYes (among global, active sets)kebab-caseRequired when visibility = globalname: uidx_permission_sets_key_global, type: btree (unique, global + active records)tenant-admin
nameHuman-readable name shown in the UI.String-YesNo-Non-empty-Tenant Administrator
visibilityWhether this Permission Set is available across every tenant (global) or scoped to one tenant (organization).Enum (PermissionSetVisibility)-YesNo-One of: global, organization-global
restrictedToTenantIdOptional hard exclusivity lock. Meaningful only when visibility = organization; always null for global sets. Left null (the default), the set is freely assignable/reusable across any tenant, exactly as an unrestricted organization set behaves. Set to a tenant, assignment and listing are both hard-restricted to that one tenant — enforced at the write endpoint, not only curated by the listing endpoint.UUID-NoNoUUID v7Foreign Key → tenant; forbidden when visibility = globalname: idx_permission_sets_restricted_tenant, type: btree (active records, partial: WHERE restrictedToTenantId IS NOT NULL)018fa51f-fda1-79f4-8461-2cb8f1cabc10
rulesThe permission rules bundled in this set. Each rule states a permission code, an effect (allow/deny), and a scope (currently only all — see PermissionRuleScope).JSON array[]YesNoSee permissionSet.rules--see JSON-DAT sibling
activeWhether this Permission Set is currently assignable. An inactive set is kept for existing assignments but cannot be newly assigned.BooleantrueYesNo--name: idx_permission_sets_active, type: btree (active records)true
levelRank used to decide who can grant this set to someone else (see description above). Higher = more privileged. Set explicitly on create/update; no default significance beyond the integer ordering.Integer0YesNo---50
createdAtTimestamp of when the record was created. Immutable after insert.Timestamp with time zonenow() — set in codeYesNoISO 8601Cannot be null; cannot be modified after creation.-2026-06-08T00:00:00Z
updatedAtTimestamp of the last update. Set on insert (equal to createdAt) and updated on every change.Timestamp with time zonenow() — set in codeYesNoISO 8601Cannot be null.-2026-06-08T00:00:00Z
deletedAtTimestamp of soft deletion. Null means the record is active. Once set, immutable.Timestamp with time zone-NoNoISO 8601Immutable once set. Active records: WHERE deletedAt IS NULLIndexed (active records)-
createdByIdentifier of the actor who created the record.String-YesNotype:actorNon-empty.-system:seed
updatedByIdentifier of the actor who last updated the record.String-YesNotype:actorNon-empty.-system:seed

Audited fields ​

Recorded (created/deleted: full set; updated: changed fields only): key, name, visibility, restrictedToTenantId, rules, active, level. All are recorded without exception beyond the standard block below — a change to rules, restrictedToTenantId or level in particular is exactly the kind of security-relevant change an audit trail exists to catch.

Excluded:

  • id, createdAt, updatedAt, deletedAt, createdBy, updatedBy — redundant with the audit entry's own entityId/createdAt/createdBy, which already identify the record and the write.