Skip to content

External Identities

Floh maintains a canonical record of every connector-managed external account linked to each user. This page explains the model, how identities are populated, and how workflows read and write them.

The three concerns

Floh's data model cleanly separates three related but distinct concepts:

Concept Where it lives What it captures
Login identity user.iss, user.sub, user.upstream_issuer, user.upstream_id How the user authenticates. Set by the OIDC login flow (Authifi session + upstream IDP chain).
Profile attributes user_profile (structured + custom_attributes JSONB) Extended HR-style fields: title, department, phone, custom attributes. Multi-source with attribute_sources provenance tracking.
Connector-managed external accounts user_external_identity First-class links to accounts in external systems (Google Workspace, AD, Slack, Confluence, etc.). One row per (user, connector, external ID) triple.

OIDC login information never flows into user_external_identity. The three tables never overlap.

The user_external_identity table

Each row represents one external account linked to a Floh user.

Column Description
user_id Floh user this identity belongs to
connector_id Connector instance the external account is managed by (required)
external_id The account's identifier in the external system (Google user ID, AD objectGUID, Slack user ID, etc.)
external_email Indexed for reverse lookup
external_display_name For UI display
source One of connector_sync, workflow, manual
attributes JSONB — connector-specific payload (org unit, UPN, team ID, etc.)
verified_at When the link was confirmed

Unique constraint: (user_id, connector_id, external_id).

Why dynamic attributes live in JSONB

Different connectors produce different shapes of user data. The table follows the same "common columns + JSONB bag" pattern as connector_resource:

  • Common columns (indexed): external_id, external_email, external_display_name — every connector provides them and they're the primary query targets.
  • attributes JSONB (flexible): everything else. The connector's sync adapter defines the shape.

Example payloads:

// Google Workspace
{ "givenName": "Alice", "familyName": "Smith", "orgUnitPath": "/Engineering",
  "suspended": false, "isAdmin": false }

// Active Directory
{ "samAccountName": "asmith", "userPrincipalName": "alice.smith@corp.ad",
  "distinguishedName": "CN=Alice Smith,OU=Users,DC=corp,DC=ad" }

// Slack
{ "teamId": "T12345", "realName": "Alice Smith", "isBot": false, "tz": "America/New_York" }

Trust hierarchy

When an upsert targets a row that already exists, the service enforces source precedence:

manual (admin-verified)    — highest trust, always wins
workflow (just provisioned, ID is authoritative)
connector_sync (algorithmic email/ID match)  — lowest trust

A lower-trust source cannot overwrite a higher-trust link. For example, a sync run cannot overwrite a row that a workflow just created. Admin overrides pass force: true.

Sync data flow

The most common way rows enter this table is via connector sync. Here is the full end-to-end flow:

flowchart TB
    subgraph external [External System]
        GoogleAPI["Google Directory API\n(listUsers returns raw objects)"]
    end

    subgraph fetch [Fetch and Normalize]
        Adapter["Sync adapter\nnormalizeUserToResource()"]
    end

    subgraph storage [Floh Database]
        CR["connector_resource\n(ALL synced users, matched or not)"]
        CSM["connector_sync_match\n(reconciliation metadata +\nuser_id when matched)"]
        UEI["user_external_identity\n(matched users only,\ncanonical link)"]
        UP["user_profile\n(custom_attributes via mappings)"]
    end

    subgraph consumers [Consumers]
        Workflow["Workflow step\n(reads externalIdentities)"]
        AdminUI["Admin UI\n(sync reconciliation)"]
        Entitlements["Entitlement provisioning"]
    end

    GoogleAPI --> Adapter
    Adapter --> CR
    CR --> CSM
    CSM -->|"when matched"| UEI
    CR -.->|"attributes copied"| UEI
    CSM -->|"attribute mappings"| UP

    UEI --> Workflow
    UEI --> Entitlements
    CSM --> AdminUI
    CR --> AdminUI

Step by step

Step 1 — Fetch. Sync engine calls listUsers on the configured connector; the connector returns raw external user objects.

Step 2 — Normalize. The connector's sync adapter converts raw objects to ConnectorResourceRecord:

{
  externalId: "108429381...",
  email: "alice@acme.google.com",
  displayName: "Alice Smith",
  attributes: { givenName: "Alice", orgUnitPath: "/Engineering", ... }
}

Step 3 — Upsert connector_resource. One row per external user. This is the raw snapshot — it includes external users that don't match any Floh user (service accounts, contractors, etc.).

