diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 1e49ebc..028ad24 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,3 +1,5 @@ +import hmac +import hashlib from datetime import datetime, timedelta, timezone from typing import Any @@ -34,3 +36,15 @@ def verify_password( def get_password_hash(password: str) -> str: return password_hash.hash(password) + + +def hash_session_token(token: str) -> bytes: + """ + Hash a session token using HMAC-SHA256 with the application's SECRET_KEY. + Used to store one-way hashes of bearer tokens in the database. + """ + return hmac.new( + settings.SECRET_KEY.encode(), + token.encode(), + hashlib.sha256 + ).digest() diff --git a/migrations/01_init.sql b/migrations/01_init.sql deleted file mode 100644 index 83b7c23..0000000 --- a/migrations/01_init.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Sample database initialization script --- This file runs when the PostgreSQL container initializes for the first time --- Place your schema creation, data seeding, or migration scripts here - --- Example: Create a simple table --- CREATE TABLE IF NOT EXISTS example ( --- id SERIAL PRIMARY KEY, --- name VARCHAR(255) NOT NULL, --- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP --- ); - --- Add your SQL statements below --- Note: Scripts run in alphabetical order, so prefix with numbers if order matters (e.g., 01_init.sql, 02_seed.sql) \ No newline at end of file diff --git a/migrations/01_schemas.sql b/migrations/01_schemas.sql new file mode 100644 index 0000000..0fadc75 --- /dev/null +++ b/migrations/01_schemas.sql @@ -0,0 +1,16 @@ +-- ============================================================ +-- Migration: Schema Definitions +-- PostgreSQL 18 +-- Run order: 01_schemas.sql (first) +-- ============================================================ +-- Creates all schemas used by the LaaS platform. +-- Must be run before any table creation migrations. +-- ============================================================ + +CREATE SCHEMA IF NOT EXISTS reference; +CREATE SCHEMA IF NOT EXISTS public; +CREATE SCHEMA IF NOT EXISTS audit; + +COMMENT ON SCHEMA reference IS 'Static lookup/reference tables for enumerations and constants. All rows inserted once at deploy time and treated as immutable. Separated to isolate migration risk and enable role-based access control (GRANT SELECT only to app role).'; +COMMENT ON SCHEMA public IS 'Core business tables for the LaaS licensing platform. Contains all mutable business entities with lifecycle state (vendors, licenses, sessions, heartbeats).'; +COMMENT ON SCHEMA audit IS 'Immutable append-only audit trail. Separated from public schema to enforce independent access control: audit readers have no business table access, and business writers cannot delete audit records. All tables are INSERT-only; no UPDATE or DELETE operations permitted.'; diff --git a/migrations/02_reference.sql b/migrations/02_reference.sql new file mode 100644 index 0000000..1f715ca --- /dev/null +++ b/migrations/02_reference.sql @@ -0,0 +1,128 @@ +-- ============================================================ +-- Migration: Reference Schema — Lookup Tables +-- PostgreSQL 18 +-- Run order: 02_reference.sql +-- Depends on: 01_schemas.sql +-- ============================================================ +-- All lookup tables use their `code` TEXT column as the PRIMARY +-- KEY. This avoids a surrogate UUID that carries no information +-- and makes FK columns in referencing tables self-documenting +-- (e.g. licenseStatusCode = 'ACTIVE' is readable without a join). +-- +-- Rows are seeded at deploy time and treated as immutable. +-- New values are added via INSERT only — never UPDATE or DELETE. +-- ============================================================ + +-- ------------------------------------------------------------ +-- reference."licenseStatuses" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS reference."licenseStatuses" ( + "code" TEXT PRIMARY KEY, + "description" TEXT NOT NULL +); + +COMMENT ON TABLE reference."licenseStatuses" IS 'Lookup table for license lifecycle states. EXPIRED is intentionally omitted: expiry is a derived state computed at query time from licenses.expiresAt. Storing it redundantly would risk inconsistency.'; +COMMENT ON COLUMN reference."licenseStatuses"."code" IS 'Machine-readable status code (PK). Self-documents FK references. Examples: ACTIVE, REVOKED.'; +COMMENT ON COLUMN reference."licenseStatuses"."description" IS 'Human-readable explanation of this license state for developers and operators.'; + +INSERT INTO reference."licenseStatuses" ("code", "description") VALUES + ('ACTIVE', 'License is valid and can be activated by a customer device.'), + ('REVOKED', 'License was manually revoked by the vendor; no further activations or heartbeats are permitted.') +ON CONFLICT ("code") DO NOTHING; + +-- ------------------------------------------------------------ +-- reference."sessionStatuses" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS reference."sessionStatuses" ( + "code" TEXT PRIMARY KEY, + "description" TEXT NOT NULL +); + +COMMENT ON TABLE reference."sessionStatuses" IS 'Lookup table for session lifecycle states. Derived states (grace period exceeded, license expired) are computed at query time, not stored.'; +COMMENT ON COLUMN reference."sessionStatuses"."code" IS 'Machine-readable status code (PK). Examples: ACTIVE, REVOKED, ZOMBIE, CLEANUP.'; +COMMENT ON COLUMN reference."sessionStatuses"."description" IS 'Human-readable explanation of this session state for developers and operators.'; + +INSERT INTO reference."sessionStatuses" ("code", "description") VALUES + ('ACTIVE', 'Session is running and receiving heartbeats normally.'), + ('REVOKED', 'Session was explicitly terminated by a vendor action or an automated system process.'), + ('ZOMBIE', 'Session has missed the configured heartbeat grace period and is considered dead. Retained in the table until evicted by the scheduled cleanup job or displaced when a new activation against the same license would exceed maxSessions.'), + ('CLEANUP', 'Session is soft-deleted and retained solely for audit continuity. Eligible for hard deletion after the configured retention window expires.') +ON CONFLICT ("code") DO NOTHING; + +-- ------------------------------------------------------------ +-- reference."heartbeatRespStatuses" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS reference."heartbeatRespStatuses" ( + "code" TEXT PRIMARY KEY, + "description" TEXT NOT NULL +); + +COMMENT ON TABLE reference."heartbeatRespStatuses" IS 'Lookup table for heartbeat response codes returned by server to SDK. Server selects a code based on license and session state; SDK acts on it.'; +COMMENT ON COLUMN reference."heartbeatRespStatuses"."code" IS 'Machine-readable response code (PK). Examples: CONTINUE, REFRESH, REVOKED, EXPIRED, ERROR.'; +COMMENT ON COLUMN reference."heartbeatRespStatuses"."description" IS 'Human-readable description of the response code and SDK action it mandates.'; + +INSERT INTO reference."heartbeatRespStatuses" ("code", "description") VALUES + ('CONTINUE', 'License is valid and the session is healthy. SDK should continue normal protected operation with no state change.'), + ('REFRESH', 'The vendor has legitimately modified the license configuration since the last heartbeat (e.g. expiry extended, maxGraceSecs changed, metadata updated). SDK must re-fetch the current license state and apply it before continuing.'), + ('REVOKED', 'Server determined the license has been revoked. SDK must immediately halt all protected functionality and notify the end user.'), + ('EXPIRED', 'Server determined the license has passed its expiresAt timestamp. SDK must immediately halt all protected functionality and notify the end user.'), + ('ERROR', 'An unexpected server-side error occurred during heartbeat validation. SDK should log the event and retry with exponential backoff; do not immediately halt protected functionality.') +ON CONFLICT ("code") DO NOTHING; + +-- ------------------------------------------------------------ +-- reference."errorCodes" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS reference."errorCodes" ( + "code" TEXT PRIMARY KEY, + "description" TEXT NOT NULL +); + +COMMENT ON TABLE reference."errorCodes" IS 'Canonical lookup table for business error codes used in API responses and SDK error handling. HTTP status codes are mapped exclusively in application code (decoupled from business logic).'; +COMMENT ON COLUMN reference."errorCodes"."code" IS 'Machine-readable error code constant (PK). Referenced by API responses, SDK handlers, and logs.'; +COMMENT ON COLUMN reference."errorCodes"."description" IS 'Human-readable explanation of the error condition for developers and operators.'; + +INSERT INTO reference."errorCodes" ("code", "description") VALUES + ('INVALID_CREDENTIALS', 'Authentication failed due to incorrect email or password.'), + ('INVALID_TOKEN', 'JWT access token is missing, malformed, or has expired.'), + ('INVALID_REFRESH_TOKEN', 'Refresh token is missing, invalid, or has passed its expiry.'), + ('INVALID_LICENSE_KEY', 'The provided license key does not exist in the system or is malformed.'), + ('LICENSE_REVOKED', 'The license has been revoked by the vendor and is no longer valid.'), + ('LICENSE_EXPIRED', 'The license has passed its configured expiry date.'), + ('INVALID_DEVICE', 'The device fingerprint submitted does not match the registered fingerprint for this node-locked license.'), + ('UNAUTHORIZED', 'The authenticated actor does not have permission to access or modify this resource (RLS policy violation).'), + ('NOT_FOUND', 'The requested resource does not exist.'), + ('GRACE_PERIOD_EXCEEDED', 'The session has not received a successful heartbeat within the configured grace period window.'), + ('MAX_SESSIONS_EXCEEDED', 'The license has reached the maximum number of permitted concurrent active sessions for this device.'), + ('INTERNAL_ERROR', 'An unexpected internal server error occurred.') +ON CONFLICT ("code") DO NOTHING; + +-- ------------------------------------------------------------ +-- reference."actions" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS reference."actions" ( + "code" TEXT PRIMARY KEY, + "description" TEXT NOT NULL +); + +COMMENT ON TABLE reference."actions" IS 'Lookup table for auditable action verbs. Codes are resource-agnostic; affected resource is captured in audit junction tables (auditLogLicenses, auditLogSessions, etc.). Allows adding new resource types in v1.0 without modifying audit.auditLogs.'; +COMMENT ON COLUMN reference."actions"."code" IS 'Machine-readable action verb (PK). Examples: CREATED, MODIFIED, REVOKED, DELETED.'; +COMMENT ON COLUMN reference."actions"."description" IS 'Human-readable description of what this action represents in the system.'; + +INSERT INTO reference."actions" ("code", "description") VALUES + ('SIGNUP', 'A new actor account was registered on the platform.'), + ('LOGIN_SUCCESS', 'An actor successfully authenticated and received an access token.'), + ('LOGIN_FAILED', 'An actor authentication attempt failed due to invalid credentials.'), + ('TOKEN_REFRESHED', 'An actor obtained a new access token using a valid refresh token.'), + ('CREATED', 'A new resource was created.'), + ('MODIFIED', 'An existing resource was modified.'), + ('REVOKED', 'A resource was revoked by an authorised actor.'), + ('EXPIRED', 'A resource was transitioned to an expired state by the system.'), + ('ACTIVATED', 'A new session was created via a successful license key activation.'), + ('HEARTBEAT_ERROR', 'A heartbeat was received but produced a non-CONTINUE response; the event is appended to public.heartbeats for the audit trail.'), + ('DELETED', 'A resource was soft-deleted.') +ON CONFLICT ("code") DO NOTHING; diff --git a/migrations/03_public.sql b/migrations/03_public.sql new file mode 100644 index 0000000..901c10a --- /dev/null +++ b/migrations/03_public.sql @@ -0,0 +1,165 @@ +-- ============================================================ +-- Migration: Public Schema — Business Tables +-- PostgreSQL 18 +-- Run order: 03_public.sql +-- Depends on: 01_schemas.sql, 02_reference.sql +-- ============================================================ +-- UUID strategy: uuidv7() is a native function in PostgreSQL 18 +-- (pg_catalog.uuidv7). It generates time-ordered UUIDs (v7), +-- improving B-tree index locality compared to random UUIDv4. +-- The DEFAULT on each surrogate id column ensures the database +-- generates the value when the application omits it. +-- NOTE: uuidv7() is used only on surrogate PKs that the DB +-- generates. Extension table PKs that are FK references (e.g. +-- nodeLockedLicenseData.licenseId) carry no DEFAULT because their +-- value must equal the parent row's id. +-- +-- Index strategy: non-unique indexes are intentionally deferred +-- pending real query profile data. Exceptions: heartbeats indexes +-- (time-series BRIN + sessionId btree) are included from day one +-- as the partitioning strategy requires them. +-- ============================================================ + +-- ------------------------------------------------------------ +-- public."vendors" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS "vendors" ( + "id" UUID PRIMARY KEY DEFAULT uuidv7(), + "email" TEXT NOT NULL UNIQUE, + "passwordHash" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "deletedAt" TIMESTAMPTZ +); + +COMMENT ON TABLE "vendors" IS 'Software vendors who issue and manage licenses on the platform. Root multi-tenant boundary: all RLS policies anchor to vendors.id.'; +COMMENT ON COLUMN "vendors"."id" IS 'Surrogate primary key (uuidv7). Time-ordered for B-tree locality. Tenant identifier for all RLS policies.'; +COMMENT ON COLUMN "vendors"."email" IS 'Vendor login email. Globally unique.'; +COMMENT ON COLUMN "vendors"."passwordHash" IS 'Salted hash of vendor password (bcrypt ≥12 rounds or Argon2 with adaptive cost). Raw password never persisted. Algorithm and parameters vary; application handles verification.'; +COMMENT ON COLUMN "vendors"."createdAt" IS 'Account creation timestamp.'; +COMMENT ON COLUMN "vendors"."updatedAt" IS 'Last update timestamp. Application must update on every write.'; +COMMENT ON COLUMN "vendors"."deletedAt" IS 'Soft-delete marker. Non-NULL means account is deactivated. All downstream data retained for audit.'; + +-- ------------------------------------------------------------ +-- public."licenses" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS "licenses" ( + "id" UUID PRIMARY KEY DEFAULT uuidv7(), + "vendorId" UUID NOT NULL REFERENCES "vendors"("id") ON DELETE RESTRICT, + "clientId" UUID, + "licenseStatusCode" TEXT NOT NULL REFERENCES reference."licenseStatuses"("code") ON DELETE RESTRICT, + "expiresAt" TIMESTAMPTZ, + "maxGraceSecs" INTEGER NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "deletedAt" TIMESTAMPTZ, + CONSTRAINT "licenses_maxGraceSecs_positive" CHECK ("maxGraceSecs" > 0) +); + +COMMENT ON TABLE "licenses" IS 'Licenses issued by vendors to customers. Root entity for activation, session management, and audit trail. License type determined by presence of extension row (e.g. nodeLockedLicenseData).'; +COMMENT ON COLUMN "licenses"."id" IS 'Surrogate primary key (uuidv7, time-ordered).'; +COMMENT ON COLUMN "licenses"."vendorId" IS 'Owning vendor (FK). Enforces multi-tenancy. Filtered by RLS policies.'; +COMMENT ON COLUMN "licenses"."clientId" IS 'Nullable placeholder for v1.0 customers table. Allows logical grouping without FK constraint in MVP.'; +COMMENT ON COLUMN "licenses"."licenseStatusCode" IS 'Stored lifecycle status (FK to reference.licenseStatuses). EXPIRED derived at query time from expiresAt.'; +COMMENT ON COLUMN "licenses"."expiresAt" IS 'Optional expiry timestamp. NULL means perpetual. Expiry checked at query time, not stored in status.'; +COMMENT ON COLUMN "licenses"."maxGraceSecs" IS 'Seconds between heartbeats before session becomes zombie. License-level policy shared by all sessions. Application must provide explicit value.'; +COMMENT ON COLUMN "licenses"."metadata" IS 'Arbitrary vendor-defined key-value metadata (e.g. product tier, feature flags). Platform does not interpret or validate.'; +COMMENT ON COLUMN "licenses"."createdAt" IS 'License creation timestamp.'; +COMMENT ON COLUMN "licenses"."deletedAt" IS 'Soft-delete marker. Non-NULL means license inactive. Retained for audit history.'; + +-- ------------------------------------------------------------ +-- public."nodeLockedLicenseData" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS "nodeLockedLicenseData" ( + "licenseId" UUID PRIMARY KEY REFERENCES "licenses"("id") ON DELETE RESTRICT, + "licenseKey" TEXT NOT NULL UNIQUE, + "deviceFingerprintHash" TEXT, + "maxSessions" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "nodeLocked_maxSessions_positive" CHECK ("maxSessions" > 0) +); + +COMMENT ON TABLE "nodeLockedLicenseData" IS 'Extension table for node-locked license subtype. Presence of row indicates license is node-locked. Future subtypes each get their own extension table; no type discriminator needed on licenses.'; +COMMENT ON COLUMN "nodeLockedLicenseData"."licenseId" IS 'FK to and PK of parent license. Enforces strict 1:1 relationship. RESTRICT prevents parent deletion while extension exists.'; +COMMENT ON COLUMN "nodeLockedLicenseData"."licenseKey" IS 'Cryptographically random activation key distributed to customer. Globally unique.'; +COMMENT ON COLUMN "nodeLockedLicenseData"."deviceFingerprintHash" IS 'SHA-256 hash of device identifiers (BIOS UUID + CPU serial + disk serial). Computed server-side. NULL until first activation; locked at first heartbeat.'; +COMMENT ON COLUMN "nodeLockedLicenseData"."maxSessions" IS 'Max concurrent ACTIVE sessions on this device. Default 1. Prevents multi-process execution of single-node-locked license.'; + +-- ------------------------------------------------------------ +-- public."sessions" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS "sessions" ( + "id" UUID PRIMARY KEY DEFAULT uuidv7(), + "licenseId" UUID NOT NULL REFERENCES "licenses"("id") ON DELETE RESTRICT, + "sessionStatusCode" TEXT NOT NULL REFERENCES reference."sessionStatuses"("code") ON DELETE RESTRICT, + "sessionTokenHash" BYTEA NOT NULL UNIQUE, + "deviceFingerprintHash" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "metadata" JSONB +); + +COMMENT ON TABLE "sessions" IS 'Active and historical sessions created via license activation. Mostly immutable after creation. Liveness computed from heartbeats.MAX(heartbeatAt) at query time.'; +COMMENT ON COLUMN "sessions"."id" IS 'Surrogate primary key (uuidv7, time-ordered).'; +COMMENT ON COLUMN "sessions"."licenseId" IS 'License activated by this session (FK). Joined on every heartbeat to fetch maxGraceSecs, licenseStatusCode, expiresAt.'; +COMMENT ON COLUMN "sessions"."sessionStatusCode" IS 'Stored lifecycle state (ACTIVE, REVOKED, ZOMBIE, CLEANUP). Derived states (grace exceeded, expired) computed at query time.'; +COMMENT ON COLUMN "sessions"."sessionTokenHash" IS 'One-way hash (HMAC-SHA256) of the bearer token. Sole credential for heartbeat requests.'; +COMMENT ON COLUMN "sessions"."deviceFingerprintHash" IS 'Device fingerprint snapshot at session creation. Denormalized to eliminate JOIN on hot-path heartbeat validation.'; +COMMENT ON COLUMN "sessions"."createdAt" IS 'Session creation timestamp (first activation timestamp for this device+license).'; +COMMENT ON COLUMN "sessions"."metadata" IS 'Arbitrary session metadata at activation time (SDK version, OS, hostname, etc.). Immutable after creation. Platform does not interpret.'; + +-- ------------------------------------------------------------ +-- public."heartbeats" +-- ------------------------------------------------------------ +-- Append-only time-series log of all heartbeat events (CONTINUE + exceptions). +-- Includes all heartbeat types to enable accurate liveness tracking via MAX(heartbeatAt) +-- without requiring write-heavy updates to the sessions table. +-- Composite PK (id, heartbeatAt) is required by PostgreSQL: all +-- partition key columns must be included in the primary key of a +-- declaratively partitioned table. +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS "heartbeats" ( + "id" UUID NOT NULL DEFAULT uuidv7(), + "sessionId" UUID NOT NULL REFERENCES "sessions"("id") ON DELETE CASCADE, + "heartbeatRespStatusCode" TEXT NOT NULL REFERENCES reference."heartbeatRespStatuses"("code") ON DELETE RESTRICT, + "errorCode" TEXT REFERENCES reference."errorCodes"("code") ON DELETE RESTRICT, + "heartbeatAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY ("id", "heartbeatAt"), + CONSTRAINT chk_heartbeats_status_errorcode_consistency CHECK ( + ("heartbeatRespStatusCode" = 'ERROR' AND "errorCode" IS NOT NULL) OR + ("heartbeatRespStatusCode" != 'ERROR' AND "errorCode" IS NULL) + ) +) PARTITION BY RANGE ("heartbeatAt"); + +COMMENT ON TABLE "heartbeats" IS 'Append-only time-series log of all heartbeat events (CONTINUE, errors, revocations, expirations). Range-partitioned by heartbeatAt. Liveness is determined by MAX(heartbeatAt) for each session.'; +COMMENT ON COLUMN "heartbeats"."id" IS 'Unique identifier (uuidv7). Composite PK with heartbeatAt (required for partitioned table).'; +COMMENT ON COLUMN "heartbeats"."sessionId" IS 'Session that emitted this event (FK). CASCADE deletion removes heartbeats during session cleanup.'; +COMMENT ON COLUMN "heartbeats"."heartbeatRespStatusCode" IS 'Response code returned to SDK. Includes CONTINUE responses for liveness tracking without hot-table updates.'; +COMMENT ON COLUMN "heartbeats"."errorCode" IS 'Error code if response was ERROR. NULL for successful events (CONTINUE, REFRESH, REVOKED, EXPIRED).'; +COMMENT ON COLUMN "heartbeats"."heartbeatAt" IS 'Timestamp when event received. Partition key. BRIN index supports efficient time-range scans for liveness queries.'; + +-- Constraint ensuring heartbeatRespStatusCode and errorCode are consistent. +-- If heartbeatRespStatusCode='ERROR' then errorCode must be NOT NULL; otherwise errorCode must be NULL. +-- This prevents invalid states like ERROR without an error code or non-error events with spurious error codes. + +-- Partitions: pre-create 5 quarters covering 2026 and 2027 Q1. +-- Add future partitions before the current quarter boundary. +-- A DEFAULT partition is included to catch any inserts beyond the last explicit range, +-- preventing insert failures when dates exceed the latest partitioned range. + +CREATE TABLE IF NOT EXISTS "heartbeats_2026_q1" PARTITION OF "heartbeats" FOR VALUES FROM (TIMESTAMPTZ '2026-01-01 00:00:00+00') TO (TIMESTAMPTZ '2026-04-01 00:00:00+00'); +CREATE TABLE IF NOT EXISTS "heartbeats_2026_q2" PARTITION OF "heartbeats" FOR VALUES FROM (TIMESTAMPTZ '2026-04-01 00:00:00+00') TO (TIMESTAMPTZ '2026-07-01 00:00:00+00'); +CREATE TABLE IF NOT EXISTS "heartbeats_2026_q3" PARTITION OF "heartbeats" FOR VALUES FROM (TIMESTAMPTZ '2026-07-01 00:00:00+00') TO (TIMESTAMPTZ '2026-10-01 00:00:00+00'); +CREATE TABLE IF NOT EXISTS "heartbeats_2026_q4" PARTITION OF "heartbeats" FOR VALUES FROM (TIMESTAMPTZ '2026-10-01 00:00:00+00') TO (TIMESTAMPTZ '2027-01-01 00:00:00+00'); +CREATE TABLE IF NOT EXISTS "heartbeats_2027_q1" PARTITION OF "heartbeats" FOR VALUES FROM (TIMESTAMPTZ '2027-01-01 00:00:00+00') TO (TIMESTAMPTZ '2027-04-01 00:00:00+00'); +CREATE TABLE IF NOT EXISTS "heartbeats_default" PARTITION OF "heartbeats" DEFAULT; + +-- Heartbeat indexes are exceptions to the deferred index policy: +-- the time-series partitioning strategy requires the BRIN index +-- from day one, and sessionId is the primary FK access pattern. + +CREATE INDEX IF NOT EXISTS "heartbeats_sessionId_idx" ON "heartbeats" ("sessionId"); +CREATE INDEX IF NOT EXISTS "heartbeats_heartbeatAt_idx" ON "heartbeats" USING BRIN ("heartbeatAt"); diff --git a/migrations/04_audit.sql b/migrations/04_audit.sql new file mode 100644 index 0000000..90c6e72 --- /dev/null +++ b/migrations/04_audit.sql @@ -0,0 +1,187 @@ +-- ============================================================ +-- Migration: Audit Schema — Immutable Audit Tables +-- PostgreSQL 18 +-- Run order: 04_audit.sql (last) +-- Depends on: 01_schemas.sql, 02_reference.sql, 03_public.sql +-- ============================================================ +-- All tables in this file belong to the audit schema. +-- Separating audit tables into their own schema: +-- 1) Enables a dedicated DB role with INSERT-only access on +-- audit.* and no UPDATE/DELETE, enforcing append-only +-- semantics at the database permission layer. +-- 2) Reduces row lock contention between the high-throughput +-- public business tables and the audit write path. +-- 3) Enables schema-level GRANT/REVOKE without touching +-- public schema objects (least-privilege by default). +-- +-- Design pattern: core audit.auditLogs row captures what action +-- was taken, from which IP, via which user agent, and when. +-- Junction tables (auditLogVendorActors, auditLogLicenses, +-- auditLogSessions) record WHO performed the action and WHICH +-- resource was affected. Separating actor and resource into +-- junction tables allows new actor types (e.g. auditLogClientActors) +-- and new resource types to be added as new tables without any +-- migration of existing audit rows. +-- +-- Cross-schema FK references use fully qualified names: +-- reference."actions", public."vendors", public."licenses", +-- public."sessions", audit."auditLogs" +-- ============================================================ + +-- ------------------------------------------------------------ +-- audit."auditLogs" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS audit."auditLogs" ( + "id" UUID PRIMARY KEY DEFAULT uuidv7(), + "actionCode" TEXT NOT NULL REFERENCES reference."actions"("code") ON DELETE RESTRICT, + "ipAddress" INET, + "userAgent" TEXT, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE audit."auditLogs" IS 'Core audit log table. Immutable append-only record of every auditable system event. Captures security and compliance context. Actor and resource recorded in separate junction tables.'; +COMMENT ON COLUMN audit."auditLogs"."id" IS 'Surrogate primary key (uuidv7, time-ordered). Referenced by all audit junction tables.'; +COMMENT ON COLUMN audit."auditLogs"."actionCode" IS 'Resource-agnostic action verb (FK to reference.actions). Resource type and ID captured in junction table (auditLogLicenses, auditLogSessions, etc.).'; +COMMENT ON COLUMN audit."auditLogs"."ipAddress" IS 'Client IP address (INET type). Supports efficient IP range queries and subnet filtering.'; +COMMENT ON COLUMN audit."auditLogs"."userAgent" IS 'HTTP User-Agent header. Used to identify SDK versions, detect automated scripts, flag anomalies.'; +COMMENT ON COLUMN audit."auditLogs"."createdAt" IS 'Timestamp when entry recorded. Always UTC. Immutable after insertion.'; + +-- ------------------------------------------------------------ +-- audit."auditLogVendorActors" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS audit."auditLogVendorActors" ( + "auditLogId" UUID NOT NULL PRIMARY KEY REFERENCES audit."auditLogs"("id") ON DELETE RESTRICT, + "vendorId" UUID NOT NULL REFERENCES public."vendors"("id") ON DELETE RESTRICT +); + +COMMENT ON TABLE audit."auditLogVendorActors" IS 'Junction table linking audit entry to vendor actor. Enforces at most one vendor actor per entry (PK). Pattern allows adding auditLogClientActors in v1.0 without migrating audit.auditLogs.'; +COMMENT ON COLUMN audit."auditLogVendorActors"."auditLogId" IS 'FK to audit log entry. Also the PK of this table.'; +COMMENT ON COLUMN audit."auditLogVendorActors"."vendorId" IS 'Vendor who performed the action (FK). RESTRICT prevents vendor deletion while audit trail exists.'; + +CREATE INDEX IF NOT EXISTS "auditLogVendorActors_vendorId_idx" ON audit."auditLogVendorActors" ("vendorId"); + +-- ------------------------------------------------------------ +-- audit."auditLogLicenses" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS audit."auditLogLicenses" ( + "auditLogId" UUID NOT NULL PRIMARY KEY REFERENCES audit."auditLogs"("id") ON DELETE CASCADE, + "licenseId" UUID NOT NULL REFERENCES public."licenses"("id") ON DELETE RESTRICT, + "changes" JSONB +); + +COMMENT ON TABLE audit."auditLogLicenses" IS 'Junction table identifying license as affected resource. Stores mutation context in changes column.'; +COMMENT ON COLUMN audit."auditLogLicenses"."auditLogId" IS 'FK to audit log entry. Also the PK of this table.'; +COMMENT ON COLUMN audit."auditLogLicenses"."licenseId" IS 'License affected by action (FK). RESTRICT prevents deletion while audit trail exists.'; +COMMENT ON COLUMN audit."auditLogLicenses"."changes" IS 'Optional JSONB diff of license mutations (e.g. {"licenseStatusCode": {"from": "ACTIVE", "to": "REVOKED"}}). NULL for read-only actions.'; + +CREATE INDEX IF NOT EXISTS "audit_auditLogLicenses_licenseId_idx" ON audit."auditLogLicenses" ("licenseId"); + +-- ------------------------------------------------------------ +-- audit."auditLogSessions" +-- ------------------------------------------------------------ + +CREATE TABLE IF NOT EXISTS audit."auditLogSessions" ( + "auditLogId" UUID NOT NULL PRIMARY KEY REFERENCES audit."auditLogs"("id") ON DELETE CASCADE, + "sessionId" UUID NOT NULL REFERENCES public."sessions"("id") ON DELETE RESTRICT, + "changes" JSONB +); + +COMMENT ON TABLE audit."auditLogSessions" IS 'Junction table identifying session as affected resource. Stores mutation context in changes column.'; +COMMENT ON COLUMN audit."auditLogSessions"."auditLogId" IS 'FK to audit log entry. Also the PK of this table.'; +COMMENT ON COLUMN audit."auditLogSessions"."sessionId" IS 'Session affected by action (FK). RESTRICT prevents deletion while audit trail exists.'; +COMMENT ON COLUMN audit."auditLogSessions"."changes" IS 'Optional JSONB diff of session mutations (e.g. {"sessionStatusCode": {"from": "ACTIVE", "to": "REVOKED"}}). NULL for read-only actions.'; + +CREATE INDEX IF NOT EXISTS "audit_auditLogSessions_sessionId_idx" ON audit."auditLogSessions" ("sessionId"); + +-- ============================================================ +-- Audit Schema Immutability Guards +-- ============================================================ +-- Enforce append-only semantics via database-layer controls: +-- 1) Trigger function blocks UPDATE/DELETE on all audit tables. +-- 2) Event trigger automatically attaches immutability guard to future tables. +-- 3) REVOKE UPDATE/DELETE from PUBLIC; GRANT INSERT to audit_writer role. +-- ============================================================ + +-- Trigger function: prevents UPDATE and DELETE operations on audit schema tables. +CREATE OR REPLACE FUNCTION audit.prevent_audit_update_delete() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'Audit tables are immutable. INSERT only. UPDATE and DELETE are forbidden on %.%', + TG_TABLE_SCHEMA, TG_TABLE_NAME; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO audit; + +COMMENT ON FUNCTION audit.prevent_audit_update_delete() IS 'Trigger function that blocks UPDATE/DELETE on audit schema tables. Enforces append-only semantics at database layer.'; + +-- Attach immutability trigger to existing audit tables. +CREATE TRIGGER prevent_audit_update_delete_tr +BEFORE UPDATE OR DELETE ON audit."auditLogs" +FOR EACH ROW EXECUTE FUNCTION audit.prevent_audit_update_delete(); + +CREATE TRIGGER prevent_audit_update_delete_tr +BEFORE UPDATE OR DELETE ON audit."auditLogVendorActors" +FOR EACH ROW EXECUTE FUNCTION audit.prevent_audit_update_delete(); + +CREATE TRIGGER prevent_audit_update_delete_tr +BEFORE UPDATE OR DELETE ON audit."auditLogLicenses" +FOR EACH ROW EXECUTE FUNCTION audit.prevent_audit_update_delete(); + +CREATE TRIGGER prevent_audit_update_delete_tr +BEFORE UPDATE OR DELETE ON audit."auditLogSessions" +FOR EACH ROW EXECUTE FUNCTION audit.prevent_audit_update_delete(); + +-- Event trigger: automatically attach immutability guard to any future tables created in audit schema. +CREATE OR REPLACE FUNCTION audit.attach_immutability_trigger() +RETURNS EVENT_TRIGGER AS $$ +DECLARE + v_obj RECORD; +BEGIN + FOR v_obj IN + SELECT * FROM pg_event_trigger_ddl_commands() + WHERE object_type = 'table' AND schema_name = 'audit' + LOOP + EXECUTE format( + 'CREATE TRIGGER prevent_audit_update_delete_tr BEFORE UPDATE OR DELETE ON %s FOR EACH ROW EXECUTE FUNCTION audit.prevent_audit_update_delete()', + (v_obj.objid::regclass) + ); + END LOOP; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO audit; + +COMMENT ON FUNCTION audit.attach_immutability_trigger() IS 'Event trigger function that automatically attaches immutability guard to any future tables created in audit schema.'; + +-- Register event trigger to fire on CREATE TABLE in audit schema. +CREATE EVENT TRIGGER audit_immutability_on_create_table ON ddl_command_end +WHEN TAG IN ('CREATE TABLE') +EXECUTE FUNCTION audit.attach_immutability_trigger(); + +COMMENT ON EVENT TRIGGER audit_immutability_on_create_table IS 'Automatically attaches immutability trigger to new tables in audit schema.'; + +-- ============================================================ +-- Audit Schema Permissions +-- ============================================================ +-- Revoke dangerous permissions from PUBLIC; grant only INSERT to audit_writer role. +-- Applications must authenticate as audit_writer to insert audit records. +-- ============================================================ + +REVOKE UPDATE, DELETE ON ALL TABLES IN SCHEMA audit FROM PUBLIC; +REVOKE UPDATE, DELETE ON ALL TABLES IN SCHEMA audit FROM postgres; + +-- Create audit_writer role if it does not exist (for fresh deployments). +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'audit_writer') THEN + CREATE ROLE audit_writer NOINHERIT; + COMMENT ON ROLE audit_writer IS 'Dedicated role for audit table writes. Has INSERT-only access to audit schema.'; + END IF; +END $$; + +-- Grant schema usage and INSERT permission to audit_writer. +GRANT USAGE ON SCHEMA audit TO audit_writer; +GRANT INSERT ON ALL TABLES IN SCHEMA audit TO audit_writer; + +-- Ensure future tables in audit schema inherit audit_writer permissions. +ALTER DEFAULT PRIVILEGES IN SCHEMA audit GRANT INSERT ON TABLES TO audit_writer; \ No newline at end of file diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..6f13b5f --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,195 @@ +# Database Migrations + +This directory contains the PostgreSQL 18 migration scripts for the LaaS (License as a Service) platform. + +## Migration Files + +Scripts are executed in alphabetical order by the Docker `postgres` image during container initialization. + +1. **`01_schemas.sql`**: Creates the `reference`, `public`, and `audit` schemas. +2. **`02_reference.sql`**: Seeds lookup tables (statuses, error codes, actions). +3. **`03_public.sql`**: Defines core business tables, partitions, and indexes. +4. **`04_audit.sql`**: Sets up immutable audit trail tables with cross-schema references. + +## Downgrade Scripts + +Downgrade scripts are located in the `down/` subdirectory and must be executed in **reverse order** of the migrations to safely remove all schema objects while respecting foreign key dependencies: + +1. **`04_audit_down.sql`**: Removes audit tables and indexes (first). +2. **`03_public_down.sql`**: Removes business tables, partitions, and indexes (second). +3. **`02_reference_down.sql`**: Removes reference lookup tables (third). +4. **`01_schemas_down.sql`**: Removes schemas (last). + +To downgrade the entire database, execute: + +```bash +# Note: These must be run in the exact order shown to respect FK constraints +docker compose exec db psql -U postgres -d app -f /docker-entrypoint-initdb.d/down/04_audit_down.sql +docker compose exec db psql -U postgres -d app -f /docker-entrypoint-initdb.d/down/03_public_down.sql +docker compose exec db psql -U postgres -d app -f /docker-entrypoint-initdb.d/down/02_reference_down.sql +docker compose exec db psql -U postgres -d app -f /docker-entrypoint-initdb.d/down/01_schemas_down.sql +``` + +--- + +## Entity Relationship Diagram (ERD) + +```mermaid +erDiagram + %% REFERENCE SCHEMA + reference_licenseStatuses { + text code + text description + } + reference_sessionStatuses { + text code + text description + } + reference_heartbeatRespStatuses { + text code + text description + } + reference_errorCodes { + text code + text description + } + reference_actions { + text code + text description + } + + %% PUBLIC SCHEMA + public_vendors { + uuid id + text email + text passwordHash + timestamptz createdAt + timestamptz updatedAt + timestamptz deletedAt + } + public_licenses { + uuid id + uuid vendorId + uuid clientId + text licenseStatusCode + timestamptz expiresAt + int maxGraceSecs + jsonb metadata + timestamptz createdAt + timestamptz deletedAt + } + public_nodeLockedLicenseData { + uuid licenseId + text licenseKey + text deviceFingerprintHash + int maxSessions + } + public_sessions { + uuid id + uuid licenseId + text sessionStatusCode + bytea sessionTokenHash + text deviceFingerprintHash + timestamptz createdAt + jsonb metadata + } + public_heartbeats { + uuid id + uuid sessionId + text heartbeatRespStatusCode + text errorCode + timestamptz heartbeatAt + } + + + %% AUDIT SCHEMA + audit_auditLogs { + uuid id + text actionCode + inet ipAddress + text userAgent + timestamptz createdAt + } + audit_auditLogVendorActors { + uuid auditLogId + uuid vendorId + } + audit_auditLogLicenses { + uuid auditLogId + uuid licenseId + jsonb changes + } + audit_auditLogSessions { + uuid auditLogId + uuid sessionId + jsonb changes + } + + %% RELATIONSHIPS + public_vendors ||--o{ public_licenses : owns + public_licenses ||--|| public_nodeLockedLicenseData : extends + public_licenses ||--o{ public_sessions : has + public_sessions ||--o{ public_heartbeats : emits + + reference_licenseStatuses ||--o{ public_licenses : defines_status + reference_sessionStatuses ||--o{ public_sessions : defines_status + reference_heartbeatRespStatuses ||--o{ public_heartbeats : defines_result + reference_errorCodes ||--o{ public_heartbeats : defines_error + + reference_actions ||--o{ audit_auditLogs : defines_type + audit_auditLogs ||--o| audit_auditLogVendorActors : acts_as + audit_auditLogs ||--o| audit_auditLogLicenses : affects + audit_auditLogs ||--o| audit_auditLogSessions : affects + + public_vendors ||--o{ audit_auditLogVendorActors : performs + public_licenses ||--o{ audit_auditLogLicenses : target + public_sessions ||--o{ audit_auditLogSessions : target +``` + +--- + +## Post-Migration Verification + +After the container starts, you can verify the deployment using the following commands: + +### 1. Verify Schemas and Tables + +```bash +# Connect to the database (assuming 'app' db name and 'postgres' user) +docker compose exec db psql -U postgres -d app -c "\dn" +docker compose exec db psql -U postgres -d app -c "SELECT schemaname, tablename FROM pg_tables WHERE schemaname IN ('reference','public','audit') ORDER BY schemaname, tablename;" +``` + +### 2. Check Seed Data Counts + +```bash +docker compose exec db psql -U postgres -d app -c " +SELECT 'licenseStatuses: ' || COUNT(*) FROM reference.\"licenseStatuses\" +UNION ALL SELECT 'sessionStatuses: ' || COUNT(*) FROM reference.\"sessionStatuses\" +UNION ALL SELECT 'heartbeatRespStatuses: ' || COUNT(*) FROM reference.\"heartbeatRespStatuses\" +UNION ALL SELECT 'errorCodes: ' || COUNT(*) FROM reference.\"errorCodes\" +UNION ALL SELECT 'actions: ' || COUNT(*) FROM reference.\"actions\";" +``` + +### 3. Verify Heartbeat Partitions + +```bash +docker compose exec db psql -U postgres -d app -c "SELECT tablename FROM pg_tables WHERE tablename LIKE 'heartbeats_%' ORDER BY tablename;" +``` + +### 4. Check UUIDv7 Defaults + +```bash +docker compose exec db psql -U postgres -d app -c "SELECT table_name, column_name, column_default FROM information_schema.columns WHERE table_schema='public' AND column_name='id' AND table_name IN ('vendors', 'licenses', 'sessions');" +``` + +### 5. Inspect Seed Data Content + +```bash +# View all registered statuses, error codes, and audit actions +docker compose exec db psql -U postgres -d app -c "SELECT * FROM reference.\"licenseStatuses\" ORDER BY code;" +docker compose exec db psql -U postgres -d app -c "SELECT * FROM reference.\"sessionStatuses\" ORDER BY code;" +docker compose exec db psql -U postgres -d app -c "SELECT * FROM reference.\"heartbeatRespStatuses\" ORDER BY code;" +docker compose exec db psql -U postgres -d app -c "SELECT code, description FROM reference.\"errorCodes\" ORDER BY code;" +docker compose exec db psql -U postgres -d app -c "SELECT code, description FROM reference.\"actions\" ORDER BY code;" +``` diff --git a/migrations/down/01_schemas_down.sql b/migrations/down/01_schemas_down.sql new file mode 100644 index 0000000..8eaf8b2 --- /dev/null +++ b/migrations/down/01_schemas_down.sql @@ -0,0 +1,11 @@ +-- ============================================================ +-- Downgrade: Schema Definitions +-- PostgreSQL 18 +-- Run order: 01_schemas_down.sql (last in downgrade sequence) +-- ============================================================ +-- Drops all schemas created by the LaaS platform. +-- Run this AFTER removing all tables from all schemas. +-- ============================================================ + +DROP SCHEMA IF EXISTS audit CASCADE; +DROP SCHEMA IF EXISTS reference CASCADE; diff --git a/migrations/down/02_reference_down.sql b/migrations/down/02_reference_down.sql new file mode 100644 index 0000000..aa4f563 --- /dev/null +++ b/migrations/down/02_reference_down.sql @@ -0,0 +1,15 @@ +-- ============================================================ +-- Downgrade: Reference Schema — Remove Lookup Tables +-- PostgreSQL 18 +-- Run order: 02_reference_down.sql (third in downgrade sequence) +-- ============================================================ +-- Drops all reference/lookup tables. +-- Run this AFTER removing public and audit schema tables. +-- Run this BEFORE removing schemas. +-- ============================================================ + +DROP TABLE IF EXISTS reference."licenseStatuses" CASCADE; +DROP TABLE IF EXISTS reference."sessionStatuses" CASCADE; +DROP TABLE IF EXISTS reference."heartbeatRespStatuses" CASCADE; +DROP TABLE IF EXISTS reference."errorCodes" CASCADE; +DROP TABLE IF EXISTS reference."actions" CASCADE; diff --git a/migrations/down/03_public_down.sql b/migrations/down/03_public_down.sql new file mode 100644 index 0000000..1f1af04 --- /dev/null +++ b/migrations/down/03_public_down.sql @@ -0,0 +1,26 @@ +-- ============================================================ +-- Downgrade: Public Schema — Remove Business Tables +-- PostgreSQL 18 +-- Run order: 03_public_down.sql (second in downgrade sequence) +-- ============================================================ +-- Drops all business tables, partitions, and indexes. +-- Run this AFTER removing audit schema tables. +-- Run this BEFORE removing reference schema tables. +-- ============================================================ + +-- Drop heartbeats partitions first (before dropping parent table) +DROP TABLE IF EXISTS "heartbeats_default" CASCADE; +DROP TABLE IF EXISTS "heartbeats_2027_q1" CASCADE; +DROP TABLE IF EXISTS "heartbeats_2026_q4" CASCADE; +DROP TABLE IF EXISTS "heartbeats_2026_q3" CASCADE; +DROP TABLE IF EXISTS "heartbeats_2026_q2" CASCADE; +DROP TABLE IF EXISTS "heartbeats_2026_q1" CASCADE; + +-- Drop parent partitioned table (will cascade to any remaining child partitions) +DROP TABLE IF EXISTS "heartbeats" CASCADE; + +-- Drop remaining business tables +DROP TABLE IF EXISTS "nodeLockedLicenseData" CASCADE; +DROP TABLE IF EXISTS "sessions" CASCADE; +DROP TABLE IF EXISTS "licenses" CASCADE; +DROP TABLE IF EXISTS "vendors" CASCADE; diff --git a/migrations/down/04_audit_down.sql b/migrations/down/04_audit_down.sql new file mode 100644 index 0000000..929153e --- /dev/null +++ b/migrations/down/04_audit_down.sql @@ -0,0 +1,44 @@ +-- ============================================================ +-- Downgrade: Audit Schema — Remove Immutable Audit Tables +-- PostgreSQL 18 +-- Run order: 04_audit_down.sql (first in downgrade sequence) +-- ============================================================ +-- Drops all audit tables and related indexes. +-- Run this BEFORE removing public schema tables. +-- ============================================================ + +-- Drop event trigger first (it depends on attach_immutability_trigger). +DROP EVENT TRIGGER IF EXISTS audit_immutability_on_create_table; + +-- Drop the attach_immutability_trigger function (used by event trigger). +DROP FUNCTION IF EXISTS audit.attach_immutability_trigger(); + +-- Drop index (depends on table, so drop before table). +DROP INDEX IF EXISTS audit."auditLogVendorActors_vendorId_idx"; + +-- Drop audit tables (triggers on these tables depend on prevent_audit_update_delete, so use CASCADE). +DROP TABLE IF EXISTS audit."auditLogVendorActors" CASCADE; +DROP TABLE IF EXISTS audit."auditLogLicenses" CASCADE; +DROP TABLE IF EXISTS audit."auditLogSessions" CASCADE; +DROP TABLE IF EXISTS audit."auditLogs" CASCADE; + +-- Now drop the trigger function (no longer referenced by any triggers or tables). +DROP FUNCTION IF EXISTS audit.prevent_audit_update_delete(); + +-- Revoke audit_writer role permissions (only if the role exists). +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'audit_writer') THEN + ALTER DEFAULT PRIVILEGES IN SCHEMA audit REVOKE INSERT ON TABLES FROM audit_writer; + REVOKE INSERT ON ALL TABLES IN SCHEMA audit FROM audit_writer; + REVOKE USAGE ON SCHEMA audit FROM audit_writer; + END IF; +END $$; + +-- Drop audit_writer role if it exists. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'audit_writer') THEN + DROP ROLE IF EXISTS audit_writer; + END IF; +END $$;