User Profiles¶
Overview¶
Floh identifies users by their OIDC identity — specifically the combination of issuer (iss) and subject (sub). This composite key is globally unique and allows Floh to support multiple identity providers simultaneously without collisions, even when two providers happen to issue the same sub value.
Identity Model¶
Every user row stores the following identity fields:
| Column | Type | Nullable | Description |
|---|---|---|---|
iss |
VARCHAR(500) |
No | OIDC issuer URL, or "-" for unconfirmed users |
sub |
VARCHAR(500) |
No | OIDC subject identifier, or the email address for unconfirmed users |
email |
VARCHAR(255) |
No | User's email address |
display_name |
VARCHAR(255) |
No | Display name shown in the UI |
confirmed |
BOOLEAN |
No | true after first OIDC login; false for pre-provisioned users |
upstream_issuer |
VARCHAR(500) |
Yes | Original issuer when using an identity proxy |
upstream_id |
VARCHAR(500) |
Yes | Original subject identifier from the upstream provider |
Unique Constraints¶
Two composite unique indexes enforce identity uniqueness at the database level:
(iss, sub)— no two users can share the same issuer + subject pair(iss, email)— no two users can share the same issuer + email pair
These indexes cover all user types, including unconfirmed profiles, without relying on partial indexes.
User Lifecycle¶
┌───────────────┐
│ Admin creates │
│ unconfirmed │
│ profile │
└──────┬────────┘
│
▼
┌───────────────────────┐
│ Unconfirmed User │
│ iss = "-" │
│ sub = email │
│ confirmed = false │
└──────────┬────────────┘
│
First OIDC login
(email matches)
│
▼
┌───────────────────────┐
│ Confirmed User │
│ iss = <real issuer> │
│ sub = <real sub> │
│ confirmed = true │
└───────────────────────┘
Alternatively, users who log in via OIDC without a pre-existing profile are auto-provisioned directly as confirmed users.
Auto-Provisioning on First Login¶
When a user authenticates via OIDC and no matching (iss, sub) record exists:
- The system checks for an unconfirmed profile with the same email address.
- If found: the unconfirmed profile is upgraded — its
issandsubare set to the real OIDC values, andconfirmedis set totrue. - If not found: a new confirmed user record is created with the OIDC identity.
On subsequent logins, the existing user's email and display_name are updated from the latest OIDC claims.
Unconfirmed User Profiles¶
Administrators can pre-provision user profiles before the user has ever logged in. This is useful for:
- Pre-assigning roles so users have the correct access on first login
- Assigning users to workflow tasks or approvals before they have an account
- Referencing users by email in notification templates
- Accepting inbound SCIM-created directory users before their first OIDC login
Creating Unconfirmed Users¶
Via API:
curl -X POST https://floh.example.com/api/users \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "displayName": "Alice"}'
Via Admin Panel: Click "Create User" in the user management section, enter an email and optional display name.
Via SCIM: An inbound SCIM POST /scim/v2/Users request creates the same kind of unconfirmed user row. SCIM externalId values are stored in scim_user_identity and are not copied into user.sub; the real login identity is still confirmed by OIDC on first login.
Constraints¶
- The email must be unique across all users with the same
issvalue. Since unconfirmed users shareiss = "-", no two unconfirmed users can have the same email. - If a confirmed user with the same email already exists under a different issuer, the unconfirmed profile can still be created (they have different
issvalues). On first login, the system links the unconfirmed profile to the real identity. - Attempting to create a duplicate returns HTTP
409 Conflictwith the message:A user with the email address '<email>' already exists.
Upstream Identity (Identity Proxies)¶
When Floh sits behind an identity proxy such as Authifi, the tokens it receives have the proxy's iss and sub — not the original provider's. Two optional fields capture the original identity for verification and future use:
| Field | Configured via | Description |
|---|---|---|
upstream_issuer |
OIDC_CLAIM_UPSTREAM_ISSUER |
JWT claim containing the original issuer URL |
upstream_id |
OIDC_CLAIM_UPSTREAM_ID |
JWT claim containing the original subject ID |
Configuration¶
Set environment variables to specify which JWT claims contain the upstream identity:
When configured, on each login the system:
- Reads the named claims from the ID token.
- Stores them in
upstream_issuerandupstream_id. - Logs a warning if the stored upstream values differ from the incoming ones (indicating a potential account linkage issue).
When not configured, these fields remain null and have no effect.
Authentication Paths¶
Floh supports two authentication methods, both resolving users via (iss, sub):
Browser Sessions (Cookie-Based)¶
- User authenticates via the OIDC login flow.
- The backend creates a server-side session in Redis containing
iss,sub, and tokens. - A
floh_sidhttpOnly cookie is set. - On subsequent requests, the session is loaded and the user is looked up by
(iss, sub).
Bearer Tokens (API Clients)¶
- External client obtains an access token from the identity provider.
- Client sends
Authorization: Bearer <token>with each request. - Floh verifies the token signature, extracts
iss(falling back toOIDC_ISSUERconfig) andsub. - User is looked up by
(iss, sub). If not found, a new user is auto-provisioned with therequestorrole.
Development Mode¶
OIDC configuration is required in all environments. Startup fails fast when any required OIDC field is missing (OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI).
Legacy Users¶
Users that existed before the identity model migration have iss = "legacy". These users continue to function normally — they are matched by their (iss, sub) pair on login. If their actual OIDC issuer is different from "legacy", they will be treated as new users on next login and a new profile will be created. To avoid this, an administrator can manually update the iss field to the correct issuer URL.
Extended Profile Attributes¶
In addition to the core identity fields in the user table, Floh supports extended profile data via a separate user_profile table (1:1 relationship with user).
Standard Profile Fields¶
| Column | Type | PII | Description |
|---|---|---|---|
title |
VARCHAR(255) |
No | Job title |
department |
VARCHAR(255) |
No | Department name |
phone_number |
TEXT |
Yes | Phone number (encrypted at rest) |
location |
VARCHAR(255) |
No | Office location |
employee_id |
TEXT |
Yes | Employee ID (encrypted at rest) |
cost_center |
VARCHAR(100) |
No | Cost center code |
start_date |
DATE |
No | Employment start date |
Custom Attributes¶
Administrators can define custom profile attributes via the Profile Attribute Schema API (/api/profile-schema). Each definition specifies:
- name — unique key used in the JSON storage
- displayLabel — human-readable label shown in the UI
- dataType —
string,number,boolean, ordate - isPii — whether the attribute contains PII (encrypted at rest)
- isRequired — whether the attribute must be set
- defaultValue — optional default value
- sortOrder — display ordering
Non-PII custom attributes are stored as plaintext JSONB in custom_attributes. PII-flagged custom attributes are serialized to JSON and encrypted as a single AES-256-GCM blob in pii_attributes.
Attribute Sources¶
The attribute_sources field (JSONB) tracks which system set each attribute. Possible sources:
| Source | Description |
|---|---|
manual |
Set by an administrator in the UI |
oidc |
Synced from identity provider claims |
workflow |
Updated by a workflow step |
connector |
Synced from an external connector |
API Endpoints¶
| Method | Path | Permission | Description |
|---|---|---|---|
GET |
/api/users/:id/profile |
user:read |
Get profile (PII masked without user:read_pii) |
PUT |
/api/users/:id/profile |
user:manage |
Create or fully replace profile |
PATCH |
/api/users/:id/profile |
user:manage |
Partially update profile |
GET |
/api/profile-schema |
user:read |
List attribute definitions |
POST |
/api/profile-schema |
user:manage |
Create attribute definition |
PUT |
/api/profile-schema/:id |
user:manage |
Update attribute definition |
DELETE |
/api/profile-schema/:id |
user:manage |
Delete attribute definition |
PII Protection¶
PII fields (phone_number, employee_id, and custom PII attributes) are:
- Encrypted at rest using AES-256-GCM (same key as connector secrets)
- Masked in API responses as
"********"unless the caller hasuser:read_piipermission - Audit-logged — every PII access is recorded via the AuditService
Workflow Integration¶
User profile data is automatically included in workflow variables when a user-type variable is expanded:
The profile_update step type allows workflows to modify user profiles:
{
"type": "profile_update",
"config": {
"userId": "{{user.id}}",
"attributes": {
"department": "Engineering",
"title": "Senior Engineer"
}
}
}
Splatting attributes from a connector response¶
When a prior step's payload (typically a connector method) already returns a
plain object whose keys match profile attribute names, point at it with
attributesFrom instead of repeating the keys. Per-row entries in
attributes win on collision so authors can override a single value sourced
from the connector response without abandoning the splat.
{
"id": "lookup",
"type": "connector",
"config": { "connector": "hr-system", "command": "getUser" },
"outputKey": "hrLookup"
},
{
"type": "profile_update",
"config": {
"userId": "{{user.id}}",
"attributesFrom": "hrLookup.user",
"attributes": { "title": "Senior Engineer" }
}
}
At least one of attributes or attributesFrom must produce keys at runtime;
when neither does, the step fails with
profile_update step requires at least one attribute (via attributes or attributesFrom).
Provisioning users mid-run with user_create¶
The user_create step provisions an unconfirmed Floh user inside a running
workflow when the email is only learned after the run starts (e.g. from a
document_submission step or a connector's findUser response). It mirrors
the run-start autoProvisionByEmail mechanism: an existing user is reused by
default, and a new unconfirmed row is created only when the run initiator
holds workflow:provision_users.
{
"type": "user_create",
"config": {
"email": "{{requestor.email}}",
"displayName": "{{requestor.displayName}}",
"upstreamIssuer": "https://idp.example.com",
"ifExists": "reuse",
"initialAttributes": {
"department": "Engineering"
},
"attributesFrom": "hrLookup.user"
}
}
| Field | Required | Notes |
|---|---|---|
email |
yes | Supports {{var}} interpolation. Must look email-shaped at runtime. |
displayName |
no | Falls back to email when omitted. |
upstreamIssuer / upstreamId |
no | Persisted as advisory hints used to deep-link the user to the right IdP on first login. |
ifExists |
no | "reuse" (default) returns the matching user's id. "fail" errors the step. |
initialAttributes |
no | Same shape rules as profile_update.attributes; written through ProfileRepository after the user row is created. |
attributesFrom |
no | Dot-path that resolves to an object spread into initialAttributes. Per-row entries in initialAttributes win on collision. |
Successful steps expose userCreated, userCreateUserId, and
userCreateEmail to the run variable bag. Use outputKey if you need to
capture the entire payload (which also includes displayName and
attributesUpdated) for downstream {{outputKey.field}} references.
The runtime gate fires before the email lookup: every user_create
execution requires the run initiator to hold workflow:provision_users,
regardless of whether the step ends up creating a new row or reusing an
existing one. This closes two enumeration vectors: (1) returning an
existing user's UUID without permission, and (2) writing
initialAttributes to a profile by guessing its email.
Every outcome is recorded as a workflow.user_auto_provisioned audit
row tagged with source: "user_create_step", run id, and step id so
operators can audit the trail. The full outcome set is:
| Outcome | Meaning |
|---|---|
created |
A new unconfirmed user row was inserted and any initialAttributes were written. |
reused |
An existing user matched the email; any initialAttributes were written to the existing profile. |
denied_missing_permission |
The run initiator does not hold workflow:provision_users. Step fails before any DB lookup. |
denied_invalid_config |
The step config is malformed (e.g. attributesFrom resolves to a non-object). Step fails before any DB write. |
denied_if_exists_fail |
An existing row matched and ifExists: "fail" was set. Step fails without modifying the matched profile. |
created_attribute_write_failed |
The user row was created but the post-create initialAttributes write failed. The orphaned user row remains for operator clean-up. |
reused_attribute_write_failed |
An existing user matched but the post-reuse initialAttributes write failed. The matched profile may have been partially mutated. |
The failure-path outcomes are also surfaced through the workflow task's
diagnostics field so they remain visible in the run detail view even
if the audit row write itself fails.
Known limitation (tracked for v2): the runtime permission check reads the initiator's live role-based permission set via
resolveUserPermissions, not the API-token / session scope that originally started the run. A user holding theworkflow:provision_usersrole who started the run with a token scoped toworkflow:startonly will still pass the gate. Until the effective run-start permission set is persisted onworkflow_run, token-scope restrictions are advisory at run-start (auto-provision) only; mid-runuser_createsteps are gated on role membership.
Database Schema¶
The user table is defined in packages/server/src/db/schema/auth-tables.ts and the migration that introduced the identity fields is packages/server/src/db/migrations/009_user_identity.ts.
Migration 009: User Identity¶
Added columns: iss, upstream_issuer, upstream_id, confirmed
Changed constraints:
- Dropped the old
UNIQUE(sub)constraint - Added
UNIQUE(iss, sub)index - Added
UNIQUE(iss, email)index
Data backfill: All existing users have iss set to "legacy" to distinguish them from users created after the migration.
Rollback¶
The migration's down function reverses all changes: drops the new indexes, restores the UNIQUE(sub) constraint, and removes the added columns.