Roles & Entitlements System¶
Overview¶
The Roles & Entitlements system enables Floh to provision and deprovision external resources (entitlements) when users are granted or revoked business roles. Roles here are distinct from platform authorization — they represent business-level access such as "Project X Participant" and map to concrete resources in external systems like Authifi groups and Active Directory accounts.
Two role domains¶
Do not treat these role systems as interchangeable:
- Authifi authorization roles and groups (
floh-console:*,floh-portal:*,floh-mcp:*) determine which OAuth permissions appear in channel access tokens.pnpm run sync-authifi-rbacreconciles them from the shared authorization catalog for three distinct resource-server audiences. This is the only source of authorization for the Floh API: a request's permissions are the access token'sscopeclaim intersected with the permissions reachable from the client (azp) the token was issued to. - Floh role definitions and assignments are business/provisioning records described in this document. A workflow can grant one and invoke connectors to create external entitlements; it does not change the caller's OAuth token and therefore never widens what the caller may do inside Floh.
Floh no longer keeps a parallel authorization store. The role, permission,
user_role, role_permission, permission_override, and api_token tables
were dropped in LSA-9939, along with floh_-prefixed API tokens — a Bearer
value starting with floh_ is rejected with 401 in every environment.
Syncing Authifi desired state does not migrate users between the two remaining
domains. Runtime Authifi grant rechecks on delayed steps are deferred to
LSA-9940. The API verifies JWT aud against the channel identifiers
(FLOH_CONSOLE_AUDIENCE, FLOH_PORTAL_AUDIENCE, FLOH_MCP_AUDIENCE), which
are both Authifi catalog resource servers and the complete accepted-audience
allow-list. FLOH_RESOURCE_ID (OIDC_AUDIENCE is a deprecated alias) names
the API resource for operator tooling and is not accepted as aud.
Floh roles terminology and authority¶
Authifi owns platform authorization through predefined Floh groups and the OAuth scopes attached to them. Floh continues to own provisioning roles and entitlements: workflow-driven delivery of external access, business-role definitions, and entitlement assignments stay in Floh even when the console's system access is Authifi-backed.
Use this terminology consistently:
- Floh roles / Floh members: Authifi-backed system access to the Floh console, portal, or MCP channel. "Floh roles" names the left-nav section and its first tab; "Floh members" names the second. They say "Floh" because the catalog covers only the groups Floh owns, not every group in the Authifi tenant — the earlier "Platform" wording oversold that scope.
- Provisioning / Access roles / Assignments / Entitlements: Floh-managed business access and external-resource delivery.
The code keeps the platform-access name throughout: the routes
(/platform-access/...), the group-ID map (PLATFORM_ACCESS_GROUP_IDS), the
store, and the BFF proxy entries are all unchanged. Only what a user reads was
renamed, so an operator configuring the map and an administrator reading the
screen are looking at two different names for the same thing.
The console BFF's /authifi allowlist — 21 exact method+prefix entries as of
LSA-10196, shared with Console Authifi sync
below — is defense-in-depth only. Prefix matching cannot constrain suffixes, so
the authoritative controls are exact single-scope issuance plus Authifi
per-namespace authorization. This section's group-member mutations use only
the GET/PUT/DELETE subset under /authifi/groups/…/users; the rest of
the table exists for console Authifi sync and is analyzed there.
PII and telemetry boundary for platform-access mutations:
- The proxy path carries group ID, user ID, and Authifi
sub. - The console BFF logs only method, sanitized route class, and status.
- Authifi owns audit for the mutation actor/target record.
- The member read projects
id,name, andemail. A row matched to a Floh directory entry renders that entry; a row with no match renders the Authifi-supplied name and email directly, so those fields do reach the DOM. This is data minimization against the 47-column default, not a boundary — an authorized console admin can already read the same fields fromGET /users. Making them unobtainable needs a server-side hop, tracked as LSA-10128. - No token, credential, or raw Authifi response body enters Floh logs or the DOM.
Removal semantics are refresh/login based, not immediate revocation. Removing a grant becomes authoritative when the session refreshes or the user logs in again; it does not retroactively invalidate an already-issued access token.
Console navigation and labels¶
| Surface | Label | Route | Authority |
|---|---|---|---|
| Left nav / Cmd+K | Floh roles | /platform-access/roles, /platform-access/members |
Live Authifi group membership via the console BFF, for a catalog of roles Floh owns. Hidden unless the caller holds both system:administer and user:read. |
| Floh roles tabs | Roles, Members | same | Membership is keyed by Authifi sub. Declared permissions are catalog-implied from AUTHORIZATION_ROLE_BUNDLES, not another user's live JWT. |
| Users table | Floh roles | — | Same store as the Floh roles screens. Absent when the caller lacks system:administer or user:read (the legacy database Roles column is gone). |
| Left nav | Provisioning | /roles, /role-assignments, /entitlements |
Floh role_definition / role_assignment / entitlements. Routes unchanged. |
| Provisioning tabs | Access roles, Assignments, Entitlements | /roles, /role-assignments, /entitlements |
Provisioning model only. |
GET /api/auth/me remains the signed-in caller's flattened token scopes. Do not present it as another user's live token.
Two different authorities are at work on the Floh roles screens, and conflating them is what made an earlier version of the screen unreliable:
- Which roles exist is Floh's answer. The canonical names live in
AUTHORIZATION_ROLE_BUNDLES, the CLI and the console's Authifi sync page apply them to Authifi as the same desired state, and the resulting name-to-id map reaches the console throughGET /api/auth/config— either from thePLATFORM_ACCESS_GROUP_IDSdeploy-time environment fallback or from the map the console itself persisted after a proven live re-read. The catalog is therefore identical for every administrator regardless of which path populated it. - Who holds a role is Authifi's answer, read per group at view time with the signed-in administrator's own delegated token. An administrator who cannot read a group's membership sees that row marked unavailable rather than empty.
Asking Authifi the first question is what does not work. The only list it will return to a delegated token is the groups that token's subject belongs to, which is a different question wearing the same shape: an administrator who belongs to three of the eleven groups would be shown a three-row catalog with no indication the other eight exist. Roles nobody has joined yet would be invisible to everyone.
Console Authifi sync (LSA-10171)¶
The /authifi-sync page lets a signed-in Authifi tenant or super admin
preview and apply Floh's fixed RBAC desired state — resource servers,
permissions, access roles, groups, and the floh namespace grant — directly
from the console. See
Auth Configuration — Console Authifi sync
for the operator-facing flow; this section is the security model.
Identity and authority.
- Every Authifi call the page makes carries the signed-in caller's own
delegated token, exchanged (RFC 8693) through the existing tenant-pinned
authifiAdminBFF slot. Floh never mints, stores, or forwards client credentials for this path, and the console has no server-side Authifi client of its own — every request crosses the same/authifiproxy the pre-existing platform-access screens use. - Authifi is authoritative for whether a given caller may perform a given
mutation. The nav entry is permission-unconstrained (no permission
predicate at all), and the route is guarded only by
authGuard— authentication, not authorization — so a non-admin's Preview/Apply attempt is rejected by Authifi (403), not by a Floh authorization check. Floh never infers Authifi privilege from a Floh permission or vice versa. - Saving the resulting group-id map to Floh always requires
system:administerand recent multifactor auth.PUT /api/auth/platform-access-group-idscarries an unconditionalrequirePermission("system:administer")guard followed byrequireStepUp()— there is no first-writer exception and no path where an authenticated-only caller can persist. A caller who can sync Authifi but lacks the permission still has the sync itself succeed; only the Floh-side cache write is denied. The guards are ordered permission-first so a caller who lacks the permission gets a plain403rather than an MFA prompt that could not help them. - Apply asks for step-up before its first write, and that half is
console-side. The page requests reauthentication before any mutation, so a
declined prompt leaves both Authifi and Floh untouched. The Authifi writes
themselves are authorized by Authifi against the caller's exchanged token;
the BFF gateway's route allowlist carries no
acr/amrcondition, so the browser-side prompt raises assurance at the console rather than at the proxy. Enforcing it on the proxied writes is tracked as LSA-10218. Floh's own persistence route is the one surface where the requirement is enforced server-side. - Client namespace ownership is part of desired state, and is
additive-only. All three managed OIDC clients belong in the
flohnamespace. The planner emits a namespace action only with a positive namespace id, writes only thenamespaceIdfield (never alongside a config patch), and has no representation for an empty namespace — so no sync run can detach a client from a namespace. When theflohnamespace does not exist yet, ownership is reported as deferred and no namespace write is attempted; the RBAC phase creates the namespace and the next preview offers the move. Authifi's own schema makes the column nullable, which is why the absence of a null-writing path is a stated invariant rather than an incidental property of today's code. - Resource-server identifiers come from the API, and any disagreement stops
the run. The console reads each channel's identifier from
channelAudienceson the publicGET /api/auth/config, which the API builds from the sameFLOH_*_AUDIENCEvalues it verifiesaudagainst. It creates a missingFloh Portal APIorFloh MCP APIwith that identifier and links only that channel's client. It never createsFloh Console API, which is a sign-in prerequisite, and never renames or re-identifies an existing resource server. A canonical-name server with another identifier, a published identifier held under another name, a duplicate canonical name, or a missing, malformed, or unreadablechannelAudiencesblocks Preview with zero writes. Creating a resource server does not widen what a token may do: the API already accepted these audiences, and channel authorization is decided byazp. - Explicit self-grant, never automatic. On a
403from the persistence route, the page offers one additional, separately confirmed action: grant the caller's own subject membership in thefloh-console:adminpredefined group (the same group discussed under Tenant-admin recovery). Granting it does not retry persistence — the caller's current session permissions are unchanged until they refresh or sign in again, and the page requires a refreshed/api/auth/meshowingsystem:administerbefore it will retry the save. Nothing in this flow can silently escalate a caller who never confirms the grant dialog. - Post-apply live proof, and cache/index semantics. Persistence never
happens from the plan the browser computed before Apply ran. After every
mutation in a run succeeds, the browser re-reads Authifi live and proves an
exact canonical map — every predefined group resolved exactly once, with
unique positive ids, linked to the
flohnamespace — before calling the persistence route. The stored map is a cache/index only: every Preview re-derives desired state from live Authifi, never from the stored map, so a stale or wrong cached value can degrade what the console can address (which Authifi group id a name maps to) but never what Authifi authorizes.
The BFF allowlist. The shared /authifi proxy allowlist is exactly 21
method+prefix entries, asserted byte-for-byte across every compose surface and
the local dev launcher by
packages/portal-bff/test/console-config-invariants.test.ts:
| Method | /authifi prefix |
Purpose |
|---|---|---|
GET |
/authifi/access-roles |
List roles and role-permission links |
GET |
/authifi/clients |
Resolve the three known OIDC clients; read-only |
GET |
/authifi/groups |
List groups, namespace groups, group-role links, namespace grants |
GET |
/authifi/namespaces |
List namespaces only |
GET |
/authifi/resource-servers |
List resource servers, permissions, client assignments |
GET |
/authifi/umrs-roles |
Resolve the namespace membership role |
POST |
/authifi/access-roles |
Create missing managed access roles |
POST |
/authifi/clients |
Create a missing portal/MCP client from an explicit payload |
POST |
/authifi/groups |
Create missing predefined groups |
POST |
/authifi/namespaces |
Create the missing floh namespace |
POST |
/authifi/resource-servers |
Create missing managed resource servers/permissions |
POST |
/authifi/umrs-grants/group-assignment |
Create the fixed namespace grant |
PUT |
/authifi/access-roles |
Replace managed access roles |
PUT |
/authifi/groups |
Replace groups; link group roles; explicit self-grant |
PUT |
/authifi/providers |
Link a managed client to an identity provider |
PUT |
/authifi/resource-servers |
Replace resource servers; link permissions/clients |
PATCH |
/authifi/clients |
Repair managed client config; set client namespace ownership |
PATCH |
/authifi/groups |
Link predefined groups to the floh namespace |
PATCH |
/authifi/resource-servers |
Patch managed permissions |
DELETE |
/authifi/groups |
Existing platform-access member revoke only |
DELETE |
/authifi/resource-servers |
Targeted client-assignment unlink only |
Both proxy entries (the Floh API entry and this Authifi entry) require the same
double-submit CSRF pair (x-csrf-token header plus the floh_console_csrf
cookie, SameSite=Lax); the gateway rejects a CSRF-less mutation before it is
ever forwarded to Authifi. The gateway (@authifi/auth-bff-gateway) matches
each entry as a segment-boundary
prefix of the request path with the query string dropped and trailing
slashes stripped — it does not distinguish a collection path from an
individual-resource path under the same prefix, and it has no concept of "this
DELETE means unlink a relationship, not delete the entity."
Residual overmatch and blast radius. Because the matcher is prefix-only, two shapes are reachable through the allowlist that no shipped browser client constructs:
DELETE /authifi/resource-servers/{id}andDELETE /authifi/groups/{id}(and nested permission deletes under a resource server) — entity deletes, not the targeted assignment/member unlinks the browser code actually issues.- The full
GET /authifi/clients/{id}subtree — single-client reads (GET /authifi/clients/{id}), the client's secret field (GET /authifi/clients/{id}/secret), and any other nested relationship read under that id (e.g.GET /authifi/clients/{id}/resource-servers) — not only the client list.
Under a tenant-admin-session XSS on the console origin, injected script
could drive any of these reachable calls with the victim's own delegated
token — the same blast radius the platform-access proxy already carried, now
extended to the wider entity set. This is a property of the gateway's prefix
matcher, not of Floh's browser code: neither authifi-sync.client.ts nor
authifi-membership.client.ts exports a generic request method, an entity
delete, or a client-secret read: the containment is that the shipped clients
cannot construct these calls, not that the proxy itself rejects them. Narrowing
the allowlist to per-entity route templates is tracked in
issue #1280.
Why the client-read overmatch does not expose a secret. Verified against
Authifi's private idbroker source, not tested by Floh. Links are pinned to
a commit SHA — not the moving main branch — so the cited lines cannot drift
out from under this doc as Authifi's repo continues to change; links resolve
for accounts with Authifi/idbroker access:
packages/auth/src/models/clients/client.model.tsdeclareshiddenProperties: ["clientSecret"]on theClientmodel (verified at2c67976onmain, 2026-09-15).packages/auth/src/controllers/tenant-client.controller.ts'sfind(list) andfindByIdroute descriptions (verified at2c67976onmain, 2026-09-15) both state, verbatim: "Client secrets are excluded from responses for security purposes unless accessed via client credentials" — which the console's exchangedauthifiAdmintoken never is (it is always an RFC 8693 exchange of the signed-in human's own session token, never a client-credentials grant). Because Authifi's repo is outside our control, re-confirm against currentmainbefore treating this as an up-to-date statement of its behavior.
So the reachable GET /authifi/clients/{id} subtree returns the caller's own
Authifi-authorized view of client metadata minus the secret, under both the
list and single-client contracts — never a usable credential.
Key Concepts¶
Entitlement Definition (Shared)¶
An independent, reusable template for a single external resource tied to a specific connector. Entitlement definitions are first-class entities that exist independently of roles and can be shared across multiple roles via a many-to-many relationship.
Each entitlement definition specifies:
- Provision config: connector command and parameters to create/grant the resource
- Deprovision config: connector command and parameters to remove the resource
- Reconciliation config (optional): policy that controls what happens when the system detects a mismatch between the entitlement and the upstream resource
- Attribute map (optional): declarative mapping that derives connector command parameters from the granted user, their profile, the role, or the connector instance config. Rendered values are shallow-merged over
provision_configat provision time. See Attribute mapping below. - Link config (optional): controls whether the role-grant flow records the new external account on the user's
user_external_identityafter a successful provision so the account is visible on the Floh profile and can be matched by future syncs. See External-identity linking below.
For example, a single "AD Group X Membership" entitlement can be linked to both "Project X Participant" and "Project X Administrator" roles.
Role Definition¶
A reusable template that defines what a role means and which entitlements it provisions. Each role definition:
- Has a unique name, description, and status (active/inactive)
- Can specify a default
expires_after_daysTTL for automatic expiry - Is linked to one or more shared entitlement definitions via a join table
- Supports soft-delete for deactivation without data loss
Role Assignment¶
An instance of a role granted to a specific user, tracking:
- Who granted it and when
- Which workflow run triggered it (if any)
- Expiry date (from the role definition's TTL or an explicit value)
- Status:
provisioning,active,partially_provisioned,expired,revoked
Entitlement Instance¶
A tracked provisioned resource within a role assignment. Each instance records:
- The external system's resource ID (
external_id) - Provisioning/deprovisioning timestamps
- Reconciliation status (
ok,missing,error)
Data Model¶
entitlement_definition (independent, shared)
├── connector_id → connector_definition
├── provision_config, deprovision_config, reconciliation_config
└── linked to roles via role_entitlement join table
role_definition
└── linked to entitlements via role_entitlement (many-to-many)
role_entitlement (join table)
├── role_definition_id → role_definition
└── entitlement_definition_id → entitlement_definition
role_assignment (instance of a granted role)
├── role_definition_id → role_definition
├── user_id → user
└── entitlement_instance[] (provisioned resources)
entitlement_instance
├── entitlement_definition_id → entitlement_definition
├── role_assignment_id → role_assignment
└── status, external_id, timestamps
Architecture¶
Workflow Run
└─ role_grant step ──→ RoleService.grantRole()
├─ Creates RoleAssignment
└─ For each linked EntitlementDefinition:
├─ Creates EntitlementInstance (pending)
├─ Calls connector with provision_config
└─ Updates EntitlementInstance (provisioned/failed)
Scheduled Jobs
├─ role-expiry-check (hourly) ──→ Revokes expired assignments
├─ document-expiry-check (hourly) ──→ Expires documents, revokes linked roles
└─ entitlement-reconciliation (daily at 02:00 UTC) ──→ Checks upstream systems
API Endpoints¶
Entitlement Definitions (/api/entitlements)¶
| Method | Path | Description | Permission |
|---|---|---|---|
| GET | / |
List entitlement definitions (paginated, filterable by connector/search) | entitlement:read |
| GET | /:id |
Get single entitlement definition | entitlement:read |
| POST | / |
Create entitlement definition | entitlement:manage |
| PUT | /:id |
Update entitlement definition | entitlement:manage |
| DELETE | /:id |
Delete (rejects if active instances exist) | entitlement:manage |
Role Definitions (/api/roles)¶
| Method | Path | Description | Permission |
|---|---|---|---|
| GET | / |
List role definitions (paginated, filterable) | role_definition:read |
| POST | / |
Create with optional entitlementIds to link |
role_definition:manage |
| GET | /:id |
Get with linked entitlements | role_definition:read |
| PUT | /:id |
Update name, description, status, expiry | role_definition:manage |
| DELETE | /:id |
Soft-delete | role_definition:manage |
| POST | /:id/restore |
Restore soft-deleted | role_definition:manage |
| POST | /:id/entitlements |
Link an existing entitlement definition | role_definition:manage |
| DELETE | /:id/entitlements/:eid |
Unlink an entitlement definition | role_definition:manage |
Role Assignments (/api/role-assignments)¶
| Method | Path | Description | Permission |
|---|---|---|---|
| GET | / |
List (filter by userId, roleDefinitionId, status; ?include=entitlements) |
entitlement:read |
| GET | /:id |
Get with entitlement instances | entitlement:read |
| POST | / |
Manual grant outside workflow | entitlement:manage |
| POST | /:id/revoke |
Revoke and deprovision | entitlement:manage |
| POST | /:id/reprovision |
Re-provision failed/orphaned | entitlement:manage |
POST / body supports an onDuplicate field (skip, error, renew, update) to control behavior when the user already has an active assignment for the role. Defaults to skip. Returns 201 for new assignments and 200 when an existing assignment was skipped, renewed, or updated.
Reconciliation (/api/reconciliation)¶
| Method | Path | Description | Permission |
|---|---|---|---|
| POST | /run |
Trigger manual reconciliation | entitlement:manage |
| GET | /status |
Last reconciliation results | entitlement:read |
Webhook (/api/entitlements/webhook)¶
| Method | Path | Description |
|---|---|---|
| POST | /:connectorId |
Receive upstream change events |
Documents (/api/documents)¶
Enhanced with new query parameters:
userId— filter by subject userstatus— filter by document status (uploaded, approved, rejected, expired)expiringBefore— ISO date, show documents expiring before this dateexpiringSoon— boolean, shorthand for documents expiring within 30 days
Enriched response includes uploader_name, workflow_name, and role_assignments[].
Workflow Step Types¶
role_grant¶
Grants a role to a user during workflow execution. Supports configurable duplicate handling for cases where the user already has an active assignment for the same role.
Config:
{
"roleDefinitionId": "uuid-of-role-definition",
"userId": "uuid-of-target-user",
"expiresAt": "2025-12-31T00:00:00Z",
"failOnPartial": false,
"onDuplicate": "skip"
}
onDuplicate strategies:
| Strategy | Behavior |
|---|---|
skip (default) |
If the user already has an active assignment, return success without changes. Ideal for idempotent workflows. |
error |
If the user already has an active assignment, fail the step. Forces explicit handling of duplicates. |
renew |
Update the expires_at on the existing assignment. Use for recertification flows where the goal is to extend access. |
update |
Reconcile entitlements on the existing assignment against the current role definition (provision new, deprovision removed) and optionally update expires_at. Use when the role's entitlements may have changed since the original grant. |
Output variables:
roleAssignmentId— ID of the created or existing assignmentroleProvisioned— boolean, true if all entitlements provisionedprovisionedCount— number of successfully provisioned entitlementsfailedCount— number of failed entitlementsroleGrantAction— what actually happened:created,skipped,renewed, orupdated
role_revoke¶
Revokes all active assignments for a role/user combination.
Config:
{
"roleDefinitionId": "uuid-of-role-definition",
"userId": "uuid-of-target-user",
"reason": "Access no longer required"
}
Output variables:
revokedCount— number of assignments revoked
document_submission (enhanced)¶
New config field:
expiresAfterDays— integer, auto-calculatesexpires_atwhen a document is uploaded
Document Lifecycle¶
Documents now track expiry and subject user:
- Upload:
expires_atcalculated from step config'sexpiresAfterDaysor explicit field - Approval: Document becomes
approved - Expiry: Scheduled job detects
expires_at < now()for approved docs - Revocation: Expired documents trigger revocation of linked role assignments (via
run_id)
Connector Extensions¶
Authifi¶
New commands:
checkGroupMembership— verifies user is still a member of a specified group- Config:
{ "command": "checkGroupMembership", "groupId": "...", "userId": "..." } - Returns:
{ "isMember": boolean }
Test Active Directory¶
New commands:
checkGroupMembership— checks if user is in an AD groupcheckAccountExists— checks if an AD account existscreateAccount— creates a new AD account (simulated)
Reconciliation¶
Reconciliation Policies¶
Each entitlement definition can specify a reconciliation policy that controls what happens when the system detects a mismatch between the entitlement and the upstream resource. The policy is stored as { "policy": "<value>" } in the reconciliation_config column.
| Policy | Label (UI) | Behavior |
|---|---|---|
null |
None | No reconciliation checks are performed for this entitlement. |
log_only |
Log only | Run the check and log any discrepancy to the audit log, but don't change entitlement status. Useful for monitoring without automated remediation. |
flag |
Flag as out of sync | Mark the entitlement instance as orphaned when the upstream resource is missing. This was the only behavior prior to the policy system. |
sync |
Keep synced | Automatically re-provision the resource via the connector if it is missing upstream. |
Auto-derived Check Commands¶
The system automatically derives which connector command to run during reconciliation. Each provision command in a connector schema can declare a reconcilesWith field that names the corresponding check command. For example:
{
"addToGroup": {
"params": ["groupId", "userId"],
"reconcilesWith": "checkGroupMembership"
},
"checkGroupMembership": {
"params": ["groupId", "userId"]
}
}
When reconciling, the system:
- Reads the
policyfrom the entitlement'sreconciliation_config - Looks up
reconcilesWithon the provision command in the connector schema - Copies matching parameter values from the entitlement's
provision_configto build the check command - Executes the check command via the connector
- Applies the policy if a mismatch is detected
Backward Compatibility¶
Legacy reconciliation_config values that contain a command field (e.g., { "command": "checkGroupMembership", "groupId": "..." }) are treated as flag policy and the config is used directly as the check command. No data migration is required.
Scheduled Reconciliation¶
Runs daily at 02:00 UTC. For each provisioned entitlement instance with a reconciliation_config:
- Resolves the reconciliation policy and derives the check command
- Executes the check command via the connector
- If the response confirms existence → updates
last_reconciled_atand status took - If the response indicates the resource is missing → applies the policy (log, flag, or sync)
- If an error occurs → marks
reconciliation_statusaserror
Webhook Support¶
External systems can notify Floh of resource changes via POST /api/entitlements/webhook/:connectorId:
The system correlates resourceId with entitlement_instance.external_id and marks matching instances as orphaned.
Attribute mapping¶
Entitlement provisioning often needs values that aren't known at design time — a new external account's primaryEmail should be derived from the granted user, the org-unit comes from their profile, and so on. Hand-coding these into provision_config would either lock the entitlement to a single user or push template strings into a JSON blob with no validation.
entitlement_definition.attribute_map solves this declaratively. It is a Record<string, AttributeMapValue> where each key is a connector command parameter and each value names a context source plus a path or template:
{
"primaryEmail": { "source": "user", "path": "email" },
"givenName": { "source": "profile", "path": "customAttributes.firstName" },
"department": { "source": "profile", "path": "department" },
"orgUnitPath": {
"source": "template",
"template": "/{{profile.department}}"
},
"domain": { "source": "connector", "path": "config.domain" },
"password": { "source": "literal", "value": "ChangeMe!" }
}
Available sources:
| Source | Resolves from |
|---|---|
literal |
the inline value |
user |
id, email, displayName, sub, iss, upstreamIssuer, upstreamId |
profile |
title, department, phoneNumber, location, employeeId, costCenter, startDate, customAttributes.<key> |
role |
roleId, roleName, assignmentId, expiresAt, grantedBy |
connector |
any field of the connector instance config (e.g. config.domain) |
template |
a string with {{user.email}} / {{profile.department}} / etc. placeholders |
At provision time the rendered map is shallow-merged over provision_config, so legacy entitlements (where attribute_map is null) keep working unchanged. To migrate, leave the static command + defaults in provision_config and add only the per-user fields to attribute_map — admins can move one parameter at a time.
PII profile fields (phoneNumber, employeeId, decrypted piiCustomAttributes) are intentionally not exposed to the renderer. The renderer also rejects path segments matching __proto__, constructor, or prototype to block prototype-pollution vectors.
External-identity linking¶
entitlement_definition.link_config controls whether a successful provision automatically records the new external account on the Floh user's user_external_identity row:
{
"enabled": true,
"externalIdFrom": "userId",
"externalEmailFrom": "primaryEmail",
"externalDisplayNameFrom": "displayName",
"attributesFrom": { "orgUnitPath": "orgUnitPath" }
}
Each *From value is a dot-path into the connector command's result data. When enabled is true and the path resolves to a non-empty string, the role-grant flow upserts a user_external_identity row with source = 'role_grant'. Trust ranking treats role_grant the same as workflow (rank 2), so a manual (rank 3) entry will still win and a connector_sync (rank 1) entry will be overwritten. Link failures are logged but do not roll back the underlying provision.
Permissions¶
| Permission | Description | Roles |
|---|---|---|
role_definition:read |
View role definitions | admin, approver, resource_manager |
role_definition:manage |
Create/update/delete role definitions, link/unlink entitlements | admin, resource_manager |
entitlement:read |
View entitlement definitions and assignment status | admin, approver, resource_manager |
entitlement:manage |
Create/update/delete entitlement definitions, grant/revoke roles, trigger reconciliation | admin, resource_manager |
Database Schema¶
Tables (migrations 021–022)¶
role_definition— business role templates with soft-deleteentitlement_definition— shared resource templates tied to connectors (independent of roles)role_entitlement— join table linking roles to entitlements (many-to-many, composite PK)role_assignment— user-role grants with lifecycle trackingentitlement_instance— provisioned resource tracking with reconciliation statusdocument— addedexpires_at(timestamp) andsubject_user_id(FK to user)
Example: Complete Flow¶
- Admin creates entitlement definitions on the Entitlements page:
Genomics Portal Access— Authifi group membership (provision:addToGroup, deprovision:removeFromGroup, reconciliation policy:flag)-
Research File Share Access— AD account (provision:createAccount, deprovision:disableAccount, reconciliation policy:sync) -
Admin creates role "Project X Participant" on the Role Definitions page and links both entitlements
-
Workflow:
document_submission(withexpiresAfterDays: 365) →approval→role_grant -
User submits document, gets approved,
role_grantstep executes: - Creates role assignment
- Provisions Authifi group membership via connector
- Provisions AD account via connector
-
Assignment status →
active -
Daily reconciliation confirms entitlements exist upstream
-
One year later: document expires →
document-expiry-checkjob detects it → role revoked → Authifi membership removed → AD account disabled -
If admin manually removes user from Authifi group: reconciliation detects orphan → entitlement instance marked as
orphaned→ admin can reprovision or revoke via UI
UI Pages¶
- Entitlements (
/entitlements) — shared entitlement definition management (CRUD), with connector selection, config editors for provision/deprovision, an attribute-map editor (per-parameter rows with source picker), an external-identity link-config panel, and a reconciliation policy dropdown - Role Definitions (
/roles) — list and detail views; link/unlink shared entitlements to roles - Role Assignments (
/role-assignments) — global view with status filters, revoke/reprovision actions - User Entitlements (
/users/:id/entitlements) — per-user view of role assignments and entitlement instances, with active/history toggle - Documents (
/documents) — enriched document browser with expiry badges and role linkage - Workflow Designer —
role_grantandrole_revokestep types with role lookup dialog - Sidebar — Access section with a single Access hub item (opens
/roles; use in-page section tabs for Role Definitions, Entitlements, and Role Assignments), plus Documents and PAM