Appearance
Entity: groupMembership
Entity Type: Database table
Description: Records that a user belongs to a group. Membership is the authoring fact that drives access — it is not itself read at evaluation time. Adding a membership row propagates, synchronously and in the same transaction, one permissionSetUserAssignment row per Permission Set the group holds (each carrying sourceGroupId set to this group), copying this row's own restrictions onto each propagated row; removing membership deletes those propagated rows the same way, unless a given row has since been claimed by a genuine direct grant (see the collision rule on permissionSetUserAssignment). Restrictions live here — not on the group, and not on the Permission Set assignment — because two members of the same group can be restricted differently (e.g. two Accountants each restricted to a different set of buildings) even though they share the same group and the same granted Permission Sets; a restriction change on this row is propagated by deleting and recreating the affected permissionSetUserAssignment row(s) with the new restriction, rather than patching them in place.
Because a group belongs to exactly one tenant for its entire lifetime (see group), this record's tenant is fixed the moment it is created and is carried on this row (tenantId) purely as a derived, denormalized value for fast tenant-scoped queries — it is set once, from the group's own tenantId, and is never independently writable or re-synced. It must never be treated as authoritative on its own for an authorization decision; the actual permission check always resolves tenant scope via the live group, and tenantId here exists only to make listing and constraint-checking fast.
Membership in a tenant's group is exclusive: a user can belong to at most one group within a given tenant at a time (enforced below), though the same user can belong to a different group in a different tenant, or hold a direct permissionSetUserAssignment alongside group membership.
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 |
| userId | The user who is a member of the group. | UUID | - | Yes | No | UUID v7 | Foreign Key → user; must exist | name: idx_group_memberships_user_tenant, type: btree (composite with tenantId, active records) | 018fa51f-fda1-79f4-8461-2cb8f1cabc10 |
| groupId | The group this user belongs to. | UUID | - | Yes | No | UUID v7 | Foreign Key → group; must exist. Immutable after insert — revoking membership is a soft delete, changing groups is a new row, never an update of this column. | name: idx_group_memberships_group, type: btree (active records) | 018fa51f-fda1-79f4-8461-2cb8f1cabc15 |
| tenantId | Derived from group.tenantId at insert time (BEFORE INSERT trigger); immutable thereafter, since neither a group's tenant nor this row's groupId can change after creation. Kept only for fast tenant-scoped queries and the uniqueness constraint below — never trusted alone for an authorization decision. | UUID | Derived from group.tenantId — set in trigger | Yes | No | UUID v7 | Must equal the referenced group's tenantId; enforced by trigger, not independently writable. | name: idx_group_memberships_user_tenant, type: btree (composite with userId, active records) | 018fa51f-fda1-79f4-8461-2cb8f1cabc10 |
| restrictions | Restriction entries narrowing this member's access within the group's granted Permission Sets. Empty array means unrestricted. | JSON array | [] | Yes | No | See restriction collection | - | - | see JSON-DAT sibling |
| 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-09-01T00:00:00Z |
| updatedAt | Timestamp of the last update (e.g. a restriction change). 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-09-01T00:00:00Z |
| deletedAt | Timestamp of soft deletion (membership revoked). Null means the membership is active. Once set, immutable. | Timestamp with time zone | - | No | No | ISO 8601 | Immutable once set. Active records: WHERE deletedAt IS NULL | name: idx_group_memberships_deleted_at, type: btree (active records) | - |
| createdBy | Identifier of the actor who created the record. | String | - | Yes | No | type:actor | Non-empty. | - | user:018e... |
| updatedBy | Identifier of the actor who last updated the record. | String | - | Yes | No | type:actor | Non-empty. | - | user:018e... |
Uniqueness: (userId, tenantId) is unique among active (non-deleted) records, enforced by unique index uidx_group_memberships_user_tenant, type: btree — this is exactly what makes group membership exclusive per tenant: a user cannot hold two live memberships whose groups share a tenant, regardless of which two groups they are.
Derived data: Creating (or soft-deleting) a groupMembership row updates the user's tenantUser membership for that tenant, exactly as a permissionSetUserAssignment does — a user is a tenant member if they hold either kind of grant there.
Audited fields
Recorded (created/deleted: full set; updated: changed fields only): groupId, restrictions.
Filed under subject: userId is not duplicated in this set — every entry for this entity sets the audit row's own subjectEntityType = "user" / subjectEntityId = userId, so one member's full group-membership history (across every group) is a single indexed read, per the Audit Log's own subject mechanism.
Excluded:
id,createdAt,updatedAt,deletedAt,createdBy,updatedBy— redundant with the audit entry's ownentityId/createdAt/createdBy, which already identify the record and the write.userId— filed as the audit row's subject reference instead (see above).tenantId— derived fromgroup.tenantId, redundant with the audit entry's owntenantId(RLS scope column) and withgroup's own trail.