Step 4 — Run matching. sync-match-runner tries to match each resource to a Floh user using the configured strategy (email, upstream_identity, etc.) and writes connector_sync_match rows:

  • Matched → user_id set, match_status: 'matched'
  • Unmatched → user_id: null, match_status: 'unmatched'
  • Ambiguous → awaiting admin resolution

Step 5 — Upsert user_external_identity (user resources only). For every matched row, the sync post-processor upserts a canonical identity link with source: 'connector_sync', copying attributes from connector_resource so workflows get a self-contained view. The resulting row ID is written back to connector_sync_match.external_identity_id.

Step 6 — Apply profile attribute mappings. Separately, the post-processor applies the configured attribute mappings (e.g. attributes.orgUnitPathuser_profile.department). This remains independent from identity linking.

Why attributes are duplicated between connector_resource and user_external_identity

For matched users, connector_resource.attributes and user_external_identity.attributes contain the same data. The duplication is intentional:

Table Scope Purpose
connector_resource.attributes All synced users (including unmatched) Raw snapshot — feeds sync admin UI, reconciliation, post-processing
user_external_identity.attributes Matched users only Canonical link — direct read path for workflows, entitlements, user profile views

Without duplication, workflows would need a conditional join: sync-sourced identities would join to connector_resource, while workflow-sourced and manual-sourced identities would have no resource to join to. Copying the attributes keeps every row self-contained and makes the read API uniform. Storage cost is ~1 KB per matched user per connector — negligible even at large scale.

Workflows

Reading external identities

When a workflow variable of type user is expanded, Floh attaches an externalIdentities array to the user object alongside profile, email, displayName, etc.

{{requestor.externalIdentities.[0].externalEmail}}

For structured access, a transform step can iterate:

var googleAccount = floh.variables.requestor.externalIdentities.find(function (id) {
  return id.connectorId === "google-workspace-prod";
});
return { googleEmail: googleAccount ? googleAccount.externalEmail : null };

After provisioning an account via a connector step, use an identity_link step to record the link in user_external_identity.

Config field Description
userId Floh user ID (use {{requestor.id}})
connectorId Connector definition ID
externalId External account ID (use the output of the connector step, e.g. {{userId}})
externalEmail Optional — external email for reverse lookup
externalDisplayName Optional — display name for UI
attributesFrom Optional dot-path — copies that variable's value into attributes (handy for persisting the full connector output)

Example for the Google Workspace account request workflow:

{
  "type": "identity_link",
  "config": {
    "userId": "{{requestor.id}}",
    "connectorId": "google-workspace-prod",
    "externalId": "{{userId}}",
    "externalEmail": "{{primaryEmail}}",
    "externalDisplayName": "{{firstName}} {{lastName}}",
    "attributesFrom": "createdUser",
  },
}

The step sets source: workflow and verified_at: now. If a higher-trust link already exists for the same tuple, the step fails with a descriptive error.

Viewing external identities (admin UI)

Admins with user:read can see every external account linked to a user directly on the user profile page.

  1. Navigate to Users in the admin sidebar, then click a user to open their profile.
  2. Scroll to the External Identities section below the profile attributes.
  3. Each row shows the connector (name + type), the external account's display name and email, the raw external_id, the source (manual / workflow / connector_sync), and the verified_at timestamp.
  4. Rows whose connector has been soft-deleted are still listed and flagged with a Connector deleted tag so stale links remain visible instead of silently disappearing.

The panel is read-only in this release — it's the fastest way to confirm that a provisioning workflow's identity_link step (or a connector sync pass) correctly wrote the link you expected. For programmatic access, use:

GET /users/{userId}/external-identities

The response shape matches the DTO in packages/shared/src/external-identity.types.ts, enriched with connectorName, connectorType, and connectorDeleted display fields.

Manual linking (admin)

Admins can link or unlink external identities through the identity management UI (forthcoming — the list view above is read-only). Manual rows carry source: manual and always take precedence over sync-sourced or workflow-sourced rows.

Relationship to connector_sync_match

connector_sync_match still exists and still owns sync reconciliation metadata (match_status, match_confidence, candidate_user_ids, resolution_notes, resolved_by). The new external_identity_id column on that table links it to the canonical identity row whenever a match is resolved.

This separation keeps concerns clean:

  • connector_sync_match answers: "how confident is sync about this match? who resolved it?"
  • user_external_identity answers: "what are this user's external accounts, regardless of how they were discovered?"