15 KiB
Identity Module -- Model Reference
Module: identity
Schema: identity
Source: Projected from data-model.md identity section
Identity & Credentials answers: "Who is this actor, and how do they authenticate?"
This module covers both human and non-human actors. Human actors authenticate through the identity provider (Keycloak) as users, or programmatically through personal access tokens. Non-human actors authenticate through service account API keys or (optionally) Keycloak client credentials -- though service account entities themselves are structurally placed in the organization module, as they belong to organizations rather than existing independently.
users
A user is an authentication identity. It represents a login credential managed by the identity provider (Keycloak). This table is a local cache of authentication data, synchronized from the identity provider.
A user is not a person. A user is a way for a person to authenticate. This distinction matters because:
- A person exists in the business domain even before they create a login (e.g., when invited)
- Authentication data (email, username) may differ from legal/business data
- If the identity provider changes, user records change but person records persist
| Field | Type | Purpose |
|---|---|---|
user_id |
UUID | Primary key |
oidc_subject |
VARCHAR(255) | Unique identifier from identity provider. Immutable reference that survives email changes. |
oidc_issuer |
VARCHAR(255) | Which identity provider issued this identity. Supports multiple IdPs. |
email |
VARCHAR(255) | Email used for authentication. May differ from billing email. |
email_verified |
BOOLEAN | Whether the identity provider has verified this email. |
username |
VARCHAR(100) | Display username from identity provider. |
display_name |
VARCHAR(255) | Human-readable name for UI display. |
avatar_url |
VARCHAR(500) | Profile image URL. |
locale |
VARCHAR(10) | Preferred language/locale. |
timezone |
VARCHAR(50) | Preferred timezone for date display. |
last_login_at |
TIMESTAMPTZ | Most recent authentication timestamp. |
last_login_ip |
INET | IP address of most recent login. For security auditing. |
status |
VARCHAR(20) | Account state: active, suspended, deleted. |
suspended_at |
TIMESTAMPTZ | When most recently suspended. |
deleted_at |
TIMESTAMPTZ | When deleted. |
created_at |
TIMESTAMPTZ | When this auth identity was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
oidc_subjectis UNIQUE.
Status lifecycle:
active->suspended: Administrative suspension. Reversible.suspended->active: Reinstatement.activeorsuspended->deleted: Set during person anonymization. Terminal state.
Relationships:
users(1) -> (0..1)persons: A user may be linked to a person. The relationship is optional on the persons side because a person can exist before they sign up (invited but not yet registered).
Behavioral contract: No plaintext secrets are stored. All auth state is Keycloak-authoritative. When a person is anonymized, the users record is overwritten (not deleted): email, username, and display_name are replaced with synthetic anonymous values; last_login_ip, avatar_url, and oidc_subject are set to NULL. The status transitions to 'deleted'. The row is retained because the audit log references user_id for actor attribution -- deleting it would corrupt audit provenance.
persons
A person is a human being as a business and legal entity. It holds information needed for invoices, contracts, tax forms, and cooperative membership.
A person is not a user. A person is a human who participates in business relationships. This distinction matters because:
- Business data (legal name, tax ID, mailing address) does not belong in the authentication system
- A person may be invited to an organization before they create a login
- Cooperative membership, equity ownership, and patronage belong to the person, not to their login credentials
- Legal and tax requirements (like 1099-PATR forms) require data the auth system does not have
| Field | Type | Purpose |
|---|---|---|
person_id |
UUID | Primary key |
user_id |
UUID | FK -> users. Link to authentication identity. NULL if invited but not yet signed up. |
legal_first_name |
VARCHAR(100) | Legal first name as it appears on official documents. |
legal_last_name |
VARCHAR(100) | Legal last name as it appears on official documents. |
phone |
VARCHAR(50) | Contact phone number. |
address_line1 |
VARCHAR(255) | Street address line 1. Required for invoices and tax forms. |
address_line2 |
VARCHAR(255) | Street address line 2. |
city |
VARCHAR(100) | City. |
state_province |
VARCHAR(100) | State or province. |
postal_code |
VARCHAR(20) | Postal/ZIP code. |
country_code |
VARCHAR(2) | ISO 3166-1 alpha-2 country code. |
tax_id_type |
VARCHAR(20) | Type of tax identifier: ssn, ein, itin, vat, gst, other. |
tax_id_last4 |
VARCHAR(4) | Last 4 digits of tax ID. Full ID stored in secure vault. |
tax_id_verified |
BOOLEAN | Whether tax ID has been verified. |
tax_id_verified_at |
TIMESTAMPTZ | When tax ID was verified. |
retention_hold |
BOOLEAN DEFAULT FALSE | Whether this person's PII is subject to an active retention obligation. When true, the anonymization protocol must not execute without resolving the hold. |
status |
VARCHAR(20) | State: pending, active, inactive, partially_erased, anonymized, merged. |
activated_at |
TIMESTAMPTZ | When pending -> active. |
deactivated_at |
TIMESTAMPTZ | When active -> inactive. |
deactivated_by |
UUID | FK -> persons (self-referential). Who deactivated. |
partially_erased_at |
TIMESTAMPTZ | When partial erasure was performed. |
anonymized_at |
TIMESTAMPTZ | When full anonymization was performed. |
created_at |
TIMESTAMPTZ | When this person record was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
user_idhas a partial unique index:UNIQUE (user_id) WHERE user_id IS NOT NULL. A user maps to at most one person. This permits arbitrarily many person records withuser_id = NULL(pending invitations awaiting signup).
Status lifecycle:
pending->active: Invitation accepted, account established.active->inactive: Administrative deactivation. Reversible.inactive->active: Reactivation.activeorinactive->partially_erased: Erasure requested but retention holds prevent full anonymization. Non-retained PII scrubbed. Not reversible.activeorinactive->anonymized: Full PII destruction. Irrevocable terminal state.partially_erased->anonymized: All retention holds released. Remaining PII scrubbed. Irrevocable.active->merged: Duplicate resolved. All FK references repointed to surviving person. Irrevocable.anonymized-> any: Invalid. Terminal state.merged-> any: Invalid. Terminal state.
Status lifecycle governed by the Soft-Delete and Terminal State Policy (documents/policy-soft-delete-terminal-state.md).
Relationships:
persons(0..1) -> (1)users: A person may be linked to a user for authentication. The relationship is 1:1: each user maps to at most one person (enforced by partial unique index onuser_id), and each person has at most one user (enforced by the single FK column).persons(1) -> (0..1)organizationsorganizationschema: A person may own a personal organization.persons(1) -> (0..*)org_membersorganizationschema: A person may be a member of multiple organizations.persons(1) -> (0..*)workspacesorganizationschema: A person may have created workspaces.persons(1) -> (0..*)role_assignmentsorganizationschema: A person may have scoped role assignments.persons(1) -> (0..*)personal_access_tokens: A person may have programmatic access tokens.persons(1) -> (0..*)retention_holds: A person may have active retention obligations.
personal_access_tokens
A personal_access_token is a credential that allows a person to access the API programmatically. The token acts as the person -- it inherits the person's permissions (potentially narrowed by scopes). It is the most common form of programmatic access for human users.
Personal access tokens are distinct from service accounts. A PAT acts as the person (same identity, same permissions). A service account acts as itself (independent identity, independently granted permissions). This distinction matters for audit trails, revocation semantics, and the "what happens when a person leaves the organization" question -- their PATs are revoked with their account, but service accounts they created continue operating.
| Field | Type | Purpose |
|---|---|---|
token_id |
UUID | Primary key |
person_id |
UUID | FK -> persons. The person this token acts as. |
name |
VARCHAR(255) | Human-readable name (e.g., "CI deploy token", "Local development"). |
description |
TEXT | What this token is used for. |
token_hash |
VARCHAR(255) | Hashed token value. The plaintext is shown once at creation and never stored. |
token_prefix |
VARCHAR(10) | First few characters of the token, for identification in logs and UI (e.g., "mc_pat_a3f..."). |
scopes |
TEXT[] | Permission scopes this token is limited to. NULL means full permissions of the person. When set, effective permissions are the intersection of the person's permissions and the token's scopes. |
expires_at |
TIMESTAMPTZ | When this token expires. NULL for no expiration (discouraged). |
last_used_at |
TIMESTAMPTZ | When this token was last used for authentication. |
last_used_ip |
INET | IP address of last use. |
revoked_at |
TIMESTAMPTZ | When this token was revoked. |
revoked_by_person_id |
UUID | FK -> persons. Who revoked this token (may differ from the owner). |
status |
VARCHAR(20) | active, expired, revoked. |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
Token format convention: mc_pat_<random> -- the prefix mc identifies the Member Console, pat identifies the token type. Leaked tokens can be identified and traced by prefix.
Security properties:
- The plaintext token is displayed exactly once at creation. Only the hash is stored.
- The
token_prefixallows identification without exposing the full token. - Expired and revoked tokens cannot authenticate. Status checks are part of the authentication path.
- Tokens inherit the person's current permissions, not a snapshot. If the person loses access, their tokens lose access immediately.
Relationships:
personal_access_tokens(0..*) -> (1)persons: Tokens belong to a person.
retention_holds
A retention_hold records an active legal obligation requiring retention of a person's PII. Retention holds prevent the anonymization protocol from executing full erasure and instead trigger partial erasure -- scrubbing non-retained data while preserving fields required by the cited legal authority.
| Field | Type | Purpose |
|---|---|---|
hold_id |
UUID | Primary key |
person_id |
UUID | FK -> persons. The person whose PII is held. |
legal_authority |
VARCHAR(100) | The legal basis for retention (e.g., irc_6001, irc_1381_1388, 26_cfr_31_6001). |
description |
TEXT | Human-readable explanation of the retention obligation. |
data_categories |
TEXT[] | Which data categories are held (e.g., ['legal_name', 'tax_id', 'billing_address']). |
hold_placed_at |
TIMESTAMPTZ | When the hold was placed. |
hold_placed_by |
UUID | FK -> persons. Who placed the hold (NULL if system-automated). |
hold_expires_at |
TIMESTAMPTZ | When the retention obligation expires. NULL if the expiration depends on a future event (e.g., equity redemption). |
hold_released_at |
TIMESTAMPTZ | When the hold was released. NULL if still active. |
hold_released_by |
UUID | FK -> persons. Who released the hold. |
release_reason |
TEXT | Why the hold was released. |
status |
VARCHAR(20) | active, released, expired. |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
Behavioral contract: When any retention_holds record with status = 'active' exists for a person, the persons.retention_hold flag must be true. When the last active hold is released or expires, the flag is set to false. This denormalization is acceptable because the flag is a hot-path check (consulted on every anonymization request) while the holds table is a cold-path audit record (consulted when evaluating why a hold exists and when it expires).
Relationships:
retention_holds(0..*) -> (1)persons: Holds apply to a person.
person_merges
A person_merge records that one person record was merged into another. This provides structured provenance for duplicate resolution, supporting potential reversal and audit compliance.
| Field | Type | Purpose |
|---|---|---|
merge_id |
UUID | Primary key |
source_person_id |
UUID | FK -> persons. The person being merged away. |
target_person_id |
UUID | FK -> persons. The surviving person. |
merged_by_person_id |
UUID | FK -> persons. Who authorized the merge. |
merged_at |
TIMESTAMPTZ | When the merge was performed. |
reason |
TEXT | Why the merge was necessary. |
affected_references |
JSONB | Snapshot of tables and row counts updated during the merge. Governed by JSONB Governance Policy (documents/policy-jsonb-governance.md). |
created_at |
TIMESTAMPTZ |
Constraints:
source_person_id!=target_person_id.
Merge operation: Executes within a single transaction: (1) validate source is not already merged or anonymized; (2) repoint all FK references from source to target across all person-referencing tables; (3) transfer active retention holds from source to target; (4) set source status to merged; (5) insert person_merges record; (6) insert audit_logs entry. See the Soft-Delete and Terminal State Policy for the full affected-tables checklist and uniqueness conflict resolution.
Relationships:
person_merges(0..*) -> (1)persons: Merges reference source, target, and merged_by persons. All three FK columns reference the persons table within this module.
Cross-Module FK Summary
All foreign key relationships within this module are intra-module. The identity module has zero outbound cross-module FKs -- it does not reference any table outside its own boundary. Outbound references noted in the persons relationship list (organizations, org_members, workspaces, role_assignments) represent downstream consumers that reference persons.person_id, not FKs owned by the identity module.