Appearance
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 Name | Description | Data Type | Default Value | Required (= Nullable) | Unique | Format | Validations | Index | Example |
|---|---|---|---|---|---|---|---|---|---|
| id | Primary key of the entity. | UUID | Generated in code (app layer) | Yes | Yes | UUID v7 | - | Primary Key | 018ed0b3-c298-7c7a-96d5-8b36f5a7f8d2 |
| key | Stable machine key, set for global Permission Sets only (e.g. tenant-admin). Used to reference a seeded set from code. | String | - | No | Yes (among global, active sets) | kebab-case | Required when visibility = global | name: uidx_permission_sets_key_global, type: btree (unique, global + active records) | tenant-admin |
| name | Human-readable name shown in the UI. | String | - | Yes | No | - | Non-empty | - | Tenant Administrator |
| visibility | Whether this Permission Set is available across every tenant (global) or scoped to one tenant (organization). | Enum (PermissionSetVisibility) | - | Yes | No | - | One of: global, organization | - | global |
| restrictedToTenantId | Optional 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 | - | No | No | UUID v7 | Foreign Key → tenant; forbidden when visibility = global | name: idx_permission_sets_restricted_tenant, type: btree (active records, partial: WHERE restrictedToTenantId IS NOT NULL) | 018fa51f-fda1-79f4-8461-2cb8f1cabc10 |
| rules | The 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 | [] | Yes | No | See permissionSet.rules | - | - | see JSON-DAT sibling |
| active | Whether this Permission Set is currently assignable. An inactive set is kept for existing assignments but cannot be newly assigned. | Boolean | true | Yes | No | - | - | name: idx_permission_sets_active, type: btree (active records) | true |
| level | Rank 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. | Integer | 0 | Yes | No | - | - | - | 50 |
| createdAt | Timestamp of when the record was created. Immutable after insert. | Timestamp with time zone | now() — set in code | Yes | No | ISO 8601 | Cannot be null; cannot be modified after creation. | - | 2026-06-08T00:00:00Z |
| updatedAt | Timestamp of the last update. Set on insert (equal to createdAt) and updated on every change. | Timestamp with time zone | now() — set in code | Yes | No | ISO 8601 | Cannot be null. | - | 2026-06-08T00:00:00Z |
| deletedAt | Timestamp of soft deletion. Null means the record is active. Once set, immutable. | Timestamp with time zone | - | No | No | ISO 8601 | Immutable once set. Active records: WHERE deletedAt IS NULL | Indexed (active records) | - |
| createdBy | Identifier of the actor who created the record. | String | - | Yes | No | type:actor | Non-empty. | - | system:seed |
| updatedBy | Identifier of the actor who last updated the record. | String | - | Yes | No | type:actor | Non-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 ownentityId/createdAt/createdBy, which already identify the record and the write.