Files
member-console/design/identity/model.md
Christian Galo 195cd348a9 Update design to use schemas per module and revert to using industry
aligned terminology for discounts and coupons and promo codes.
2026-04-01 03:08:35 -05:00

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_subject is UNIQUE.

Status lifecycle:

  • active -> suspended: Administrative suspension. Reversible.
  • suspended -> active: Reinstatement.
  • active or suspended -> 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_id has 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 with user_id = NULL (pending invitations awaiting signup).

Status lifecycle:

  • pending -> active: Invitation accepted, account established.
  • active -> inactive: Administrative deactivation. Reversible.
  • inactive -> active: Reactivation.
  • active or inactive -> partially_erased: Erasure requested but retention holds prevent full anonymization. Non-retained PII scrubbed. Not reversible.
  • active or inactive -> 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 on user_id), and each person has at most one user (enforced by the single FK column).
  • persons (1) -> (0..1) organizations organization schema: A person may own a personal organization.
  • persons (1) -> (0..*) org_members organization schema: A person may be a member of multiple organizations.
  • persons (1) -> (0..*) workspaces organization schema: A person may have created workspaces.
  • persons (1) -> (0..*) role_assignments organization schema: 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_prefix allows 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.