Skip to content

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:

  1. The system checks for an unconfirmed profile with the same email address.
  2. If found: the unconfirmed profile is upgraded — its iss and sub are set to the real OIDC values, and confirmed is set to true.
  3. 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 iss value. Since unconfirmed users share iss = "-", 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 iss values). On first login, the system links the unconfirmed profile to the real identity.
  • Attempting to create a duplicate returns HTTP 409 Conflict with 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:

OIDC_CLAIM_UPSTREAM_ISSUER=original_issuer
OIDC_CLAIM_UPSTREAM_ID=original_sub

When configured, on each login the system:

  1. Reads the named claims from the ID token.
  2. Stores them in upstream_issuer and upstream_id.
  3. 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):

  1. User authenticates via the OIDC login flow.
  2. The backend creates a server-side session in Redis containing iss, sub, and tokens.
  3. A floh_sid httpOnly cookie is set.
  4. On subsequent requests, the session is loaded and the user is looked up by (iss, sub).

Bearer Tokens (API Clients)

  1. External client obtains an access token from the identity provider.
  2. Client sends Authorization: Bearer <token> with each request.
  3. Floh verifies the token signature, extracts iss (falling back to OIDC_ISSUER config) and sub.
  4. User is looked up by (iss, sub). If not found, a new user is auto-provisioned with the requestor role.

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
  • dataTypestring, number, boolean, or date
  • 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 has user:read_pii permission
  • 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:

{{user.profile.department}}
{{user.profile.title}}
{{user.profile.customAttributes.building}}

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 the workflow:provision_users role who started the run with a token scoped to workflow:start only will still pass the gate. Until the effective run-start permission set is persisted on workflow_run, token-scope restrictions are advisory at run-start (auto-provision) only; mid-run user_create steps 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.