Skip to content
Updated Oct 1, 2026 by Barča Dvořáková · Owner: analysisactivefeatureapi-a Edit on GitHub

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 ​

FieldTypeRequiredDescription
emailStringYesThe invited person's email address
firstNameStringYesThe 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.
lastNameStringYesThe invited person's last name. Ignored under the same find-or-grant condition as firstName.
phoneNumberStringNoThe invited person's phone number; optional, never required. Ignored under the same find-or-grant condition as firstName.
isPorsennaUserBooleanYesWhether 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).
grantsArray<Grant>YesOne or more access grants, in the same call; at least one required

A Grant is one of:

FieldTypeRequiredDescription
typeEnum: group | directYesWhether this grant joins a group or is a direct Permission Set assignment
groupIdUUIDRequired when type = groupThe group to join; must not be combined with tenantId or permissionSetIds — tenant is implied by the group
tenantIdUUIDOnly when type = directThe tenant this direct grant applies to; omit for a platform-level grant
permissionSetIdsArray<UUID>Required when type = directOne or more Permission Sets to grant directly, in the stated scope
restrictionsArray<Restriction>NoRestriction 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 this email.
    • If one exists, skips the user insert entirely and processes grants below against that existing userId, 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, and phoneNumber are ignored in this case (see Request Body above); isPorsennaUser is instead checked against the existing user's actual value and rejected on mismatch (see 409 ERR_USERS_ACCESS_TYPE_MISMATCH below) — this person's category (Porsenna vs. tenant) cannot be changed by being invited again.
    • If none exists, proceeds as today: a new user row is created from email, firstName, lastName, phoneNumber, and isPorsennaUser exactly as given.
    • Either way, grants still 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.
  • Each groupId must reference an existing, active group; each permissionSetIds entry must exist, be active, and (for a direct grant) have restrictedToTenantId either null or equal to that grant's own tenantId — 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 at GET /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 group grant, the group's own currently-held sets') level is higher than the caller's own highest held level.
  • Rejects the request if a direct grant's permissionSetIds includes the Admin-manažer Permission Set and that grant's restrictions contains no clientGroup entry (see permission-model).
  • Restriction ceiling: rejects the request if any grant's restrictions is 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 user row with status = invited, oid = null, isPorsennaUser as given, firstName, lastName, and phoneNumber (or null) as given.
  • When a live user already exists (find-or-grant): no user row is inserted or modified; every grant below is created against that user's existing id.
  • For each group grant, inserts a groupMembership row with the given restrictions (or none).
  • For each direct grant, inserts one permissionSetUserAssignment row per permissionSetIds entry, with the given tenantId (or null for platform level) and restrictions.
  • The insert triggers on groupMembership and permissionSetUserAssignment derive the corresponding tenantUser row 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 FieldSource / Value
