Skip to main content

Tenant Service — Data Model

Status: populated Owner: TBD Last updated: 2026-04-18 Companion: Service Template · NAMING · 13 Security

Schema: tenant ID strategy: ULID + service-scoped prefix. All surrogate keys CHAR(26). Isolation: Postgres RLS on all tenant-scoped tables. SET app.tenant_id per connection.

1. ID prefix registry

PrefixEntityTable
ten_Tenanttenants
nod_HierarchyNodehierarchy_nodes
prf_UserProfileuser_profiles
mem_OrgMembershiporg_memberships
rol_Roleroles
roa_RoleAssignmentrole_assignments
pol_AccessPolicyaccess_policies

2. TypeScript interfaces (domain)

type TenantId = Branded<string, 'TenantId'>
type NodeId = Branded<string, 'NodeId'>

interface Tenant {
id: TenantId
slug: string
legalName: string
displayName: string
status: 'pending' | 'active' | 'suspended' | 'terminated'
countryCode: string
timezone: string
locale: string
hierarchyProfileId: string | null
subscriptionTier: 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE'
subscriptionStart: Date | null
subscriptionEnd: Date | null
rootNodeId: NodeId | null
contactEmail: string
createdAt: Date
updatedAt: Date
activatedAt: Date | null
suspendedAt: Date | null
terminatedAt: Date | null
}

interface HierarchyNode {
id: NodeId
tenantId: TenantId
parentId: NodeId | null
nodeType: 'organization' | 'facility' | 'department' | 'ward' | 'bed'
name: string
code: string
attributes: Record<string, unknown>
isArchived: boolean
createdAt: Date
updatedAt: Date
}

3. Postgres schema

CREATE SCHEMA IF NOT EXISTS tenant;
SET search_path TO tenant, public;

CREATE TABLE tenants (
id CHAR(26) PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE,
legal_name VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','active','suspended','terminated')),
country_code CHAR(2) NOT NULL,
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
locale VARCHAR(20) NOT NULL DEFAULT 'en',
hierarchy_profile_id VARCHAR(50),
subscription_tier VARCHAR(20) NOT NULL DEFAULT 'STARTER',
subscription_start DATE,
subscription_end DATE,
root_node_id CHAR(26),
contact_email VARCHAR(320) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
suspended_at TIMESTAMPTZ,
terminated_at TIMESTAMPTZ
);
CREATE INDEX ix_tenants_status ON tenants(status);
CREATE INDEX ix_tenants_slug ON tenants(slug);
-- No RLS on tenants table — SUPER_ADMIN queries all; TENANT_ADMIN filtered by guard

CREATE TABLE tenant_configurations (
tenant_id CHAR(26) NOT NULL REFERENCES tenants(id),
key VARCHAR(100) NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, key)
);
ALTER TABLE tenant_configurations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_config_isolation ON tenant_configurations
USING (tenant_id = current_setting('app.tenant_id', true)::char(26));

CREATE TABLE hierarchy_nodes (
id CHAR(26) PRIMARY KEY,
tenant_id CHAR(26) NOT NULL REFERENCES tenants(id),
parent_id CHAR(26) REFERENCES hierarchy_nodes(id),
node_type VARCHAR(20) NOT NULL,
name VARCHAR(255) NOT NULL,
code VARCHAR(50),
attributes JSONB NOT NULL DEFAULT '{}',
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_hierarchy_nodes_tenant ON hierarchy_nodes(tenant_id);
CREATE INDEX ix_hierarchy_nodes_parent ON hierarchy_nodes(parent_id) WHERE parent_id IS NOT NULL;
ALTER TABLE hierarchy_nodes ENABLE ROW LEVEL SECURITY;
CREATE POLICY hierarchy_nodes_isolation ON hierarchy_nodes
USING (tenant_id = current_setting('app.tenant_id', true)::char(26));

CREATE TABLE user_profiles (
id CHAR(26) PRIMARY KEY,
tenant_id CHAR(26) NOT NULL REFERENCES tenants(id),
user_id CHAR(26) NOT NULL, -- identity-service UserId
first_name VARCHAR(100),
last_name VARCHAR(100),
specialty VARCHAR(100),
credentials TEXT,
locale VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, user_id)
);
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_profiles_isolation ON user_profiles
USING (tenant_id = current_setting('app.tenant_id', true)::char(26));

CREATE TABLE org_memberships (
id CHAR(26) PRIMARY KEY,
tenant_id CHAR(26) NOT NULL REFERENCES tenants(id),
user_id CHAR(26) NOT NULL,
node_id CHAR(26) NOT NULL REFERENCES hierarchy_nodes(id),
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active','suspended','removed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, node_id)
);
CREATE INDEX ix_org_mem_tenant_user ON org_memberships(tenant_id, user_id);
ALTER TABLE org_memberships ENABLE ROW LEVEL SECURITY;
CREATE POLICY org_memberships_isolation ON org_memberships
USING (tenant_id = current_setting('app.tenant_id', true)::char(26));

CREATE TABLE roles (
id CHAR(26) PRIMARY KEY,
tenant_id CHAR(26) REFERENCES tenants(id), -- NULL = built-in platform role
code VARCHAR(60) NOT NULL,
name VARCHAR(120) NOT NULL,
is_builtin BOOLEAN NOT NULL DEFAULT FALSE,
permissions TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, code)
);

CREATE TABLE role_assignments (
id CHAR(26) PRIMARY KEY,
tenant_id CHAR(26) NOT NULL REFERENCES tenants(id),
user_id CHAR(26) NOT NULL,
role_id CHAR(26) NOT NULL REFERENCES roles(id),
node_id CHAR(26) REFERENCES hierarchy_nodes(id),
assigned_by CHAR(26) NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, role_id, node_id)
);
ALTER TABLE role_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY role_assignments_isolation ON role_assignments
USING (tenant_id = current_setting('app.tenant_id', true)::char(26));

CREATE TABLE outbox (
id CHAR(26) PRIMARY KEY,
tenant_id CHAR(26),
subject VARCHAR(200) NOT NULL,
payload JSONB NOT NULL,
headers JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
attempt INT NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX ix_outbox_unpublished ON outbox(published_at) WHERE published_at IS NULL;

CREATE TABLE inbox (
id CHAR(26) PRIMARY KEY,
subject VARCHAR(200) NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);

4. Volume estimates

TableYear-1 rowsRetention
tenants500Forever
tenant_configurations5 000Forever
hierarchy_nodes50 000Forever (archived soft-delete)
user_profiles500 000Forever (anonymized on deactivation)
org_memberships2 MForever
role_assignments3 MForever
outboxrolling7 days after publish
inboxrolling30 days

5. RLS bypass role

tenant_rls_bypass — used by cron jobs (subscription expiry) with BYPASSRLS. Separate connection pool with strict GRANT.