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

Entity: permissionSetUserAssignment ​

Entity Type: Database table

Description: Grants one permissionSet directly to one user, either for a specific tenant (tenantId set) or at platform level (tenantId null). This is the only granting mechanism for any platform-level grant, and is used for exactly two kinds of holder, which behave differently:

  • A Porsenna employee. Their direct role is always global — tenantId null, no tenant or building scoping — and the product screens only ever let them hold one at a time (see permission-model); a second direct role replaces the first rather than adding to it.
  • A non-Porsenna, external role (the "Admin-manažer"). A platform-level grant restricted to one or more client groups (dimension clientGroup — see restriction collection and clientGroup) is how this kind of user is granted access to create and manage clients — and, by cascade through the client, to create the tenant that follows from it — without that access spanning every client on the platform. The scoping lives entirely in that one row's restrictions array (several client groups in one grant), never as several rows per tenant. Unlike every other Permission Set, an Admin-manažer grant must carry at least one clientGroup restriction entry — this is real, server-side validation, not a screen convention, since an unrestricted Admin-manažer would have platform-wide reach (see permission-model). The product also never lets this same user hold tenant-side access via group membership at the same time — that exclusivity, unlike the client-group requirement, is UI-only (see permission-model).

For tenant-side users, direct assignment is a minor, edge-case path: the normal path for a tenant-side user is group membership (see permissionSetGroupAssignment); direct assignment remains available for a tenant user who needs a grant outside any group.

This is the single source of truth read at evaluation time. Every effective grant a user holds — whether created directly by an administrator, or held because the user belongs to a group that permissionSetGroupAssignment grants a Permission Set to — is materialized as its own row here. groupMembership and permissionSetGroupAssignment are the authoring mechanism (an administrator manages a group's membership and its granted Permission Sets there), but neither is read at evaluation time any more — evaluation is a single flat read of this table for the actor and the request's scope, with no join through group membership.

A group-derived row carries sourceGroupId set to the originating group; a genuine direct grant carries sourceGroupId null. The pair (sourceGroupId, permissionSetId) traces a propagated row back to the exact permissionSetGroupAssignment that produced it, since a group can hold more than one Permission Set at once. Propagation happens synchronously, in the same transaction as the triggering groupMembership or permissionSetGroupAssignment write: a membership added, a group's grant added, or a restriction changed all write (or delete-and-recreate, for a restriction change) the affected rows here before the transaction commits — there is no separate async job and no propagation lag. Removing a user from a group, or removing a Permission Set from a group's grants, deletes the corresponding propagated rows the same way. Groups are expected to stay small (tens of members, not hundreds), and each propagating edit is all-or-nothing: either every affected row is written, or none are.

Collision with an existing direct grant. The uniqueness constraint below (one 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: if propagation would target a row that already has sourceGroupId = null, propagation skips that row — the user already effectively has the access. If an administrator creates a direct grant that collides with an existing propagated row, the write clears sourceGroupId to null on that row — it is "claimed" as a direct grant from that point on and is no longer touched by that group's own reconciliation (removing the user from the group, or removing the group's grant, will not delete it).

A user may simultaneously hold group-derived rows and genuine direct rows — Permission Sets remain additive across every source, so a user's effective access in a tenant is still the union of every row they hold there (now all visible in one table, rather than requiring a join). The schema still permits more than one live direct row per user (no uniqueness constraint on userId alone beyond the per-set constraint below); "one direct role per user" is enforced only by the invite/assign screens, not by this table — see permission-model for why, and note that this restriction counts only genuine direct rows (sourceGroupId null), never propagated ones. The one case the screens actively keep apart is Admin-manažer versus group membership (see above) — everywhere else, holding both a direct grant and group-derived access for the same tenant is allowed and evaluated additively as described.

This entity is the only source read at evaluation time. groupMembership and permissionSetGroupAssignment remain sources of truth for how a group's access should look and who belongs to it — that is what propagation reads from — but access itself is decided here alone. A user's tenant membership (see tenantUser) is derived from group membership and from genuine direct grants, and is never assigned directly.

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
userIdThe user this Permission Set is granted to.UUID-YesNoUUID v7Foreign Key → user; must existname: idx_permission_set_user_assignments_user_tenant, type: btree (composite with tenantId, active records)018fa51f-fda1-79f4-8461-2cb8f1cabc10
tenantIdThe tenant this grant applies to. Null means the grant is platform-scoped (not tied to any tenant). Set directly on this row — there is no group to derive it from.UUID-NoNoUUID v7Foreign Key → tenant when setname: idx_permission_set_user_assignments_user_tenant, type: btree (composite with userId, active records)018fa51f-fda1-79f4-8461-2cb8f1cabc10
permissionSetIdThe Permission Set being granted.UUID-YesNoUUID v7Foreign Key → permissionSet; must exist, be active, and (restrictedToTenantId is null or equals this row's tenantId)name: idx_permission_set_user_assignments_set, type: btree (active records)018fa51f-fda1-79f4-8461-2cb8f1cabc11
sourceGroupIdThe group whose Permission Set grant produced this row, when it originated from group membership rather than a direct grant. Null for a genuine direct grant. Paired with permissionSetId to trace back to the exact permissionSetGroupAssignment that produced it. Cleared to null if a direct grant is later created for the same (userId, tenantId, permissionSetId) — see description above.UUIDnullNoNoUUID v7Foreign Key → group when set; a stale reference (group no longer holds this set) is corrected by the next reconciliation, never read as authoritativename: idx_permission_set_user_assignments_source_group, type: btree (partial, WHERE sourceGroupId IS NOT NULL)018fa51f-fda1-79f4-8461-2cb8f1cabc20
restrictionsRestriction entries narrowing this grant. Empty array means unrestricted.JSON array[]YesNoSee restriction collection--see JSON-DAT sibling
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 (i.e. the grant was revoked). Null means the grant 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.-user:018e...
updatedByIdentifier of the actor who last updated the record.String-YesNotype:actorNon-empty.-user:018e...

Uniqueness: (userId, tenantId, permissionSetId) is unique among active (non-deleted) records, enforced by unique index idx_permission_set_user_assignments_unique, type: btree (NULLS NOT DISTINCT, so two platform-scoped grants of the same set to the same user also collide) — the same Permission Set cannot be granted twice to the same user for the same tenant. sourceGroupId is deliberately not part of this key — a direct grant and a group-derived grant for the same (userId, tenantId, permissionSetId) are the same row, not two rows; see the collision rule above for which write wins when both would apply.

Audited fields ​

Recorded (created/deleted: full set; updated: changed fields only): tenantId, permissionSetId, restrictions, sourceGroupId. Unlike the derived tenantId on group-based grants, tenantId here is set directly and can be null (a platform-level grant) — that distinction is itself part of what was granted, so it is recorded rather than treated as redundant scope metadata. sourceGroupId is recorded because a row changing from propagated to claimed-direct (or being created by propagation in the first place) is itself a meaningful fact about how the access came to exist. A propagated row's createdBy/updatedBy is the actor who made the triggering group or membership change — never a synthetic "system" actor — so a user's own audit history reads naturally ("granted by <admin>") even though that admin acted on the group screen, not the user's.

Filed under subject: userId is not duplicated in this set — every entry sets the audit row's own subjectEntityType = "user" / subjectEntityId = userId, so one person's full direct-grant history (including a Porsenna user's grants across several tenants) is a single indexed read.

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.
  • userId — filed as the audit row's subject reference instead (see above).