data.userIduser.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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
tenantIdsQueryArray<UUID>No-Restrict to users who belong to any of these tenants; when set, rows with isPorsennaUser = true are always excludedRepeated query param (?tenantIds=a&tenantIds=b); each must exist; capped at 50 valuestenantUser.tenantId
statusQueryEnumNo-Filter by user statusOne of UserStatususer.status
isPorsennaUserQueryBooleanNo-Filter by person kind; only meaningful without tenantIds (a platform-level listing)Ignored if tenantIds is setuser.isPorsennaUser
page / pageSizeQueryIntegerNo1 / 20PaginationpageSize 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 live tenantUser row for that user (not just the ones matching the filter — see the response's tenantIds below).
  • When tenantIds is supplied, restricts to users with at least one live tenantUser row whose tenantId is 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 filters isPorsennaUser = false, regardless of the isPorsennaUser query 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 FieldSource / Value
data.users[].iduser.id
data.users[].emailuser.email
data.users[].firstNameuser.firstName
data.users[].lastNameuser.lastName
data.users[].displayNameComputed: user.lastName + " " + user.firstName — see user
data.users[].phoneNumberuser.phoneNumber
data.users[].statususer.status
data.users[].isPorsennaUseruser.isPorsennaUser
data.users[].tenantIdsEvery 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.totalCountPagination 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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
userIdPathUUIDYes-The user to readMust existuser.id

Request Logic ​

  • Reads user by id.
  • Reads live permissionSetUserAssignment rows for this userId, joined to permissionSet for name.
  • Reads live groupMembership rows for this userId, joined to group for name/tenant and to every live permissionSetGroupAssignment for 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 FieldSource / Value
data.user.iduser.id
data.user.emailuser.email
data.user.firstNameuser.firstName
data.user.lastNameuser.lastName
data.user.displayNameComputed: user.lastName + " " + user.firstName — see user
data.user.phoneNumberuser.phoneNumber
data.user.platformRoleuser.platformRole
data.user.isPorsennaUseruser.isPorsennaUser
data.user.statususer.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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
userIdPathUUIDYes-The user to suspendMust existuser.id

Request Logic ​

  • Sets user.status = suspended.
  • Does not touch permissionSetUserAssignment, groupMembership, or tenantUser rows.

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 FieldSource / Value
data.userIduser.id
data.statususer.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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
userIdPathUUIDYes-The user to restoreMust exist and currently be suspendeduser.id

Request Logic ​

  • Sets user.status = active.
  • Does not re-create or alter any permissionSetUserAssignment, groupMembership, or tenantUser row — 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 FieldSource / Value
data.userIduser.id
data.statususer.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 ​

FieldTypeRequiredDescription
tenantIdUUIDYesThe tenant this group belongs to; immutable after creation
nameStringYesThe 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 name already exists for this tenantId.
  • Inserts a group row.

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 FieldSource / Value
data.groupIdgroup.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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
tenantIdQueryUUIDYes-The tenant whose groups to listMust existgroup.tenantId
page / pageSizeQueryIntegerNo1 / 20PaginationpageSize capped at 100-

Request Logic ​

  • Reads live group rows for tenantId.

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 FieldSource / Value
data.groups[].idgroup.id
data.groups[].namegroup.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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
groupIdPathUUIDYes-The group to deleteMust existgroup.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 ​

FieldTypeRequiredDescription
userIdUUIDYesThe user to add
restrictionsArray<Restriction>NoRestriction entries for this membership (see restriction collection)
json
{
  "userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc12",
  "restrictions": []
}

Request Parameters ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
groupIdPathUUIDYes-The group to add the user toMust exist and be activegroup.id

Request Logic ​

  • userId must reference an existing, non-suspended user.
  • Rejects the request if userId already holds a live groupMembership for a group sharing this group's tenant (exclusivity — a user belongs to at most one group per tenant).
  • Inserts a groupMembership row; tenantId is derived from group.tenantId by trigger.
  • The insert trigger derives the tenantUser row for this tenant if one does not already exist live.
  • Restriction ceiling: rejects the request if this membership's restrictions is broader than the caller's own effective building restriction 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 permissionSetGroupAssignment for groupId), inserts a permissionSetUserAssignment row for userId (tenantId = the group's tenant, sourceGroupId = groupId, restrictions copied from this membership's own restrictions) — unless a row already exists for that (userId, tenantId, permissionSetId) with sourceGroupId null, 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 FieldSource / Value
data.membershipIdgroupMembership.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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
membershipIdPathUUIDYes-The membership to revokeMust existgroupMembership.id

Request Logic ​

  • Sets groupMembership.deletedAt = now().
  • Does not delete the derived tenantUser row — the user may still belong to the tenant through another live grant; if this was their last live grant for the tenant, tenantUser is left in place as a record that membership once existed, consistent with soft-delete throughout this feature.
  • Reverses propagated access: soft-deletes every permissionSetUserAssignment row that carries this membership's sourceGroupId for this user and tenant — unless that row's sourceGroupId has 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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
groupIdQueryUUIDConditional-List members of this group; required unless userId is givenMust existgroupMembership.groupId
userIdQueryUUIDConditional-List this user's group membership(s); required unless groupId is givenMust existgroupMembership.userId
page / pageSizeQueryIntegerNo1 / 20PaginationpageSize 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 groupMembership rows 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 FieldSource / 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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
tenantIdQueryUUIDNo-List sets assignable in this tenant (global sets, plus organization sets with an existing live assignment in this tenant)Must existDerived — see Request Logic; not a stored column on permissionSet
page / pageSizeQueryIntegerNo1 / 20PaginationpageSize capped at 100-

