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. attributesJSONB (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_idset,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.orgUnitPath → user_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.
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 };
Writing external identities (identity_link step)¶
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.
- Navigate to Users in the admin sidebar, then click a user to open their profile.
- Scroll to the External Identities section below the profile attributes.
- Each row shows the connector (name + type), the external account's display name and email, the raw
external_id, thesource(manual/workflow/connector_sync), and theverified_attimestamp. - 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:
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_matchanswers: "how confident is sync about this match? who resolved it?"user_external_identityanswers: "what are this user's external accounts, regardless of how they were discovered?"