Appearance
Users & Access — API Analysis (API-A)
Feature: Users & Access
Paths below are versioned under /v1. Success envelope: { data, status, message, requestId }. Error envelope: { status, error: { code, message, details }, requestId }. JSON properties use camelCase throughout. A 403 raised for a missing permission code populates details.requiredPermission with that code — see Enforcement on both sides for the decision.
There is no Shared API Conventions page or Permission Catalog Registry page in this project yet, so each endpoint's Authorization subsection states its permission code directly rather than linking one.
Distinguishing a Porsenna platform person from a tenant person (required so a tenant's user list never shows Porsenna staff — see the feature doc's Business Context) cannot be inferred from grant shape alone, since both use direct permissionSetUserAssignment grants. The endpoints below use the isPorsennaUser flag on user for this purpose.
📥 Endpoint: POST /v1/users
Purpose: Invites a person into EM3, granting them one or more access grants (group memberships and/or direct Permission Set assignments) in the same call. If nobody with this email exists yet, this also creates their user record; if a live user with this email already exists (typically in another tenant), this grants the new access to that existing user instead of creating a second one — see find-or-grant in Request Logic below. Either way, a user is never touched without also being granted access to something.
Authorization
Requires permission code platform.users.invite, held either globally (to invite at platform level or into any tenant) or for every tenant referenced by the request's grants. Additionally, every Permission Set referenced by a direct grant, and every group referenced by a group grant, must have a level (see permissionSet) no higher than the highest level among every Permission Set the caller themselves holds — see permission-model.
Request Headers
None beyond the project's standard headers.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email | String | Yes | The invited person's email address |
firstName | String | Yes | The invited person's first name. Ignored when a live user already exists with this email (find-or-grant, see Request Logic) — the existing identity is authoritative, and is refreshed from the identity provider at sign-in anyway. |
lastName | String | Yes | The invited person's last name. Ignored under the same find-or-grant condition as firstName. |
phoneNumber | String | No | The invited person's phone number; optional, never required. Ignored under the same find-or-grant condition as firstName. |
isPorsennaUser | Boolean | Yes | Whether this is a Porsenna platform person or a tenant person. When a live user already exists with this email, this value is validated against that user's actual isPorsennaUser rather than ignored — a mismatch is rejected (see Request Logic and 409 ERR_USERS_ACCESS_TYPE_MISMATCH below). |
grants | Array<Grant> | Yes | One or more access grants, in the same call; at least one required |
A Grant is one of:
| Field | Type | Required | Description |
|---|---|---|---|
type | Enum: group | direct | Yes | Whether this grant joins a group or is a direct Permission Set assignment |
groupId | UUID | Required when type = group | The group to join; must not be combined with tenantId or permissionSetIds — tenant is implied by the group |
tenantId | UUID | Only when type = direct | The tenant this direct grant applies to; omit for a platform-level grant |
permissionSetIds | Array<UUID> | Required when type = direct | One or more Permission Sets to grant directly, in the stated scope |
restrictions | Array<Restriction> | No | Restriction entries for this grant (see restriction collection) — for direct, on the new permissionSetUserAssignment row; for group, on the new groupMembership row, exactly as on POST /v1/groups/{groupId}/members. Omitted means unrestricted, same as everywhere else. |
json
{
"email": "jane.doe@example.com",
"firstName": "Jane",
"lastName": "Doe",
"phoneNumber": null,
"isPorsennaUser": false,
"grants": [
{ "type": "group", "groupId": "018fa51f-fda1-79f4-8461-2cb8f1cabc16" }
]
}Request Parameters
None (all input is in the body).
Request Logic
- Find-or-grant on
email. Looks up whether a live (non-suspended) user already exists with thisemail.- If one exists, skips the
userinsert entirely and processesgrantsbelow against that existinguserId, through the exact same validation as a brand-new invite (role-level ceiling, restriction ceiling, Admin-manažer client-group requirement, group-membership exclusivity per tenant). Invite is the only screen that grants access at all (no group-management UI, and no Permission Set assignment UI outside this screen — see Functional Requirements), so it is also how someone already a live user elsewhere is granted access to a second tenant.firstName,lastName, andphoneNumberare ignored in this case (see Request Body above);isPorsennaUseris instead checked against the existing user's actual value and rejected on mismatch (see409 ERR_USERS_ACCESS_TYPE_MISMATCHbelow) — this person's category (Porsenna vs. tenant) cannot be changed by being invited again. - If none exists, proceeds as today: a new
userrow is created fromemail,firstName,lastName,phoneNumber, andisPorsennaUserexactly as given. - Either way,
grantsstill cannot be empty, and a grant that itself conflicts with the target user's existing access (e.g. they already hold a live membership in that same tenant) is still rejected by the group-membership-exclusivity and other checks below — find-or-grant reuses the identity, it does not bypass any grant-level validation.
- If one exists, skips the
- Each
groupIdmust reference an existing, active group; eachpermissionSetIdsentry must exist, beactive, and (for adirectgrant) haverestrictedToTenantIdeither null or equal to that grant's owntenantId— a set locked to a different tenant is rejected even though the caller supplied its ID directly (see permissionSet); an unlocked set is otherwise assignable regardless of prior use elsewhere, and curation of what's offered by default happens atGET /v1/permission-sets, not as a write-time constraint here. - Rejects the request if two
group-type grants would place the user in two different groups that share the same tenant (violates group-membership exclusivity per tenant), or if any grant's tenant duplicates another grant's tenant in a way not otherwise permitted. - Rejects the request if any referenced Permission Set's (or, for a
groupgrant, the group's own currently-held sets')levelis higher than the caller's own highest heldlevel. - Rejects the request if a
directgrant'spermissionSetIdsincludes the Admin-manažer Permission Set and that grant'srestrictionscontains noclientGroupentry (see permission-model). - Restriction ceiling: rejects the request if any grant's
restrictionsis broader than the caller's own effective restriction for that dimension in that scope — see permission-model. Does not apply when the caller is unrestricted in that scope. - When no live user exists yet: inserts a
userrow withstatus = invited,oid = null,isPorsennaUseras given,firstName,lastName, andphoneNumber(or null) as given. - When a live user already exists (find-or-grant): no
userrow is inserted or modified; every grant below is created against that user's existingid. - For each
groupgrant, inserts agroupMembershiprow with the givenrestrictions(or none). - For each
directgrant, inserts onepermissionSetUserAssignmentrow perpermissionSetIdsentry, with the giventenantId(or null for platform level) andrestrictions. - The insert triggers on
groupMembershipandpermissionSetUserAssignmentderive the correspondingtenantUserrow when a tenant is implied.
Transactional Operations
The user insert (when one happens — see find-or-grant above) and every grant insert (groupMembership and/or permissionSetUserAssignment) commit together in one transaction; if any grant fails validation, nothing is written, and an existing user found by find-or-grant is never partially modified.
✅ Success Response (201 Created)
Stays 201 Created even for find-or-grant, where no user row is inserted: the resource this call creates is the grant (a new groupMembership and/or permissionSetUserAssignment), and at least one of those is always newly created — only the user row is sometimes reused instead of created.
json
{
"data": {
"userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc12"
},
"status": 201,
"message": "User invited.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.userId | user.id, newly generated |
❌ Error Responses
400 ERR_USERS_ACCESS_MISSING_GRANT
Thrown when grants is empty.
json
{ "status": 400, "error": { "code": "ERR_USERS_ACCESS_MISSING_GRANT", "message": "At least one grant is required." }, "requestId": "…" }400 ERR_USERS_ACCESS_INVALID_GRANT_SHAPE
Thrown when a group grant also carries tenantId/permissionSetIds, or a direct grant carries groupId, or a direct grant's permissionSetIds is empty.
json
{ "status": 400, "error": { "code": "ERR_USERS_ACCESS_INVALID_GRANT_SHAPE", "message": "Grant does not match its declared type." }, "requestId": "…" }403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.users.invite for the requested scope.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot invite users into this scope.", "details": { "requiredPermission": "platform.users.invite" } }, "requestId": "…" }404 ERR_USERS_ACCESS_GROUP_NOT_FOUND
Thrown when a groupId does not exist or is inactive.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_GROUP_NOT_FOUND", "message": "Group not found." }, "requestId": "…" }404 ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND
Thrown when a permissionSetIds entry does not exist, is inactive, or is locked to a different tenant via restrictedToTenantId (deliberately indistinguishable from not existing, so a caller cannot use this response to probe another tenant's private sets).
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND", "message": "Permission set not found." }, "requestId": "…" }409 ERR_USERS_ACCESS_TYPE_MISMATCH
Thrown when a live user already exists with this email (find-or-grant, see Request Logic above) and the request's isPorsennaUser does not match that existing user's actual value — this person's kind (Porsenna vs. tenant) cannot be changed by being invited again.
json
{ "status": 409, "error": { "code": "ERR_USERS_ACCESS_TYPE_MISMATCH", "message": "This person already exists as a different user type (Porsenna vs. tenant)." }, "requestId": "…" }409 ERR_USERS_ACCESS_GROUP_MEMBERSHIP_CONFLICT
Thrown when two group grants in the same request would place the user in two groups sharing a tenant.
json
{ "status": 409, "error": { "code": "ERR_USERS_ACCESS_GROUP_MEMBERSHIP_CONFLICT", "message": "Cannot join two groups in the same tenant." }, "requestId": "…" }403 ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH
Thrown when a referenced Permission Set's level is higher than the caller's own highest held level (see permission-model).
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH", "message": "You cannot grant a role above your own level." }, "requestId": "…" }400 ERR_USERS_ACCESS_CLIENT_GROUP_REQUIRED
Thrown when a direct grant targets the Admin-manažer Permission Set with no clientGroup entry in restrictions (see permission-model).
json
{ "status": 400, "error": { "code": "ERR_USERS_ACCESS_CLIENT_GROUP_REQUIRED", "message": "Admin-manažer must be granted with at least one client group." }, "requestId": "…" }403 ERR_USERS_ACCESS_RESTRICTION_EXCEEDS_GRANTER
Thrown when a grant's restrictions is broader, for its dimension, than the caller's own effective restriction in that scope (see permission-model).
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_RESTRICTION_EXCEEDS_GRANTER", "message": "You cannot grant access to buildings outside your own restriction.", "dimension": "building", "grantersValues": ["018fa51f-fda1-79f4-8461-2cb8f1cabc14"] }, "requestId": "…" }📥 Endpoint: GET /v1/users
Purpose: Paginated listing of users, for the user administration screen. In a tenant-scoped listing, Porsenna staff are always excluded — a tenant never sees Porsenna's people with access to it. Supports listing across several tenants at once (the Platform-side "Clients" screen has no reason to force a caller to page through one tenant at a time).
Authorization
Requires permission code platform.users.read, held either globally or for every tenant in the tenantIds filter being queried.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
tenantIds | Query | Array<UUID> | No | - | Restrict to users who belong to any of these tenants; when set, rows with isPorsennaUser = true are always excluded | Repeated query param (?tenantIds=a&tenantIds=b); each must exist; capped at 50 values | tenantUser.tenantId |
status | Query | Enum | No | - | Filter by user status | One of UserStatus | user.status |
isPorsennaUser | Query | Boolean | No | - | Filter by person kind; only meaningful without tenantIds (a platform-level listing) | Ignored if tenantIds is set | user.isPorsennaUser |
page / pageSize | Query | Integer | No | 1 / 20 | Pagination | pageSize capped at 100 | - |
Deprecated alias: the previous singular tenantId param is still accepted as a one-element form of tenantIds, for any caller not yet updated; both are never accepted in the same request.
Request Logic
- Reads
user, left-joined to every livetenantUserrow for that user (not just the ones matching the filter — see the response'stenantIdsbelow). - When
tenantIdsis supplied, restricts to users with at least one livetenantUserrow whosetenantIdis in the set (a union across the given tenants, not an intersection — a user who belongs to only one of the requested tenants still matches), and always filtersisPorsennaUser = false, regardless of theisPorsennaUserquery parameter. - Excludes soft-deleted rows (
deletedAt IS NULL).
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"users": [
{
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc12",
"email": "jane.doe@example.com",
"firstName": "Jane",
"lastName": "Doe",
"displayName": "Doe Jane",
"phoneNumber": null,
"status": "active",
"isPorsennaUser": false,
"tenantIds": ["018fa51f-fda1-79f4-8461-2cb8f1cabc10"]
}
],
"page": 1,
"pageSize": 20,
"totalCount": 1
},
"status": 200,
"message": "Fetched users.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.users[].id | user.id |
data.users[].email | user.email |
data.users[].firstName | user.firstName |
data.users[].lastName | user.lastName |
data.users[].displayName | Computed: user.lastName + " " + user.firstName — see user |
data.users[].phoneNumber | user.phoneNumber |
data.users[].status | user.status |
data.users[].isPorsennaUser | user.isPorsennaUser |
data.users[].tenantIds | Every live tenantUser.tenantId for this user, narrowed to the requested tenantIds when that filter is set, or the user's full tenant list when it isn't (platform-level listing) |
data.page, data.pageSize, data.totalCount | Pagination metadata |
No separate clientIds: per client, a "client" is a tenant — "one client = one tenant schema" — so a clientIds field would duplicate tenantIds under a second name rather than identify anything distinct. tenantIds is the one array this response needs. Resolved: rendering the "Tenant" column needs each tenant's display name — now that tenant has its own entity page, that's tenant.displayName, looked up for each returned tenantId (same source for the same need on permissionSetUserAssignment.tenantId and elsewhere in this API).
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.users.read in the requested scope.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot read users in this scope.", "details": { "requiredPermission": "platform.users.read" } }, "requestId": "…" }400 ERR_USERS_ACCESS_TOO_MANY_TENANTS
Thrown when tenantIds has more than 50 values.
json
{ "status": 400, "error": { "code": "ERR_USERS_ACCESS_TOO_MANY_TENANTS", "message": "At most 50 tenantIds are allowed per request." }, "requestId": "…" }📥 Endpoint: GET /v1/users/{userId}
Purpose: Full detail for one user, including their current group memberships and direct Permission Set assignments, for the user administration detail screen.
Authorization
Requires permission code platform.users.read, held either globally or for at least one tenant this user belongs to.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
userId | Path | UUID | Yes | - | The user to read | Must exist | user.id |
Request Logic
- Reads
userbyid. - Reads live
permissionSetUserAssignmentrows for thisuserId, joined topermissionSetfor name. - Reads live
groupMembershiprows for thisuserId, joined togroupfor name/tenant and to every livepermissionSetGroupAssignmentfor that group for the group's granted Permission Sets.
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"user": {
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc12",
"email": "jane.doe@example.com",
"firstName": "Jane",
"lastName": "Doe",
"displayName": "Doe Jane",
"phoneNumber": null,
"platformRole": "none",
"isPorsennaUser": false,
"status": "active"
},
"directAssignments": [
{
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc13",
"tenantId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"permissionSetId": "018fa51f-fda1-79f4-8461-2cb8f1cabc11",
"permissionSetName": "Manager",
"restrictions": []
}
],
"groupMemberships": [
{
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc17",
"groupId": "018fa51f-fda1-79f4-8461-2cb8f1cabc16",
"groupName": "Accountants",
"tenantId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"permissionSets": [
{ "id": "018fa51f-fda1-79f4-8461-2cb8f1cabc18", "name": "Accountant" }
],
"restrictions": []
}
]
},
"status": 200,
"message": "Fetched user.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.user.id | user.id |
data.user.email | user.email |
data.user.firstName | user.firstName |
data.user.lastName | user.lastName |
data.user.displayName | Computed: user.lastName + " " + user.firstName — see user |
data.user.phoneNumber | user.phoneNumber |
data.user.platformRole | user.platformRole |
data.user.isPorsennaUser | user.isPorsennaUser |
data.user.status | user.status |
data.directAssignments[].* | permissionSetUserAssignment, joined to permissionSet.name |
data.groupMemberships[].* | groupMembership, joined to group and every live permissionSetGroupAssignment for that group |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.users.read for any tenant this user belongs to.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot read this user.", "details": { "requiredPermission": "platform.users.read" } }, "requestId": "…" }404 ERR_USERS_ACCESS_USER_NOT_FOUND
Thrown when userId does not exist or is soft-deleted.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_USER_NOT_FOUND", "message": "User not found." }, "requestId": "…" }📥 Endpoint: POST /v1/users/{userId}/suspend
Purpose: Withdraws a user's ability to sign in, without discarding their group memberships or direct Permission Set assignments.
Authorization
Requires permission code platform.users.suspend, held either globally or for at least one tenant this user belongs to.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
userId | Path | UUID | Yes | - | The user to suspend | Must exist | user.id |
Request Logic
- Sets
user.status = suspended. - Does not touch
permissionSetUserAssignment,groupMembership, ortenantUserrows.
Transactional Operations
Single-row update; no other table is written.
✅ Success Response (200 OK)
json
{
"data": { "userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc12", "status": "suspended" },
"status": 200,
"message": "User suspended.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.userId | user.id |
data.status | user.status, after update |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.users.suspend for this user's scope.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot suspend this user.", "details": { "requiredPermission": "platform.users.suspend" } }, "requestId": "…" }404 ERR_USERS_ACCESS_USER_NOT_FOUND
Thrown when userId does not exist.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_USER_NOT_FOUND", "message": "User not found." }, "requestId": "…" }409 ERR_USERS_ACCESS_ALREADY_SUSPENDED
Thrown when the user's status is already suspended.
json
{ "status": 409, "error": { "code": "ERR_USERS_ACCESS_ALREADY_SUSPENDED", "message": "User is already suspended." }, "requestId": "…" }📥 Endpoint: POST /v1/users/{userId}/restore
Purpose: Restores a suspended user's ability to sign in, with their prior group memberships and direct Permission Set assignments unchanged.
Authorization
Requires permission code platform.users.suspend (the same code governs both directions of the transition), held either globally or for at least one tenant this user belongs to.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
userId | Path | UUID | Yes | - | The user to restore | Must exist and currently be suspended | user.id |
Request Logic
- Sets
user.status = active. - Does not re-create or alter any
permissionSetUserAssignment,groupMembership, ortenantUserrow — none were removed by suspension, so none need restoring.
Transactional Operations
Single-row update; no other table is written.
✅ Success Response (200 OK)
json
{
"data": { "userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc12", "status": "active" },
"status": 200,
"message": "User restored.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.userId | user.id |
data.status | user.status, after update |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.users.suspend for this user's scope.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot restore this user.", "details": { "requiredPermission": "platform.users.suspend" } }, "requestId": "…" }404 ERR_USERS_ACCESS_USER_NOT_FOUND
Thrown when userId does not exist.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_USER_NOT_FOUND", "message": "User not found." }, "requestId": "…" }409 ERR_USERS_ACCESS_NOT_SUSPENDED
Thrown when the user's status is not suspended (e.g. still invited, or already active).
json
{ "status": 409, "error": { "code": "ERR_USERS_ACCESS_NOT_SUSPENDED", "message": "User is not suspended." }, "requestId": "…" }📥 Endpoint: POST /v1/groups
🚫 Not in MVP. Every group is pre-seeded; there is no group-creation surface in MVP.
Purpose: Creates a new group in a tenant. Platform-only, per the confirmed requirement that group CRUD is a Platform capability.
Authorization
Requires permission code platform.groups.write, held globally (group creation is a Platform-only capability, not delegated to tenant administrators).
Request Headers
None beyond the project's standard headers.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
tenantId | UUID | Yes | The tenant this group belongs to; immutable after creation |
name | String | Yes | The group's name, unique within its tenant |
json
{
"tenantId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"name": "Accountants"
}Request Parameters
None (all input is in the body).
Request Logic
- Rejects the request if an active group with this
namealready exists for thistenantId. - Inserts a
grouprow.
Transactional Operations
Single insert; no other table is written.
✅ Success Response (201 Created)
json
{
"data": { "groupId": "018fa51f-fda1-79f4-8461-2cb8f1cabc16" },
"status": 201,
"message": "Group created.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.groupId | group.id, newly generated |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.groups.write.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot create groups.", "details": { "requiredPermission": "platform.groups.write" } }, "requestId": "…" }409 ERR_USERS_ACCESS_GROUP_NAME_TAKEN
Thrown when an active group with this name already exists for this tenant.
json
{ "status": 409, "error": { "code": "ERR_USERS_ACCESS_GROUP_NAME_TAKEN", "message": "A group with this name already exists in this tenant." }, "requestId": "…" }📥 Endpoint: GET /v1/groups
Purpose: Lists a tenant's groups. There is no group-management screen in MVP (see Functional Requirements) — this backs the group picker on the Invite screen and a tenant's own read-only view of its groups.
Authorization
Requires permission code platform.groups.read (Platform) or tenant.groups.read (held for the queried tenant).
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
tenantId | Query | UUID | Yes | - | The tenant whose groups to list | Must exist | group.tenantId |
page / pageSize | Query | Integer | No | 1 / 20 | Pagination | pageSize capped at 100 | - |
Request Logic
- Reads live
grouprows fortenantId.
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"groups": [
{ "id": "018fa51f-fda1-79f4-8461-2cb8f1cabc16", "name": "Accountants" }
],
"page": 1,
"pageSize": 20,
"totalCount": 1
},
"status": 200,
"message": "Fetched groups.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.groups[].id | group.id |
data.groups[].name | group.name |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks both platform.groups.read and tenant.groups.read for this tenant. details.requiredPermission names the tenant-scoped code tenant.groups.read, the minimum grant that would satisfy this specific request.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot read this tenant's groups.", "details": { "requiredPermission": "tenant.groups.read" } }, "requestId": "…" }📥 Endpoint: DELETE /v1/groups/{groupId}
🚫 Not in MVP. No group-deletion surface in MVP; seeded groups are permanent for now.
Purpose: Deletes a group. Soft delete, so the record that the group once existed is preserved.
Authorization
Requires permission code platform.groups.write, held globally.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
groupId | Path | UUID | Yes | - | The group to delete | Must exist | group.id |
Request Logic
- Rejects if the group has any live member. Checks for any live groupMembership row for this group; if one exists, the whole delete is rejected — the caller removes every member first (via
DELETE /v1/group-memberships/{membershipId}), then retries. - Once no member exists, also soft-deletes every live permissionSetGroupAssignment row for this group — with no members left, its own Permission Set grants have nothing to apply to.
- Sets
group.deletedAt = now().
Transactional Operations
Single transaction: the membership-count guard, the group's own permissionSetGroupAssignment soft-deletes, and the group soft-delete all happen together, so a concurrent membership add cannot race past the guard.
✅ Success Response (204 No Content)
No body.
Response Data Mapping
N/A — no payload.
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.groups.write.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot delete groups.", "details": { "requiredPermission": "platform.groups.write" } }, "requestId": "…" }409 ERR_USERS_ACCESS_GROUP_HAS_MEMBERS
Thrown when the group has one or more live members.
json
{ "status": 409, "error": { "code": "ERR_USERS_ACCESS_GROUP_HAS_MEMBERS", "message": "Remove every member before deleting this group." }, "requestId": "…" }404 ERR_USERS_ACCESS_GROUP_NOT_FOUND
Thrown when groupId does not exist or is already deleted.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_GROUP_NOT_FOUND", "message": "Group not found." }, "requestId": "…" }📥 Endpoint: POST /v1/groups/{groupId}/members
Purpose: Adds a user to a group, optionally with a restriction. Usable from both the Platform UI and a tenant's own UI, per the confirmed requirement that tenant admins can manage their own groups' membership.
Authorization
Requires permission code platform.groups.write (Platform) or tenant.groups.write (held for the group's tenant).
Request Headers
None beyond the project's standard headers.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
userId | UUID | Yes | The user to add |
restrictions | Array<Restriction> | No | Restriction entries for this membership (see restriction collection) |
json
{
"userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc12",
"restrictions": []
}Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
groupId | Path | UUID | Yes | - | The group to add the user to | Must exist and be active | group.id |
Request Logic
userIdmust reference an existing, non-suspended user.- Rejects the request if
userIdalready holds a livegroupMembershipfor a group sharing this group's tenant (exclusivity — a user belongs to at most one group per tenant). - Inserts a
groupMembershiprow;tenantIdis derived fromgroup.tenantIdby trigger. - The insert trigger derives the
tenantUserrow for this tenant if one does not already exist live. - Restriction ceiling: rejects the request if this membership's
restrictionsis broader than the caller's own effectivebuildingrestriction in this tenant — see permission-model. Does not apply when the caller is unrestricted in this tenant. - Propagates group access: for every Permission Set currently granted to this group (every live
permissionSetGroupAssignmentforgroupId), inserts apermissionSetUserAssignmentrow foruserId(tenantId= the group's tenant,sourceGroupId=groupId,restrictionscopied from this membership's ownrestrictions) — unless a row already exists for that (userId,tenantId,permissionSetId) withsourceGroupIdnull, in which case that row is left untouched (a genuine direct grant is never overwritten by propagation; see permissionSetUserAssignment). All-or-nothing: if any propagated insert fails, the whole request fails.
Transactional Operations
Insert into groupMembership, the derived tenantUser upsert (database trigger), and every propagated permissionSetUserAssignment insert all commit as one atomic unit.
✅ Success Response (201 Created)
json
{
"data": { "membershipId": "018fa51f-fda1-79f4-8461-2cb8f1cabc17" },
"status": 201,
"message": "Member added.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.membershipId | groupMembership.id, newly generated |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks both platform.groups.write and tenant.groups.write for this group's tenant. details.requiredPermission names the tenant-scoped code tenant.groups.write, the minimum grant that would satisfy this specific request.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot manage this group's members.", "details": { "requiredPermission": "tenant.groups.write" } }, "requestId": "…" }404 ERR_USERS_ACCESS_GROUP_NOT_FOUND
Thrown when groupId does not exist or is inactive.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_GROUP_NOT_FOUND", "message": "Group not found." }, "requestId": "…" }404 ERR_USERS_ACCESS_USER_NOT_FOUND
Thrown when userId does not exist.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_USER_NOT_FOUND", "message": "User not found." }, "requestId": "…" }409 ERR_USERS_ACCESS_GROUP_MEMBERSHIP_CONFLICT
Thrown when userId already belongs to a different group sharing this group's tenant.
json
{ "status": 409, "error": { "code": "ERR_USERS_ACCESS_GROUP_MEMBERSHIP_CONFLICT", "message": "User already belongs to a group in this tenant." }, "requestId": "…" }403 ERR_USERS_ACCESS_RESTRICTION_EXCEEDS_GRANTER
Thrown when restrictions is broader than the caller's own effective building restriction in this tenant (see permission-model).
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_RESTRICTION_EXCEEDS_GRANTER", "message": "You cannot grant access to buildings outside your own restriction.", "dimension": "building", "grantersValues": ["018fa51f-fda1-79f4-8461-2cb8f1cabc14"] }, "requestId": "…" }📥 Endpoint: DELETE /v1/group-memberships/{membershipId}
Purpose: Removes a member from a group. Soft delete.
Authorization
Requires permission code platform.groups.write (Platform) or tenant.groups.write (held for the membership's tenant).
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
membershipId | Path | UUID | Yes | - | The membership to revoke | Must exist | groupMembership.id |
Request Logic
- Sets
groupMembership.deletedAt = now(). - Does not delete the derived
tenantUserrow — the user may still belong to the tenant through another live grant; if this was their last live grant for the tenant,tenantUseris left in place as a record that membership once existed, consistent with soft-delete throughout this feature. - Reverses propagated access: soft-deletes every
permissionSetUserAssignmentrow that carries this membership'ssourceGroupIdfor this user and tenant — unless that row'ssourceGroupIdhas since been cleared to null by a direct grant claiming it (see permissionSetUserAssignment), in which case it is left in place. All-or-nothing with the membership removal itself.
Transactional Operations
The membership soft-delete and every reversed (soft-deleted) propagated permissionSetUserAssignment row commit as one atomic unit.
✅ Success Response (204 No Content)
No body.
Response Data Mapping
N/A — no payload.
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks both platform.groups.write and tenant.groups.write for this membership's tenant. details.requiredPermission names the tenant-scoped code tenant.groups.write, the minimum grant that would satisfy this specific request.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot remove this member.", "details": { "requiredPermission": "tenant.groups.write" } }, "requestId": "…" }404 ERR_USERS_ACCESS_MEMBERSHIP_NOT_FOUND
Thrown when membershipId does not exist or is already revoked.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_MEMBERSHIP_NOT_FOUND", "message": "Membership not found." }, "requestId": "…" }📥 Endpoint: GET /v1/group-memberships
🚫 Not in MVP. Membership listing isn't needed by any MVP screen (only add/remove are).
Purpose: Lists the live members of a group, or the live group membership of a user.
Authorization
Requires permission code platform.groups.read (Platform) or tenant.groups.read (held for the requested tenant).
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
groupId | Query | UUID | Conditional | - | List members of this group; required unless userId is given | Must exist | groupMembership.groupId |
userId | Query | UUID | Conditional | - | List this user's group membership(s); required unless groupId is given | Must exist | groupMembership.userId |
page / pageSize | Query | Integer | No | 1 / 20 | Pagination | pageSize capped at 100 | - |
Requiring at least one of groupId or userId keeps an omitted filter from quietly returning every membership row in the system.
Request Logic
- Reads live
groupMembershiprows matching the given filter(s).
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"memberships": [
{
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc17",
"userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc12",
"groupId": "018fa51f-fda1-79f4-8461-2cb8f1cabc16",
"tenantId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"restrictions": []
}
],
"page": 1,
"pageSize": 20,
"totalCount": 1
},
"status": 200,
"message": "Fetched group memberships.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.memberships[].* | groupMembership |
❌ Error Responses
400 ERR_USERS_ACCESS_MISSING_FILTER
Thrown when neither groupId nor userId is supplied.
json
{ "status": 400, "error": { "code": "ERR_USERS_ACCESS_MISSING_FILTER", "message": "groupId or userId is required." }, "requestId": "…" }403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks both platform.groups.read and tenant.groups.read for the requested tenant. details.requiredPermission names the tenant-scoped code tenant.groups.read, the minimum grant that would satisfy this specific request.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot read memberships in this scope.", "details": { "requiredPermission": "tenant.groups.read" } }, "requestId": "…" }📥 Endpoint: GET /v1/permission-sets
Purpose: Lists the Permission Sets available to grant, for the assignment picker (direct assignment or group assignment).
Authorization
Requires permission code platform.users.invite, platform.permission-set-user-assignments.write, or platform.permission-set-group-assignments.write, held either globally or for the tenantId being queried.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
tenantId | Query | UUID | No | - | List sets assignable in this tenant (global sets, plus organization sets with an existing live assignment in this tenant) | Must exist | Derived — see Request Logic; not a stored column on permissionSet |
page / pageSize | Query | Integer | No | 1 / 20 | Pagination | pageSize capped at 100 | - |
Request Logic
- Without
tenantId: returns global, active Permission Sets only. - With
tenantId: returns global, active sets, plusorganizationsets that are either (a)restrictedToTenantIdequal to thattenantId(always shown to their locked tenant, regardless of assignment history), or (b)restrictedToTenantId IS NULLand have at least one live permissionSetGroupAssignment or permissionSetUserAssignment scoped to thattenantId(unlocked sets are curated by prior use, not stored ownership). A set locked to a different tenant never appears, regardless of assignment history. - Decided: kept as an explicit
tenantIdquery parameter. Matches the convention every other scoped read in this catalog uses (seeGET /v1/permission-model/diagnose); authorization already governs the security question correctly (the permission code held either globally or for the queried tenant), so a Porsenna caller picking sets for a tenant they don't belong to — inviting into or creating a group for that tenant — is already handled without needing a different mechanism.
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"permissionSets": [
{
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc11",
"key": "tenant-admin",
"name": "Tenant Administrator",
"visibility": "global",
"level": 100
}
],
"page": 1,
"pageSize": 20,
"totalCount": 1
},
"status": 200,
"message": "Fetched permission sets.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}level is included here (a cheap scalar column) so the Roles List's rank column has what it needs without a second call; rules, active, and restrictedToTenantId are not — see GET /v1/permission-sets/{permissionSetId} below for the full record.
Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.permissionSets[].id | permissionSet.id |
data.permissionSets[].key | permissionSet.key |
data.permissionSets[].name | permissionSet.name |
data.permissionSets[].visibility | permissionSet.visibility |
data.permissionSets[].level | permissionSet.level |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks all three of platform.users.invite, platform.permission-set-user-assignments.write, and platform.permission-set-group-assignments.write for the requested scope — any one suffices. details.requiredPermission names whichever of the three the caller is closest to holding in that scope; when none is held at all, it names platform.users.invite.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot read permission sets in this scope.", "details": { "requiredPermission": "platform.users.invite" } }, "requestId": "…" }📥 Endpoint: GET /v1/permission-sets/{permissionSetId}
Purpose: Reads one Permission Set's full record — including
rules,active, andrestrictedToTenantId, which the list endpoint above deliberately omits. Backs the Role Detail screen and the Role permission matrix (see Permission Model), which need the full record one set at a time rather than paying for it on every list call.
Authorization
Requires permission code platform.permission-sets.read, held globally — matching the .read/.write split this catalog already uses (see GET /v1/permission-set-user-assignments). permissionSet carries no tenantId, so there is no tenant to scope the check to.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
permissionSetId | Path | UUID | Yes | - | The Permission Set to read | Must exist | permissionSet.id |
Request Logic
- Reads the
permissionSetrow and returns every field a management screen needs — the list endpoint's four fields plusrules,active,restrictedToTenantId, andlevel.
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc11",
"key": "tenant-admin",
"name": "Tenant Administrator",
"visibility": "global",
"restrictedToTenantId": null,
"active": true,
"level": 100,
"rules": [
{ "permissionCode": "tenant.documents.read", "effect": "allow", "scope": "all" },
{ "permissionCode": "tenant.users.manage", "effect": "deny", "scope": "all" }
]
},
"status": 200,
"message": "Fetched permission set.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.id | permissionSet.id |
data.key | permissionSet.key |
data.name | permissionSet.name |
data.visibility | permissionSet.visibility |
data.restrictedToTenantId | permissionSet.restrictedToTenantId |
data.active | permissionSet.active |
data.level | permissionSet.level |
data.rules | permissionSet.rules |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-sets.read globally.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot read this permission set.", "details": { "requiredPermission": "platform.permission-sets.read" } }, "requestId": "…" }404 ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND
Thrown when permissionSetId doesn't exist or is soft-deleted.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND", "message": "Permission set not found." }, "requestId": "…" }📥 Endpoint: GET /v1/permission-sets/matrix
Purpose: Bulk read backing the Role permission matrix screen — every active Permission Set's rules, plus the full permission-code catalog for the matrix's rows, in one response. A dedicated endpoint rather than the caller looping
GET /v1/permission-sets/{permissionSetId}once per set, since the matrix always needs every set at once and never a partial view.
Authorization
Requires permission code platform.permission-sets.read, held globally — same as GET /v1/permission-sets/{permissionSetId}.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
None.
Request Logic
- Reads every active
permissionSetrow (id,key,name,level,rules), ordered byleveldescending to match the Roles list's rank order. - Reads the full capability catalog (see The capability catalog) for
permissionCodes— the matrix's row set, independent of which sets have a rule for each code. - Does not compute cell values server-side: the response gives each set's own
rulesarray as-is: the screen rendersallow/denyfor a code with a rule,—for a code the set'srulesarray doesn't mention. No cross-set precedence applies here (that's only for evaluating one actor's request — see Rule precedence); each cell is one set's own rule, or the absence of one.
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"permissionCodes": [
"tenant.documents.read",
"tenant.users.manage"
],
"permissionSets": [
{
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc11",
"key": "tenant-admin",
"name": "Tenant Administrator",
"level": 100,
"rules": [
{ "permissionCode": "tenant.documents.read", "effect": "allow", "scope": "all" },
{ "permissionCode": "tenant.users.manage", "effect": "deny", "scope": "all" }
]
}
]
},
"status": 200,
"message": "Fetched the permission matrix.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.permissionCodes | The capability catalog's registered codes |
data.permissionSets[].id | permissionSet.id |
data.permissionSets[].key | permissionSet.key |
data.permissionSets[].name | permissionSet.name |
data.permissionSets[].level | permissionSet.level |
data.permissionSets[].rules | permissionSet.rules |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-sets.read globally.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot read the permission matrix.", "details": { "requiredPermission": "platform.permission-sets.read" } }, "requestId": "…" }📥 Endpoint: POST /v1/permission-sets
🚫 Not in MVP. Permission Sets are seeded, not created through the product, in MVP.
Purpose: Creates a new,
organization-visibility Permission Set together with its rules, in one call. Global Permission Sets are seeded from code at deploy time — see permissionSet — and are never created through this endpoint.
Authorization
Requires permission code platform.permission-sets.write, held globally. Decided: always global, never tenant-scoped, with no tenant-scoped variant — permissionSet carries no tenantId, so there is no tenant to check the caller's grant against, and a Permission Set is Platform-managed content regardless of visibility; a freshly created organization set isn't owned by any tenant until its first assignment (or a restrictedToTenantId lock) makes it so. A tenant admin cannot be granted the ability to create or edit sets for their own tenant through this endpoint.
Request Headers
None beyond the project's standard headers.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | String | Yes | Human-readable name shown in the UI |
restrictedToTenantId | UUID | No | Hard exclusivity lock to one tenant (see permissionSet); left null, the set is freely reusable across any tenant once assigned |
active | Boolean | No | Defaults to true |
rules | Array<Rule> | No | Each { permissionCode, effect, scope }; defaults to []. scope accepts only all — see PermissionRuleScope |
level | Integer | No | Rank used by the role-hierarchy rule; defaults to 0. Must not be higher than the caller's own highest held level (a peer level is allowed) |
json
{
"name": "Facility Viewer",
"restrictedToTenantId": null,
"active": true,
"rules": [
{ "permissionCode": "tenant.buildings.read", "effect": "allow", "scope": "all" }
],
"level": 20
}Request Parameters
None (all input is in the body).
Request Logic
visibilityis always set toorganizationby this endpoint — there is no way to requestglobalhere (see Authorization above).keyis leftnull— it is set for global sets only, assigned by the seed script, never by this endpoint.- Each rule's
permissionCodeis validated only as non-empty and dot-separated, exactly as decided in Permission Model — membership in the capability catalog is checked by the repo-wide reconciling test, not by this endpoint at write time. effectmust beallowordeny;scopemust beall— anything else is rejected.- Two rules with the same
permissionCodeandscopeare a duplicate and are rejected — collapsing them silently would hide a contradiction the author meant to see. - Rejects if
restrictedToTenantIdis supplied and doesn't resolve to an existing tenant. - Rejects if
levelis supplied above the caller's own highest heldlevel(a peer level is allowed) — otherwise this endpoint would be a trivial way around the role-hierarchy rule: mint a set above yourself, then grant it to yourself. Omitted,leveldefaults to0(see permissionSet). - Auto-provisions a group per tenant when unrestricted: if
restrictedToTenantIdis leftnull(the deliberately reusable-everywhere case — see permissionSet), also creates one group per currently-active tenant, named after this set, and one permissionSetGroupAssignment linking each new group to this set. If the set's name collides with an existing group name in any active tenant (groupenforces(tenantId, name)uniqueness), the entire request is rejected — no partial provisioning, including thepermissionSetinsert itself.
Transactional Operations
Insert into permissionSet; when restrictedToTenantId is left null, also one insert into group and one into permissionSetGroupAssignment per currently-active tenant — all in the same transaction, all-or-nothing with the permissionSet insert.
✅ Success Response (201 Created)
json
{
"data": { "permissionSetId": "018fa51f-fda1-79f4-8461-2cb8f1cabc30" },
"status": 201,
"message": "Permission set created.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.permissionSetId | permissionSet.id, newly generated |
❌ Error Responses
400 ERR_USERS_ACCESS_INVALID_RULE_SHAPE
Thrown when a rule's permissionCode is empty or not dot-separated, effect isn't allow/deny, scope isn't all, or two rules duplicate the same (permissionCode, scope).
json
{ "status": 400, "error": { "code": "ERR_USERS_ACCESS_INVALID_RULE_SHAPE", "message": "One or more rules are invalid." }, "requestId": "…" }403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-sets.write globally.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot create permission sets.", "details": { "requiredPermission": "platform.permission-sets.write" } }, "requestId": "…" }404 ERR_USERS_ACCESS_TENANT_NOT_FOUND
Thrown when restrictedToTenantId is supplied but doesn't resolve to an existing tenant.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_TENANT_NOT_FOUND", "message": "Tenant not found." }, "requestId": "…" }403 ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH
Thrown when level is supplied above the caller's own highest held level.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH", "message": "You cannot create a role above your own level." }, "requestId": "…" }409 ERR_USERS_ACCESS_GROUP_NAME_CONFLICT
Thrown when restrictedToTenantId is left null and the set's name already matches an existing, active group's name in one or more active tenants, so auto-provisioning cannot create that tenant's group. Lists every conflicting tenant so the caller can rename the set or the colliding group first.
json
{ "status": 409, "error": { "code": "ERR_USERS_ACCESS_GROUP_NAME_CONFLICT", "message": "A group named \"Facility Viewer\" already exists in one or more tenants.", "conflictingTenantIds": ["018fa51f-fda1-79f4-8461-2cb8f1cabc10"] }, "requestId": "…" }📥 Endpoint: PATCH /v1/permission-sets/{permissionSetId}
Purpose: Updates a Permission Set's rules, name, active flag, or
restrictedToTenantId— this is the "assign permissions to a Permission Set" write the Roles screens need (the Edit rules picker). Only the fields supplied in the request change.
Authorization
Requires permission code platform.permission-sets.write, held globally — same reasoning as POST /v1/permission-sets above; there is still no tenant to scope the check to.
Request Headers
None beyond the project's standard headers.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | String | No | New name |
restrictedToTenantId | UUID or null | No | Forbidden when visibility = global; set to lock an organization set to one tenant, or null to unlock it |
active | Boolean | No | |
rules | Array<Rule> | No | Replaces the whole array — this is a set of rules, not a log of them; there is no per-rule add/remove call, so the caller (the Edit rules screen) sends the full resulting list each time |
level | Integer | No | Rank used by the role-hierarchy rule. Raising it is rejected if the new value would be higher than the caller's own highest held level (raising it to a peer level is allowed) |
json
{
"rules": [
{ "permissionCode": "tenant.documents.read", "effect": "allow", "scope": "all" },
{ "permissionCode": "tenant.users.manage", "effect": "deny", "scope": "all" }
]
}Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
permissionSetId | Path | UUID | Yes | - | The Permission Set to update | Must exist | permissionSet.id |
Request Logic
visibilityandkeyare never editable through this endpoint — a global set's identity is fixed at seed time, andorganizationsets never get promoted toglobalafter creation (see permissionSet).- Rules validate exactly as on
POST /v1/permission-sets(format-onlypermissionCodecheck,effect/scopelegality, no duplicate(permissionCode, scope)pairs). - Rejects if
restrictedToTenantIdis supplied on aglobalset. - Rejects if
restrictedToTenantIdis supplied and doesn't resolve to an existing tenant. - Rejects if
levelis supplied above the caller's own highest heldlevel(a peer level is allowed) — same rationale as on create; otherwise an existing low-level set could be quietly promoted above its granter. - On commit, invalidates the decided-capability cache for every actor currently holding this set, direct or via a group — this is exactly Permission Model's cache-invalidation trigger 2, now backed by a real write endpoint instead of a hypothetical one.
Transactional Operations
Single update to permissionSet; the cache invalidation sweep runs in the same request, outside the DB transaction (the cache is not transactional with Postgres).
✅ Success Response (200 OK)
json
{
"data": { "permissionSetId": "018fa51f-fda1-79f4-8461-2cb8f1cabc11" },
"status": 200,
"message": "Permission set updated.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.permissionSetId | permissionSet.id |
❌ Error Responses
400 ERR_USERS_ACCESS_INVALID_RULE_SHAPE
Same conditions as on create.
json
{ "status": 400, "error": { "code": "ERR_USERS_ACCESS_INVALID_RULE_SHAPE", "message": "One or more rules are invalid." }, "requestId": "…" }400 ERR_USERS_ACCESS_RESTRICTION_ON_GLOBAL_SET
Thrown when restrictedToTenantId is supplied for a global set.
json
{ "status": 400, "error": { "code": "ERR_USERS_ACCESS_RESTRICTION_ON_GLOBAL_SET", "message": "A global permission set cannot be restricted to a tenant." }, "requestId": "…" }403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-sets.write globally.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot update permission sets.", "details": { "requiredPermission": "platform.permission-sets.write" } }, "requestId": "…" }404 ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND
Thrown when permissionSetId doesn't exist or is soft-deleted.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND", "message": "Permission set not found." }, "requestId": "…" }404 ERR_USERS_ACCESS_TENANT_NOT_FOUND
Thrown when restrictedToTenantId is supplied but doesn't resolve to an existing tenant.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_TENANT_NOT_FOUND", "message": "Tenant not found." }, "requestId": "…" }403 ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH
Thrown when level is supplied above the caller's own highest held level.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH", "message": "You cannot raise a role above your own level." }, "requestId": "…" }📥 Endpoint: DELETE /v1/permission-sets/{permissionSetId}
🚫 Not in MVP. No Permission Set deletion surface in MVP; seeded sets are permanent for now.
Purpose: Deletes a Permission Set. Soft delete, so the record that it once existed is preserved.
Authorization
Requires permission code platform.permission-sets.write, held globally — same reasoning as POST/PATCH above.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
permissionSetId | Path | UUID | Yes | - | The Permission Set to delete | Must exist | permissionSet.id |
Request Logic
- Rejects if anyone currently holds the set. Checks for any live permissionSetUserAssignment row (direct or propagated via
sourceGroupId) and any live permissionSetGroupAssignment row (even one whose group currently has no members — the group still holds the grant) referencing this set. If either exists, the whole delete is rejected — see permission-model. - No cascading auto-revoke: the caller must unassign the set from every user and group first (via
DELETE /v1/permission-set-user-assignments/{assignmentId}andDELETE /v1/permission-set-group-assignments/{assignmentId}), then retry the delete. - Sets
permissionSet.deletedAt = now().
Transactional Operations
Single-row soft-delete of permissionSet, guarded by the in-use check above (read and delete happen in the same transaction, so a concurrent new assignment cannot race past the guard).
✅ Success Response (204 No Content)
No body.
Response Data Mapping
N/A — no payload.
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-sets.write globally.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot delete permission sets.", "details": { "requiredPermission": "platform.permission-sets.write" } }, "requestId": "…" }404 ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND
Thrown when permissionSetId does not exist or is already deleted.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND", "message": "Permission set not found." }, "requestId": "…" }409 ERR_USERS_ACCESS_PERMISSION_SET_IN_USE
Thrown when anyone currently holds the set. Lists the users and groups holding it, direct assignments and group assignments separately, so the caller knows exactly what to unassign first.
json
{
"status": 409,
"error": {
"code": "ERR_USERS_ACCESS_PERMISSION_SET_IN_USE",
"message": "This permission set is still assigned and cannot be deleted.",
"users": [
{ "userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc40", "tenantId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10", "sourceGroupId": null }
],
"groups": [
{ "groupId": "018fa51f-fda1-79f4-8461-2cb8f1cabc50", "tenantId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10", "name": "Accountants" }
]
},
"requestId": "…"
}📥 Endpoint: POST /v1/permission-set-user-assignments
Purpose: Grants an additional Permission Set directly to an existing user — the only mechanism for Porsenna users, and a minor, edge-case path for tenant users outside any group.
Authorization
Requires permission code platform.permission-set-user-assignments.write, held either globally or for the requested tenantId. The granted permissionSetId's level must also be no higher than the caller's own highest held level (a peer level is allowed) — see permission-model.
Request Headers
None beyond the project's standard headers.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
userId | UUID | Yes | The user to grant the Permission Set to |
tenantId | UUID | No | The tenant this grant applies to; omit for a platform-level grant |
permissionSetId | UUID | Yes | The Permission Set to grant |
restrictions | Array<Restriction> | No | Restriction entries for this grant (see restriction collection) |
json
{
"userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc12",
"tenantId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"permissionSetId": "018fa51f-fda1-79f4-8461-2cb8f1cabc11",
"restrictions": []
}Request Parameters
None (all input is in the body).
Request Logic
userIdmust reference an existing, non-suspended user.permissionSetIdmust exist, beactive, and haverestrictedToTenantIdeither null or equal to this grant's owntenantId(see permissionSet).- Rejects the request if this exact (
userId,tenantId,permissionSetId) grant already exists and is live. - Rejects the request if
permissionSetId'slevelis higher than the caller's own highest heldlevel. - Rejects the request if
permissionSetIdis the Admin-manažer Permission Set andrestrictionscontains noclientGroupentry (see permission-model). - Restriction ceiling: rejects the request if
restrictionsis broader, for its dimension, than the caller's own effective restriction in that scope — see permission-model. Does not apply when the caller is unrestricted in that scope. - The insert trigger on
permissionSetUserAssignmentderives thetenantUserrow whentenantIdis set.
Transactional Operations
Single insert into permissionSetUserAssignment; the derived tenantUser upsert (when tenantId is set) runs in the same transaction via the database trigger.
✅ Success Response (201 Created)
json
{
"data": { "assignmentId": "018fa51f-fda1-79f4-8461-2cb8f1cabc13" },
"status": 201,
"message": "Permission set granted.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.assignmentId | permissionSetUserAssignment.id, newly generated |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-set-user-assignments.write for the requested scope.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot grant permission sets in this scope.", "details": { "requiredPermission": "platform.permission-set-user-assignments.write" } }, "requestId": "…" }404 ERR_USERS_ACCESS_USER_NOT_FOUND
Thrown when userId does not exist.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_USER_NOT_FOUND", "message": "User not found." }, "requestId": "…" }404 ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND
Thrown when permissionSetId does not exist, is inactive, or is locked to a different tenant via restrictedToTenantId (deliberately indistinguishable from not existing).
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND", "message": "Permission set not found." }, "requestId": "…" }409 ERR_USERS_ACCESS_ASSIGNMENT_ALREADY_EXISTS
Thrown when this exact grant already exists and is live.
json
{ "status": 409, "error": { "code": "ERR_USERS_ACCESS_ASSIGNMENT_ALREADY_EXISTS", "message": "This permission set is already granted." }, "requestId": "…" }403 ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH
Thrown when permissionSetId's level is higher than the caller's own highest held level.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH", "message": "You cannot grant a role above your own level." }, "requestId": "…" }400 ERR_USERS_ACCESS_CLIENT_GROUP_REQUIRED
Thrown when permissionSetId is the Admin-manažer Permission Set and restrictions contains no clientGroup entry (see permission-model).
json
{ "status": 400, "error": { "code": "ERR_USERS_ACCESS_CLIENT_GROUP_REQUIRED", "message": "Admin-manažer must be granted with at least one client group." }, "requestId": "…" }403 ERR_USERS_ACCESS_RESTRICTION_EXCEEDS_GRANTER
Thrown when restrictions is broader, for its dimension, than the caller's own effective restriction in that scope (see permission-model).
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_RESTRICTION_EXCEEDS_GRANTER", "message": "You cannot grant access to buildings outside your own restriction.", "dimension": "building", "grantersValues": ["018fa51f-fda1-79f4-8461-2cb8f1cabc14"] }, "requestId": "…" }📥 Endpoint: DELETE /v1/permission-set-user-assignments/{assignmentId}
Purpose: Revokes one direct Permission Set grant. Soft delete.
Authorization
Requires permission code platform.permission-set-user-assignments.write, held either globally or for the assignment's tenantId.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
assignmentId | Path | UUID | Yes | - | The grant to revoke | Must exist | permissionSetUserAssignment.id |
Request Logic
- Sets
permissionSetUserAssignment.deletedAt = now(). - Does not delete the derived
tenantUserrow — see the equivalent note on the group-membership removal endpoint; the same soft-delete-preserves-history rule applies.
Transactional Operations
Single-row soft-delete; no other table is written.
✅ Success Response (204 No Content)
No body.
Response Data Mapping
N/A — no payload.
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-set-user-assignments.write for this assignment's scope.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot revoke this assignment.", "details": { "requiredPermission": "platform.permission-set-user-assignments.write" } }, "requestId": "…" }404 ERR_USERS_ACCESS_ASSIGNMENT_NOT_FOUND
Thrown when assignmentId does not exist or is already revoked.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_ASSIGNMENT_NOT_FOUND", "message": "Assignment not found." }, "requestId": "…" }📥 Endpoint: GET /v1/permission-set-user-assignments
Purpose: Lists the live direct Permission Set grants for a user or a tenant, for the assignment administration screen.
Authorization
Requires permission code platform.permission-set-user-assignments.read, held either globally or for the requested tenantId.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
userId | Query | UUID | Conditional | - | List grants for this user; required unless tenantId is given | Must exist | permissionSetUserAssignment.userId |
tenantId | Query | UUID | Conditional | - | List grants for this tenant; required unless userId is given | Must exist | permissionSetUserAssignment.tenantId |
page / pageSize | Query | Integer | No | 1 / 20 | Pagination | pageSize capped at 100 | - |
Requiring at least one of userId or tenantId — rather than defaulting to every assignment in the system — keeps an omitted filter from quietly returning the platform's entire grant list.
Request Logic
- Reads live
permissionSetUserAssignmentrows matching the given filter(s), joined topermissionSetfor name.
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"assignments": [
{
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc13",
"userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc12",
"tenantId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"permissionSetId": "018fa51f-fda1-79f4-8461-2cb8f1cabc11",
"permissionSetName": "Manager",
"restrictions": []
}
],
"page": 1,
"pageSize": 20,
"totalCount": 1
},
"status": 200,
"message": "Fetched permission set assignments.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.assignments[].id | permissionSetUserAssignment.id |
data.assignments[].userId | permissionSetUserAssignment.userId |
data.assignments[].tenantId | permissionSetUserAssignment.tenantId |
data.assignments[].permissionSetId | permissionSetUserAssignment.permissionSetId |
data.assignments[].permissionSetName | permissionSet.name, joined |
data.assignments[].restrictions | permissionSetUserAssignment.restrictions |
❌ Error Responses
400 ERR_USERS_ACCESS_MISSING_FILTER
Thrown when neither userId nor tenantId is supplied.
json
{ "status": 400, "error": { "code": "ERR_USERS_ACCESS_MISSING_FILTER", "message": "userId or tenantId is required." }, "requestId": "…" }403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-set-user-assignments.read for the requested scope.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot read assignments in this scope.", "details": { "requiredPermission": "platform.permission-set-user-assignments.read" } }, "requestId": "…" }📥 Endpoint: POST /v1/groups/{groupId}/permission-set-assignments
🚫 Not in MVP. A group's Permission Set assignment is fixed at seed time in MVP.
Purpose: Grants a Permission Set to a group — the normal mechanism by which tenant users gain access.
Authorization
Requires permission code platform.permission-set-group-assignments.write, held globally or for the group's tenant. The granted permissionSetId's level must also be no higher than the caller's own highest held level (a peer level is allowed) — see permission-model.
Request Headers
None beyond the project's standard headers.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
permissionSetId | UUID | Yes | The Permission Set to grant to the group; must be active, and restrictedToTenantId must be null or equal to this group's tenantId (see permissionSet) |
json
{ "permissionSetId": "018fa51f-fda1-79f4-8461-2cb8f1cabc18" }Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
groupId | Path | UUID | Yes | - | The group to grant the Permission Set to | Must exist and be active | group.id |
Request Logic
permissionSetIdmust exist, beactive, and haverestrictedToTenantIdeither null or equal to this group'stenantId.- Rejects the request if this exact (
groupId,permissionSetId) grant already exists and is live. - Rejects the request if
permissionSetId'slevelis higher than the caller's own highest heldlevel. - Inserts a
permissionSetGroupAssignmentrow;tenantIdis derived fromgroup.tenantIdby trigger.
Transactional Operations
Single insert; no other table is written.
✅ Success Response (201 Created)
json
{
"data": { "assignmentId": "018fa51f-fda1-79f4-8461-2cb8f1cabc19" },
"status": 201,
"message": "Permission set granted to group.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.assignmentId | permissionSetGroupAssignment.id, newly generated |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-set-group-assignments.write for this group's tenant.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot grant permission sets to this group.", "details": { "requiredPermission": "platform.permission-set-group-assignments.write" } }, "requestId": "…" }404 ERR_USERS_ACCESS_GROUP_NOT_FOUND
Thrown when groupId does not exist or is inactive.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_GROUP_NOT_FOUND", "message": "Group not found." }, "requestId": "…" }404 ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND
Thrown when permissionSetId does not exist, is inactive, or is locked to a different tenant via restrictedToTenantId (deliberately indistinguishable from not existing).
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_PERMISSION_SET_NOT_FOUND", "message": "Permission set not found." }, "requestId": "…" }409 ERR_USERS_ACCESS_ASSIGNMENT_ALREADY_EXISTS
Thrown when this exact grant already exists and is live.
json
{ "status": 409, "error": { "code": "ERR_USERS_ACCESS_ASSIGNMENT_ALREADY_EXISTS", "message": "This permission set is already granted to this group." }, "requestId": "…" }403 ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH
Thrown when permissionSetId's level is higher than the caller's own highest held level.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_ROLE_LEVEL_TOO_HIGH", "message": "You cannot grant a role above your own level." }, "requestId": "…" }📥 Endpoint: DELETE /v1/permission-set-group-assignments/{assignmentId}
🚫 Not in MVP. A group's Permission Set assignment is fixed at seed time in MVP.
Purpose: Revokes a group's Permission Set grant. Soft delete.
Authorization
Requires permission code platform.permission-set-group-assignments.write, held globally or for the assignment's tenant.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
assignmentId | Path | UUID | Yes | - | The grant to revoke | Must exist | permissionSetGroupAssignment.id |
Request Logic
- Sets
permissionSetGroupAssignment.deletedAt = now(). - Does not alter
groupMembershiportenantUserrows — the group's members simply lose whatever access that Permission Set granted; their membership in the group itself is untouched.
Transactional Operations
Single-row soft-delete; no other table is written.
✅ Success Response (204 No Content)
No body.
Response Data Mapping
N/A — no payload.
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-set-group-assignments.write for this assignment's tenant.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot revoke this assignment.", "details": { "requiredPermission": "platform.permission-set-group-assignments.write" } }, "requestId": "…" }404 ERR_USERS_ACCESS_ASSIGNMENT_NOT_FOUND
Thrown when assignmentId does not exist or is already revoked.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_ASSIGNMENT_NOT_FOUND", "message": "Assignment not found." }, "requestId": "…" }📥 Endpoint: GET /v1/permission-set-group-assignments
🚫 Not in MVP. A group's Permission Set assignment is fixed at seed time in MVP.
Purpose: Lists a group's live Permission Set grants.
Authorization
Requires permission code platform.permission-set-group-assignments.read, held globally or for the requested group's tenant.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
groupId | Query | UUID | Yes | - | List grants for this group | Must exist | permissionSetGroupAssignment.groupId |
page / pageSize | Query | Integer | No | 1 / 20 | Pagination | pageSize capped at 100 | - |
Request Logic
- Reads live
permissionSetGroupAssignmentrows forgroupId, joined topermissionSetfor name.
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"assignments": [
{
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc19",
"groupId": "018fa51f-fda1-79f4-8461-2cb8f1cabc16",
"permissionSetId": "018fa51f-fda1-79f4-8461-2cb8f1cabc18",
"permissionSetName": "Accountant"
}
],
"page": 1,
"pageSize": 20,
"totalCount": 1
},
"status": 200,
"message": "Fetched group permission set assignments.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.assignments[].id | permissionSetGroupAssignment.id |
data.assignments[].groupId | permissionSetGroupAssignment.groupId |
data.assignments[].permissionSetId | permissionSetGroupAssignment.permissionSetId |
data.assignments[].permissionSetName | permissionSet.name, joined |
❌ Error Responses
403 ERR_USERS_ACCESS_FORBIDDEN
Thrown when the caller lacks platform.permission-set-group-assignments.read for this group's tenant.
json
{ "status": 403, "error": { "code": "ERR_USERS_ACCESS_FORBIDDEN", "message": "You cannot read this group's assignments.", "details": { "requiredPermission": "platform.permission-set-group-assignments.read" } }, "requestId": "…" }📥 Endpoint: GET /v1/me/tenants
Purpose: Self-service listing of the tenants the currently authenticated user belongs to, for the tenant switcher. Never accepts a target user — always the caller's own membership.
Authorization
Requires a valid, authenticated session; no permission code beyond authentication itself. This endpoint infers the user from the session and can never be widened with a parameter to read another user's memberships.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
None. The user is inferred from the session; there is no userId parameter.
Request Logic
- Reads live
tenantUserrows for the caller's ownuserId.
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"tenants": [
{ "tenantId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10", "status": "active" }
]
},
"status": 200,
"message": "Fetched your tenants.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.tenants[].tenantId | tenantUser.tenantId |
data.tenants[].status | tenantUser.status |
❌ Error Responses
None beyond the project's standard authentication failure.
📥 Endpoint: GET /v1/me/capabilities
Purpose: Self-service read of the caller's own already-decided capability codes for one scope — the flat list the client checks membership against, so it never re-fetches rules, re-runs precedence, or asks "would this specific scope be allowed" on its own. See Permission Model — Enforcement on both sides for why this is a dedicated endpoint rather than piggybacked onto
GET /v1/me/tenants.
Authorization
Requires a valid, authenticated session; no permission code beyond authentication itself. Same shape as GET /v1/me/tenants: this endpoint infers the actor from the session and can never be widened with a parameter to read another actor's capabilities.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
tenantId | Query | UUID | No | - | The scope to evaluate. Omitted means platform scope. | Must exist when given. | Matched against the caller's permissionSetUserAssignment rows the same way evaluation does |
Request Logic
- Evaluates exactly as an ordinary request does (see Permission Model — Evaluating a request): reads every permissionSetUserAssignment row that applies to the caller — in the queried
tenantId(or platform-wide when omitted) plus every platform-wide row regardless — already reflecting both direct grants and every group the caller belongs to, with no separate group join. - Collects every rule naming any capability across the resulting sets, applies deny-first precedence per code, and returns the flat set of codes whose winning rule is
allow. A code with no matching rule at all is simply absent from the list (default deny, not an entry with afalsevalue). - Backed by the decided-capability cache (see Caching) — a single cache read in the common case, keyed on
(actorId, tenantId)or the platform-wide entry whentenantIdis omitted.
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK)
json
{
"data": {
"tenantId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"capabilities": [
"tenant.buildings.read",
"tenant.buildings.manage",
"platform.permission-sets.read"
]
},
"status": 200,
"message": "Fetched your capabilities.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}tenantId is echoed as null when the request omitted it (platform scope).
Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.tenantId | Echoed from the request, or null for platform scope |
data.capabilities | Every permissionCode whose deny-first-resolved winning rule is allow, per Request Logic |
❌ Error Responses
404 ERR_USERS_ACCESS_TENANT_NOT_FOUND
Thrown when tenantId is given but doesn't reference an existing tenant.
json
{ "status": 404, "error": { "code": "ERR_USERS_ACCESS_TENANT_NOT_FOUND", "message": "Tenant not found." }, "requestId": "…" }Design notes
- Not piggybacked on
GET /v1/me/tenants. 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 make an org-wide actor an effective member of most or all tenants, computing a capability list per tenant at that point would mean real, wasted evaluation work for a screen that only needstenantIdandstatus. This endpoint is instead called once, when the client actually enters a tenant (or once at platform scope on session start). - Distinct from the diagnose endpoint. This returns the caller's own flat, already-decided list for one scope;
GET /v1/permission-model/diagnoseexplains one specific actor/capability/scope decision (including someone else's, with the right permission) and is not meant to be called once per capability to reconstruct this list.
📥 Endpoint: GET /v1/permission-model/diagnose
Purpose: Answers, for one actor/capability/scope combination, whether access is granted and why — the winning rule, which Permission Set it came from, and how the actor holds it (direct grant, or through which group). Exists so a misconfigured bundle is debugged by reading an answer, not by reconstructing precedence and restriction cascades by hand. This is the diagnostic read the Permission Model concept requires.
Authorization
Requires permission code platform.permission-model.diagnose, held either globally or for the tenantId being queried (same shape as every other scoped permission code in this project). Deliberately not self-service-only: the primary use case is an administrator debugging someone else's access. A caller may always diagnose their own userId without needing the broader grant — see Request Logic.
Request Headers
None beyond the project's standard headers.
Request Body
None.
Request Parameters
| Name | In | Type | Required | Default | Description | Validation/Notes | DB Mapping |
|---|---|---|---|---|---|---|---|
userId | Query | UUID | Yes | - | The actor being diagnosed. | Must reference an existing user. | permissionSetUserAssignment.userId / groupMembership.userId |
permissionCode | Query | String | Yes | - | The capability being checked (e.g. tenant.buildings.manage). | Checked against the capability catalog; an unknown code is rejected. | permissionSet.rules[].permissionCode |
tenantId | Query | UUID | No | - | The scope being checked. Omitted means platform scope. | Must exist when given. | Matched against each candidate assignment's tenantId (see Request Logic) |
targetId | Query | UUID | No | - | One specific record to also check against any restriction the winning rule's holder carries (e.g. a building id). Only meaningful together with a rule whose holder has a restriction in a matching dimension. | Format depends on dimension. | restriction collection.values |
dimension | Query | String | Required when targetId is given | - | Which restriction dimension targetId belongs to (building or clientGroup). | Must be a dimension in current use — see restriction collection. | (same JSON-DAT) |
Request Logic
- Self-diagnosis bypass: if
userIdequals the caller's own id, the endpoint always answers, regardless of whether the caller holdsplatform.permission-model.diagnose— a person can always ask why they were allowed or refused something. Diagnosing anyone else'suserIdrequires the permission code above, in the scope being queried. - Gathers every candidate assignment exactly as request-evaluation does (see Permission Model — Evaluating a request): the actor's own permissionSetUserAssignment rows matching
tenantId(or platform-scoped,tenantId IS NULL), plus — if the actor holds a groupMembership in the queried tenant — that group's permissionSetGroupAssignment rows. - Collects, from every active Permission Set referenced by a candidate assignment, every rule naming
permissionCode. - If no rule names
permissionCodeanywhere:decision = deny,reason = "no_matching_rule"— the default-deny base case, not a fallback. - If at least one rule matches: applies deny-first precedence (see Permission Model — Rule precedence) — any
denyamong the matches wins outright; otherwise the (single, since onlyallscope exists)allowwins. - The winning rule's holder (the assignment it came from) is identified in the response — whether it's the direct assignment or, for a group-sourced rule, which group.
- If
targetIdanddimensionare both given: additionally checks whether the winning rule's holder carries a restriction in thatdimension. Three possible restriction outcomes, reported separately from the base allow/deny decision (a scope-level allow can still be a record-level deny): no restriction entry for that dimension (unrestricted —targetIdis reachable), a restriction entry whosevaluesincludetargetId(reachable), or a restriction entry whosevaluesdo not include it (not reachable for this specific record) — this does not walk the multi-hop cascade itself (that is the caller's/consuming feature's job to resolvetargetIddown to the restricted dimension before calling this endpoint); the endpoint checks membership only, given a already-resolvedtargetId. - A
denydecision short-circuits:targetId/dimensionare ignored and omitted from the response when the base decision is alreadydeny, since there is no "holder" to check a restriction against.
Transactional Operations
N/A — read-only.
✅ Success Response (200 OK) — allowed, direct grant, no target checked
json
{
"data": {
"decision": "allow",
"reason": "rule_matched",
"winningRule": {
"permissionCode": "tenant.buildings.manage",
"effect": "allow",
"scope": "all"
},
"permissionSet": {
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc11",
"name": "Tenant Administrator"
},
"source": {
"type": "direct",
"assignmentId": "018fa51f-fda1-79f4-8461-2cb8f1cabc12"
},
"restriction": null
},
"status": 200,
"message": "Diagnosis complete.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}✅ Success Response (200 OK) — allowed via group, target reachable
json
{
"data": {
"decision": "allow",
"reason": "rule_matched",
"winningRule": {
"permissionCode": "tenant.buildings.manage",
"effect": "allow",
"scope": "all"
},
"permissionSet": {
"id": "018fa51f-fda1-79f4-8461-2cb8f1cabc11",
"name": "Správci"
},
"source": {
"type": "group",
"groupId": "018fa51f-fda1-79f4-8461-2cb8f1cabc13",
"assignmentId": "018fa51f-fda1-79f4-8461-2cb8f1cabc14"
},
"restriction": {
"dimension": "building",
"targetId": "018fa51f-fda1-79f4-8461-2cb8f1cabc15",
"reachable": true,
"unrestricted": false
}
},
"status": 200,
"message": "Diagnosis complete.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}✅ Success Response (200 OK) — denied, no matching rule
json
{
"data": {
"decision": "deny",
"reason": "no_matching_rule",
"winningRule": null,
"permissionSet": null,
"source": null,
"restriction": null
},
"status": 200,
"message": "Diagnosis complete.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}A denial is reported with 200, not 403 — this endpoint's job is to explain a decision, not to enforce one; the caller asking the question is not the actor being denied anything.
Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.decision | allow or deny, per Request Logic |
data.reason | rule_matched or no_matching_rule |
data.winningRule.permissionCode | The matched rule's permissionCode |
data.winningRule.effect | The matched rule's effect |
data.winningRule.scope | The matched rule's scope (currently always all — see PermissionRuleScope) |
data.permissionSet.id / .name | The permissionSet the winning rule belongs to |
data.source.type | direct or group |
data.source.groupId | The group, when source.type = group |
data.source.assignmentId | The permissionSetUserAssignment or permissionSetGroupAssignment id the rule was resolved through |
data.restriction.dimension / .targetId | Echoed from the request |
data.restriction.reachable | Whether targetId is reachable under the holder's restriction |
data.restriction.unrestricted | Whether the holder carries no restriction entry for dimension at all (the "Select All" case — see restriction collection) |
❌ Error Responses
403 ERR_PERMISSION_MODEL_FORBIDDEN
Thrown when the caller is diagnosing someone else's userId without holding platform.permission-model.diagnose in the requested scope.
json
{ "status": 403, "error": { "code": "ERR_PERMISSION_MODEL_FORBIDDEN", "message": "You cannot diagnose another actor's access.", "details": { "requiredPermission": "platform.permission-model.diagnose" } }, "requestId": "…" }404 ERR_PERMISSION_MODEL_USER_NOT_FOUND
Thrown when userId does not reference an existing user.
json
{ "status": 404, "error": { "code": "ERR_PERMISSION_MODEL_USER_NOT_FOUND", "message": "User not found." }, "requestId": "…" }400 ERR_PERMISSION_MODEL_INVALID_TARGET
Thrown when targetId is given without dimension, or dimension is given without targetId, or dimension is not a dimension currently in use.
json
{ "status": 400, "error": { "code": "ERR_PERMISSION_MODEL_INVALID_TARGET", "message": "targetId and dimension must be given together, and dimension must be a supported value." }, "requestId": "…" }Design notes
- Cascade resolution for
targetIdis, and stays, the caller's job. If a project screen wants to diagnose "may this actor see this document" and the holder's restriction is onbuilding, the caller walks document → building before calling this endpoint with the resolved building id; this endpoint does not do that walk itself. The caller already knows the entity type it's checking, so a generic server-side cascade resolver here would duplicate that knowledge for no benefit. - A single
dimensionis checked per call. Combining multiple simultaneous restriction dimensions on one holder does not arise for this endpoint — see restriction collection for why, given today's two dimensions, it cannot arise at all. - This endpoint supplements, not replaces, a future "view this user's effective access" admin UI. It answers one actor/capability/scope/dimension combination at a time, by design — the machine-readable explain read for debugging one decision. A future effective-access UI showing everything a user can do is a different, broader surface (most likely its own aggregate endpoint), not a replacement for this one.