Request Logic ​

  • Without tenantId: returns global, active Permission Sets only.
  • With tenantId: returns global, active sets, plus organization sets that are either (a) restrictedToTenantId equal to that tenantId (always shown to their locked tenant, regardless of assignment history), or (b) restrictedToTenantId IS NULL and have at least one live permissionSetGroupAssignment or permissionSetUserAssignment scoped to that tenantId (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 tenantId query parameter. Matches the convention every other scoped read in this catalog uses (see GET /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 FieldSource / Value
data.permissionSets[].idpermissionSet.id
data.permissionSets[].keypermissionSet.key
data.permissionSets[].namepermissionSet.name
data.permissionSets[].visibilitypermissionSet.visibility
data.permissionSets[].levelpermissionSet.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, and restrictedToTenantId, 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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
permissionSetIdPathUUIDYes-The Permission Set to readMust existpermissionSet.id

Request Logic ​

  • Reads the permissionSet row and returns every field a management screen needs — the list endpoint's four fields plus rules, active, restrictedToTenantId, and level.

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 FieldSource / Value
data.idpermissionSet.id
data.keypermissionSet.key
data.namepermissionSet.name
data.visibilitypermissionSet.visibility
data.restrictedToTenantIdpermissionSet.restrictedToTenantId
data.activepermissionSet.active
data.levelpermissionSet.level
data.rulespermissionSet.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 permissionSet row (id, key, name, level, rules), ordered by level descending 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 rules array as-is: the screen renders allow/deny for a code with a rule, — for a code the set's rules array 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 FieldSource / Value
data.permissionCodesThe capability catalog's registered codes
data.permissionSets[].idpermissionSet.id
data.permissionSets[].keypermissionSet.key
data.permissionSets[].namepermissionSet.name
data.permissionSets[].levelpermissionSet.level
data.permissionSets[].rulespermissionSet.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 ​

FieldTypeRequiredDescription
nameStringYesHuman-readable name shown in the UI
restrictedToTenantIdUUIDNoHard exclusivity lock to one tenant (see permissionSet); left null, the set is freely reusable across any tenant once assigned
activeBooleanNoDefaults to true
rulesArray<Rule>NoEach { permissionCode, effect, scope }; defaults to []. scope accepts only all — see PermissionRuleScope
levelIntegerNoRank 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 ​

  • visibility is always set to organization by this endpoint — there is no way to request global here (see Authorization above).
  • key is left null — it is set for global sets only, assigned by the seed script, never by this endpoint.
  • Each rule's permissionCode is 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.
  • effect must be allow or deny; scope must be all — anything else is rejected.
  • Two rules with the same permissionCode and scope are a duplicate and are rejected — collapsing them silently would hide a contradiction the author meant to see.
  • Rejects if restrictedToTenantId is supplied and doesn't resolve to an existing tenant.
  • Rejects if level is supplied above the caller's own highest held level (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, level defaults to 0 (see permissionSet).
  • Auto-provisions a group per tenant when unrestricted: if restrictedToTenantId is left null (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 (group enforces (tenantId, name) uniqueness), the entire request is rejected — no partial provisioning, including the permissionSet insert 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 FieldSource / Value
data.permissionSetIdpermissionSet.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 ​

FieldTypeRequiredDescription
nameStringNoNew name
restrictedToTenantIdUUID or nullNoForbidden when visibility = global; set to lock an organization set to one tenant, or null to unlock it
activeBooleanNo
rulesArray<Rule>NoReplaces 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
levelIntegerNoRank 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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
permissionSetIdPathUUIDYes-The Permission Set to updateMust existpermissionSet.id

Request Logic ​

  • visibility and key are never editable through this endpoint — a global set's identity is fixed at seed time, and organization sets never get promoted to global after creation (see permissionSet).
  • Rules validate exactly as on POST /v1/permission-sets (format-only permissionCode check, effect/scope legality, no duplicate (permissionCode, scope) pairs).
  • Rejects if restrictedToTenantId is supplied on a global set.
  • Rejects if restrictedToTenantId is supplied and doesn't resolve to an existing tenant.
  • Rejects if level is supplied above the caller's own highest held level (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 FieldSource / Value
data.permissionSetIdpermissionSet.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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
permissionSetIdPathUUIDYes-The Permission Set to deleteMust existpermissionSet.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} and DELETE /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 ​

FieldTypeRequiredDescription
userIdUUIDYesThe user to grant the Permission Set to
tenantIdUUIDNoThe tenant this grant applies to; omit for a platform-level grant
permissionSetIdUUIDYesThe Permission Set to grant
restrictionsArray<Restriction>NoRestriction 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 ​

  • userId must reference an existing, non-suspended user.
  • permissionSetId must exist, be active, and have restrictedToTenantId either null or equal to this grant's own tenantId (see permissionSet).
  • Rejects the request if this exact (userId, tenantId, permissionSetId) grant already exists and is live.
  • Rejects the request if permissionSetId's level is higher than the caller's own highest held level.
  • Rejects the request if permissionSetId is the Admin-manažer Permission Set and restrictions contains no clientGroup entry (see permission-model).
  • Restriction ceiling: rejects the request if restrictions is 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 permissionSetUserAssignment derives the tenantUser row when tenantId is 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 FieldSource / Value
data.assignmentIdpermissionSetUserAssignment.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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
assignmentIdPathUUIDYes-The grant to revokeMust existpermissionSetUserAssignment.id

Request Logic ​

  • Sets permissionSetUserAssignment.deletedAt = now().
  • Does not delete the derived tenantUser row — 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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
userIdQueryUUIDConditional-List grants for this user; required unless tenantId is givenMust existpermissionSetUserAssignment.userId
tenantIdQueryUUIDConditional-List grants for this tenant; required unless userId is givenMust existpermissionSetUserAssignment.tenantId
page / pageSizeQueryIntegerNo1 / 20PaginationpageSize 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 permissionSetUserAssignment rows matching the given filter(s), joined to permissionSet for 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 FieldSource / Value
data.assignments[].idpermissionSetUserAssignment.id
data.assignments[].userIdpermissionSetUserAssignment.userId
data.assignments[].tenantIdpermissionSetUserAssignment.tenantId
data.assignments[].permissionSetIdpermissionSetUserAssignment.permissionSetId
data.assignments[].permissionSetNamepermissionSet.name, joined
data.assignments[].restrictionspermissionSetUserAssignment.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 ​

FieldTypeRequiredDescription
permissionSetIdUUIDYesThe 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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
groupIdPathUUIDYes-The group to grant the Permission Set toMust exist and be activegroup.id

Request Logic ​

  • permissionSetId must exist, be active, and have restrictedToTenantId either null or equal to this group's tenantId.
  • Rejects the request if this exact (groupId, permissionSetId) grant already exists and is live.
  • Rejects the request if permissionSetId's level is higher than the caller's own highest held level.
  • Inserts a permissionSetGroupAssignment row; tenantId is derived from group.tenantId by 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 FieldSource / Value
data.assignmentIdpermissionSetGroupAssignment.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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
assignmentIdPathUUIDYes-The grant to revokeMust existpermissionSetGroupAssignment.id

Request Logic ​

  • Sets permissionSetGroupAssignment.deletedAt = now().
  • Does not alter groupMembership or tenantUser rows — 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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
groupIdQueryUUIDYes-List grants for this groupMust existpermissionSetGroupAssignment.groupId
page / pageSizeQueryIntegerNo1 / 20PaginationpageSize capped at 100-

Request Logic ​

  • Reads live permissionSetGroupAssignment rows for groupId, joined to permissionSet for 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 FieldSource / Value
data.assignments[].idpermissionSetGroupAssignment.id
data.assignments[].groupIdpermissionSetGroupAssignment.groupId
data.assignments[].permissionSetIdpermissionSetGroupAssignment.permissionSetId
data.assignments[].permissionSetNamepermissionSet.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 tenantUser rows for the caller's own userId.

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 FieldSource / Value
data.tenants[].tenantIdtenantUser.tenantId
data.tenants[].statustenantUser.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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
tenantIdQueryUUIDNo-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 a false value).
  • 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 when tenantId is 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 FieldSource / Value
data.tenantIdEchoed from the request, or null for platform scope
data.capabilitiesEvery 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 needs tenantId and status. 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/diagnose explains 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 ​

NameInTypeRequiredDefaultDescriptionValidation/NotesDB Mapping
userIdQueryUUIDYes-The actor being diagnosed.Must reference an existing user.permissionSetUserAssignment.userId / groupMembership.userId
permissionCodeQueryStringYes-The capability being checked (e.g. tenant.buildings.manage).Checked against the capability catalog; an unknown code is rejected.permissionSet.rules[].permissionCode
tenantIdQueryUUIDNo-The scope being checked. Omitted means platform scope.Must exist when given.Matched against each candidate assignment's tenantId (see Request Logic)
targetIdQueryUUIDNo-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
dimensionQueryStringRequired 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 userId equals the caller's own id, the endpoint always answers, regardless of whether the caller holds platform.permission-model.diagnose — a person can always ask why they were allowed or refused something. Diagnosing anyone else's userId requires 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 permissionCode anywhere: 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 deny among the matches wins outright; otherwise the (single, since only all scope exists) allow wins.
  • 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 targetId and dimension are both given: additionally checks whether the winning rule's holder carries a restriction in that dimension. 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 — targetId is reachable), a restriction entry whose values include targetId (reachable), or a restriction entry whose values do 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 resolve targetId down to the restricted dimension before calling this endpoint); the endpoint checks membership only, given a already-resolved targetId.
  • A deny decision short-circuits: targetId/dimension are ignored and omitted from the response when the base decision is already deny, 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 FieldSource / Value
data.decisionallow or deny, per Request Logic
data.reasonrule_matched or no_matching_rule
data.winningRule.permissionCodeThe matched rule's permissionCode
data.winningRule.effectThe matched rule's effect
data.winningRule.scopeThe matched rule's scope (currently always all — see PermissionRuleScope)
data.permissionSet.id / .nameThe permissionSet the winning rule belongs to
data.source.typedirect or group
data.source.groupIdThe group, when source.type = group
data.source.assignmentIdThe permissionSetUserAssignment or permissionSetGroupAssignment id the rule was resolved through
data.restriction.dimension / .targetIdEchoed from the request
data.restriction.reachableWhether targetId is reachable under the holder's restriction
data.restriction.unrestrictedWhether 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 targetId is, 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 on building, 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 dimension is 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.