From 9201b9d81f5465e5bbb1f3aa40ebac2631c8060a Mon Sep 17 00:00:00 2001 From: M Laraib Ali Date: Thu, 26 Feb 2026 00:34:59 +0500 Subject: [PATCH 1/6] Add initial database migration scripts for schemas, reference tables, and audit logs --- migrations/01_init.sql | 13 --- migrations/01_schemas.sql | 16 ++++ migrations/02_reference.sql | 123 ++++++++++++++++++++++++ migrations/03_public.sql | 186 ++++++++++++++++++++++++++++++++++++ migrations/04_audit.sql | 93 ++++++++++++++++++ migrations/README.md | 185 +++++++++++++++++++++++++++++++++++ 6 files changed, 603 insertions(+), 13 deletions(-) delete mode 100644 migrations/01_init.sql create mode 100644 migrations/01_schemas.sql create mode 100644 migrations/02_reference.sql create mode 100644 migrations/03_public.sql create mode 100644 migrations/04_audit.sql create mode 100644 migrations/README.md 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..d1021e3 --- /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; +COMMENT ON SCHEMA reference IS 'Static lookup/reference tables for enumerations and constants. Rows are inserted once at deploy time and treated as immutable by application code and FK constraints. Separated to isolate migration risk and allow tighter access control (GRANT SELECT only to app role).'; + +COMMENT ON SCHEMA public IS 'Core business tables for the LaaS licensing platform. Contains all entities with mutable lifecycle state (vendors, licenses, sessions, heartbeats, licenseVersions).'; + +CREATE SCHEMA IF NOT EXISTS audit; +COMMENT ON SCHEMA audit IS 'Immutable append-only audit tables. Separated from the public business schema to allow independent access control (audit readers should not have write access to business tables, and business writers should not be able to delete audit records). All tables in this schema are append-only; no UPDATE or DELETE operations are permitted by application roles.'; diff --git a/migrations/02_reference.sql b/migrations/02_reference.sql new file mode 100644 index 0000000..4a26142 --- /dev/null +++ b/migrations/02_reference.sql @@ -0,0 +1,123 @@ +-- ============================================================ +-- 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 reference."licenseStatuses" ( + "code" TEXT NOT NULL PRIMARY KEY, + "description" TEXT NOT NULL +); + +COMMENT ON TABLE reference."licenseStatuses" IS 'Lookup table for license lifecycle states. EXPIRED is intentionally absent: it is a derived state computed at query time from licenses.expiresAt. Storing it would risk inconsistency between the timestamp field and the status field.'; +COMMENT ON COLUMN reference."licenseStatuses"."code" IS 'Machine-readable status code. Used in application logic and stored in public.licenses.licenseStatusCode.'; +COMMENT ON COLUMN reference."licenseStatuses"."description" IS 'Human-readable explanation of this status for developer and operator reference.'; + +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.'); + +-- ------------------------------------------------------------ +-- reference."sessionStatuses" +-- ------------------------------------------------------------ + +CREATE TABLE reference."sessionStatuses" ( + "code" TEXT NOT NULL PRIMARY KEY, + "description" TEXT NOT NULL +); + +COMMENT ON TABLE reference."sessionStatuses" IS 'Lookup table for stored session lifecycle states. Derived states (grace period exceeded, license expired) are not stored here; they are computed at query time by evaluating (NOW() - sessions.lastHeartbeatAt) > licenses.maxGraceSecs and (NOW() > licenses.expiresAt).'; +COMMENT ON COLUMN reference."sessionStatuses"."code" IS 'Machine-readable status code stored in public.sessions.sessionStatusCode.'; +COMMENT ON COLUMN reference."sessionStatuses"."description" IS 'Human-readable explanation of this session state.'; + +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.'); + +-- ------------------------------------------------------------ +-- reference."heartbeatRespStatuses" +-- ------------------------------------------------------------ + +CREATE TABLE reference."heartbeatRespStatuses" ( + "code" TEXT NOT NULL PRIMARY KEY, + "description" TEXT NOT NULL +); + +COMMENT ON TABLE reference."heartbeatRespStatuses" IS 'Lookup table for the response codes the server communicates to the SDK after evaluating a heartbeat. The server inspects the DB state and selects the appropriate code; the SDK acts on it. REVOKED and EXPIRED are valid server-to-SDK signals: the server discovers the state by querying the license table and informs the SDK. REFRESH signals a legitimate vendor-initiated configuration change, not an attack — TAMPERED would be semantically incorrect. CONTINUE is SDK behavioral vocabulary directing the application to continue execution; it is not HTTP transport vocabulary, so OK would be a category error.'; +COMMENT ON COLUMN reference."heartbeatRespStatuses"."code" IS 'Machine-readable response code stored in audit.heartbeats.heartbeatRespStatusCode and returned in the heartbeat API response.'; +COMMENT ON COLUMN reference."heartbeatRespStatuses"."description" IS 'Human-readable description of the response code and the 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.'); + +-- ------------------------------------------------------------ +-- reference."errorCodes" +-- ------------------------------------------------------------ + +CREATE TABLE reference."errorCodes" ( + "code" TEXT NOT NULL PRIMARY KEY, + "description" TEXT NOT NULL +); + +COMMENT ON TABLE reference."errorCodes" IS 'Canonical lookup table for all standardised business error codes used across the API contract, SDK error handling, and audit trail. HTTP status code mapping is handled exclusively in application code to keep transport and business logic decoupled.'; +COMMENT ON COLUMN reference."errorCodes"."code" IS 'Machine-readable error code constant referenced by API responses and SDK error handling logic.'; +COMMENT ON COLUMN reference."errorCodes"."description" IS 'Human-readable explanation of the error condition for developer and operator reference.'; + +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.'); + +-- ------------------------------------------------------------ +-- reference."actions" +-- ------------------------------------------------------------ + +CREATE TABLE reference."actions" ( + "code" TEXT NOT NULL PRIMARY KEY, + "description" TEXT NOT NULL +); + +COMMENT ON TABLE reference."actions" IS 'Lookup table for all auditable action verbs. Codes are intentionally resource-agnostic: the affected resource is captured in the per-type audit junction tables (audit.auditLogLicenses, audit.auditLogSessions, etc.). Encoding the resource in the action code (e.g. LICENSE_CREATED) would duplicate information already held in the junction tables and tightly couple this table to the business schema.'; +COMMENT ON COLUMN reference."actions"."code" IS 'Machine-readable action verb stored in audit.auditLogs.actionCode.'; +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 audit.heartbeats for the audit trail.'), + ('DELETED', 'A resource was soft-deleted.'); diff --git a/migrations/03_public.sql b/migrations/03_public.sql new file mode 100644 index 0000000..47d3d8e --- /dev/null +++ b/migrations/03_public.sql @@ -0,0 +1,186 @@ +-- ============================================================ +-- 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 "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 use the platform to issue, track, and enforce licenses for their products. Root multi-tenant boundary: all RLS policies are anchored to vendors.id.'; +COMMENT ON COLUMN "vendors"."id" IS 'Surrogate primary key generated by uuidv7(). Time-ordered for B-tree locality. Used as the tenant identifier in all RLS policies.'; +COMMENT ON COLUMN "vendors"."email" IS 'Vendor login email address; globally unique across all vendors.'; +COMMENT ON COLUMN "vendors"."passwordHash" IS 'bcrypt hash of the vendor password (minimum 12 rounds). The raw password is never persisted.'; +COMMENT ON COLUMN "vendors"."createdAt" IS 'Timestamp when the vendor account was first created.'; +COMMENT ON COLUMN "vendors"."updatedAt" IS 'Timestamp of the most recent update to any vendor field; must be set by the application on every write.'; +COMMENT ON COLUMN "vendors"."deletedAt" IS 'Soft-delete marker. Non-NULL means the vendor account is deactivated. All downstream data is retained for audit purposes.'; + +-- ------------------------------------------------------------ +-- public."licenses" +-- ------------------------------------------------------------ + +CREATE TABLE "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 is determined by the presence of a corresponding extension table row (e.g. nodeLockedLicenseData) — no type column is stored here.'; +COMMENT ON COLUMN "licenses"."id" IS 'Surrogate primary key generated by uuidv7().'; +COMMENT ON COLUMN "licenses"."vendorId" IS 'Owning vendor. Enforces multi-tenancy: identifies which vendor issued this license. RLS policies filter on this column.'; +COMMENT ON COLUMN "licenses"."clientId" IS 'Nullable forward-compatibility placeholder for the v1.0 clients table. Allows logical grouping of licenses by customer UUID without a FK constraint in MVP. No referential integrity enforced until the clients table exists.'; +COMMENT ON COLUMN "licenses"."licenseStatusCode" IS 'Current stored lifecycle status of the license. FK to reference.licenseStatuses. EXPIRED is not a valid stored status; expiry is derived at query time from expiresAt.'; +COMMENT ON COLUMN "licenses"."expiresAt" IS 'Optional expiry timestamp. NULL means perpetual. Always evaluated at query time via (NOW() > expiresAt); never mutates licenseStatusCode automatically.'; +COMMENT ON COLUMN "licenses"."maxGraceSecs" IS 'Seconds permitted between consecutive successful heartbeats before a session is classified as a zombie. License-level policy shared by all sessions under this license. No column DEFAULT: the application must always supply an explicit value to prevent silent misconfiguration.'; +COMMENT ON COLUMN "licenses"."metadata" IS 'Arbitrary vendor-defined key-value metadata (e.g. product tier, feature flags). Nullable. The platform does not interpret or validate this field.'; +COMMENT ON COLUMN "licenses"."createdAt" IS 'Timestamp when the license was created.'; +COMMENT ON COLUMN "licenses"."deletedAt" IS 'Soft-delete marker. Non-NULL means the license has been removed from active use but is retained for audit history.'; + +-- ------------------------------------------------------------ +-- public."nodeLockedLicenseData" +-- ------------------------------------------------------------ + +CREATE TABLE "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 the node-locked license subtype. The presence of a row for a given licenseId indicates that license is node-locked. Future subtypes each receive their own extension table; no type discriminator column is required on licenses. Storing licenseKey here rather than on licenses keeps the activation mechanism specific to this subtype.'; +COMMENT ON COLUMN "nodeLockedLicenseData"."licenseId" IS 'FK to and PK of the parent license row. Enforces a strict 1:1 relationship. ON DELETE RESTRICT prevents the parent license from being deleted while this extension row exists.'; +COMMENT ON COLUMN "nodeLockedLicenseData"."licenseKey" IS 'Cryptographically random, human-legible activation key distributed to the end customer (e.g. XXXX-XXXX-XXXX-XXXX). Globally unique across all licenses.'; +COMMENT ON COLUMN "nodeLockedLicenseData"."deviceFingerprintHash" IS 'SHA-256 hash of the combined device hardware identifiers (BIOS UUID + CPU serial + disk serial) computed server-side. Raw identifiers are never persisted. NULL until the first activation; set on first successful activation and verified on every subsequent heartbeat.'; +COMMENT ON COLUMN "nodeLockedLicenseData"."maxSessions" IS 'Maximum number of concurrent ACTIVE sessions permitted on this licensed device. Default 1. Prevents a single node-locked license from running as multiple parallel processes (e.g. in a container cluster).'; + +-- ------------------------------------------------------------ +-- public."sessions" +-- ------------------------------------------------------------ + +CREATE TABLE "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, + "sessionToken" TEXT NOT NULL UNIQUE, + "deviceFingerprintHash" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "lastHeartbeatAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "metadata" JSONB +); + +COMMENT ON TABLE "sessions" IS 'Active and historical sessions created via license activation. Hot-read table for heartbeat validation. On a successful CONTINUE heartbeat, only lastHeartbeatAt is updated in-place; no row is appended. Non-CONTINUE events (errors, revocations, expirations) are appended to the heartbeats table.'; +COMMENT ON COLUMN "sessions"."id" IS 'Surrogate primary key generated by uuidv7().'; +COMMENT ON COLUMN "sessions"."licenseId" IS 'License this session was activated against. Joined on every heartbeat to retrieve maxGraceSecs, licenseStatusCode, and expiresAt.'; +COMMENT ON COLUMN "sessions"."sessionStatusCode" IS 'Stored lifecycle state of the session (ACTIVE, REVOKED, ZOMBIE, CLEANUP). Derived states (grace period exceeded, license expired) are computed at query time.'; +COMMENT ON COLUMN "sessions"."sessionToken" IS 'Cryptographically random opaque token issued to the SDK on activation. Used to identify the session on all subsequent heartbeat requests. Must be treated as a secret: it is the sole bearer credential for heartbeat calls.'; +COMMENT ON COLUMN "sessions"."deviceFingerprintHash" IS 'Snapshot of the device fingerprint hash captured at session creation. Intentionally denormalized from nodeLockedLicenseData to eliminate a JOIN on the hot-path heartbeat validation query.'; +COMMENT ON COLUMN "sessions"."createdAt" IS 'Timestamp when the session was created, equivalent to the first activation timestamp for this device+license pair.'; +COMMENT ON COLUMN "sessions"."lastHeartbeatAt" IS 'Timestamp of the most recent successful (CONTINUE) heartbeat. Updated in-place on every successful heartbeat. Grace-period check: (NOW() - lastHeartbeatAt) > licenses.maxGraceSecs.'; +COMMENT ON COLUMN "sessions"."metadata" IS 'Arbitrary session metadata captured at activation time (e.g. SDK version, OS, hostname). Nullable. Not mutated after session creation.'; + +-- ------------------------------------------------------------ +-- public."heartbeats" +-- ------------------------------------------------------------ +-- Append-only time-series log for non-CONTINUE heartbeat events. +-- Successful CONTINUE heartbeats update sessions.lastHeartbeatAt +-- only and are NOT appended here to keep write amplification low. +-- 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 "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") +) PARTITION BY RANGE ("heartbeatAt"); + +COMMENT ON TABLE "heartbeats" IS 'Append-only time-series log of non-CONTINUE heartbeat events (errors, revocations, expirations, refresh triggers). CONTINUE heartbeats are not appended here; they only update sessions.lastHeartbeatAt. Range-partitioned by heartbeatAt for efficient time-range archival and partition pruning.'; +COMMENT ON COLUMN "heartbeats"."id" IS 'Unique identifier for the heartbeat record, generated by uuidv7(). Forms a composite PK with heartbeatAt: PostgreSQL requires all partition key columns to be included in the primary key of a partitioned table.'; +COMMENT ON COLUMN "heartbeats"."sessionId" IS 'Session that emitted this heartbeat event. ON DELETE CASCADE removes all heartbeat rows when the parent session is hard-deleted during retention cleanup.'; +COMMENT ON COLUMN "heartbeats"."heartbeatRespStatusCode" IS 'Response code returned to the SDK for this heartbeat event. Only non-CONTINUE responses are recorded here.'; +COMMENT ON COLUMN "heartbeats"."errorCode" IS 'Error code if the heartbeat resulted in an ERROR response. NULL for non-error anomaly events (REFRESH, REVOKED, EXPIRED).'; +COMMENT ON COLUMN "heartbeats"."heartbeatAt" IS 'Timestamp when the heartbeat event was received. Partition key for range-based archival. BRIN index supports efficient time-range scans with minimal write overhead.'; + +-- Partitions: pre-create 5 quarters covering 2026 and 2027 Q1. +-- Add future partitions before the current quarter boundary. + +CREATE TABLE "heartbeats_2026_q1" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-01-01') TO ('2026-04-01'); +CREATE TABLE "heartbeats_2026_q2" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-04-01') TO ('2026-07-01'); +CREATE TABLE "heartbeats_2026_q3" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-07-01') TO ('2026-10-01'); +CREATE TABLE "heartbeats_2026_q4" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-10-01') TO ('2027-01-01'); +CREATE TABLE "heartbeats_2027_q1" PARTITION OF "heartbeats" FOR VALUES FROM ('2027-01-01') TO ('2027-04-01'); + +-- 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 "heartbeats_sessionId_idx" ON "heartbeats" ("sessionId"); +CREATE INDEX "heartbeats_heartbeatAt_idx" ON "heartbeats" USING BRIN ("heartbeatAt"); + +-- ------------------------------------------------------------ +-- public."licenseVersions" +-- ------------------------------------------------------------ + +CREATE TABLE "licenseVersions" ( + "id" UUID PRIMARY KEY DEFAULT uuidv7(), + "licenseId" UUID NOT NULL REFERENCES "licenses"("id") ON DELETE RESTRICT, + "vendorId" UUID NOT NULL REFERENCES "vendors"("id") ON DELETE RESTRICT, + "changeType" TEXT NOT NULL, + "previousState" JSONB NOT NULL, + "newState" JSONB NOT NULL, + "changedBy" TEXT NOT NULL, + "changeReason" TEXT, + "changedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE "licenseVersions" IS 'Immutable append-only log of all license state changes. Distinct from audit.auditLogs: this table tracks the evolution of the license business object for point-in-time reconstruction; auditLogs tracks who performed an action and from which IP. Both records are written transactionally on any mutating license operation.'; +COMMENT ON COLUMN "licenseVersions"."id" IS 'Surrogate primary key generated by uuidv7().'; +COMMENT ON COLUMN "licenseVersions"."licenseId" IS 'License whose state was changed.'; +COMMENT ON COLUMN "licenseVersions"."vendorId" IS 'Denormalized vendor ID for efficient RLS filtering and vendor-scoped historical queries without a JOIN through licenses.'; +COMMENT ON COLUMN "licenseVersions"."changeType" IS 'Categorical label describing the nature of the change (e.g. LICENSE_CREATED, LICENSE_EXTENDED, DEVICE_UPDATED, LICENSE_REVOKED, LICENSE_REACTIVATED).'; +COMMENT ON COLUMN "licenseVersions"."previousState" IS 'Full JSONB snapshot of the license row immediately before this change. Enables complete rollback diffing and point-in-time reconstruction.'; +COMMENT ON COLUMN "licenseVersions"."newState" IS 'Full JSONB snapshot of the license row immediately after this change.'; +COMMENT ON COLUMN "licenseVersions"."changedBy" IS 'Identifier of the actor who made the change: vendor UUID for manual changes, or the string "system" for automated transitions (e.g. expiry jobs).'; +COMMENT ON COLUMN "licenseVersions"."changeReason" IS 'Optional human-readable justification for the change (e.g. "Device stolen, reactivation requested by customer"). Useful for customer support and dispute resolution.'; +COMMENT ON COLUMN "licenseVersions"."changedAt" IS 'Timestamp when this version entry was recorded. Immutable after insertion.'; + +CREATE INDEX "licenseVersions_licenseId_idx" ON "licenseVersions" ("licenseId"); +CREATE INDEX "licenseVersions_vendorId_changedAt_idx" ON "licenseVersions" ("vendorId", "changedAt" DESC); diff --git a/migrations/04_audit.sql b/migrations/04_audit.sql new file mode 100644 index 0000000..2d66edb --- /dev/null +++ b/migrations/04_audit.sql @@ -0,0 +1,93 @@ +-- ============================================================ +-- 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 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 the security and compliance context: what action was performed, from which IP, via which user agent, and when. The actor (who) and the affected resource (what subject) are captured in separate per-type junction tables, allowing new actor and resource types to be added without migrating this table.'; +COMMENT ON COLUMN audit."auditLogs"."id" IS 'Surrogate primary key generated by uuidv7(). Time-ordered for chronological B-tree locality. Referenced as a FK in all audit junction tables.'; +COMMENT ON COLUMN audit."auditLogs"."actionCode" IS 'Resource-agnostic action verb performed. FK to reference.actions. The resource type and ID are captured in the corresponding junction table (auditLogLicenses, auditLogSessions, etc.).'; +COMMENT ON COLUMN audit."auditLogs"."ipAddress" IS 'Client IP address at time of the action. Stored as INET type to support efficient IP range queries and subnet filtering for anomaly detection.'; +COMMENT ON COLUMN audit."auditLogs"."userAgent" IS 'HTTP User-Agent header from the request. Used to identify SDK versions, detect automated scripts, and flag anomalous client behaviour during security investigations.'; +COMMENT ON COLUMN audit."auditLogs"."createdAt" IS 'Timestamp when this audit entry was recorded. Always UTC. Immutable after insertion.'; + +-- ------------------------------------------------------------ +-- audit."auditLogVendorActors" +-- ------------------------------------------------------------ + +CREATE TABLE audit."auditLogVendorActors" ( + "auditLogId" UUID NOT NULL PRIMARY KEY REFERENCES audit."auditLogs"("id") ON DELETE CASCADE, + "vendorId" UUID NOT NULL REFERENCES public."vendors"("id") ON DELETE RESTRICT +); + +COMMENT ON TABLE audit."auditLogVendorActors" IS 'Junction table associating an audit log entry with the vendor who performed the action. At most one vendor actor per audit entry (PK enforces this). In v1.0, auditLogClientActors will be added as a separate table using the same pattern — no migration of existing audit rows needed.'; +COMMENT ON COLUMN audit."auditLogVendorActors"."auditLogId" IS 'FK to the core audit log entry. Also the PK of this table, enforcing at most one vendor actor per audit entry.'; +COMMENT ON COLUMN audit."auditLogVendorActors"."vendorId" IS 'Vendor who performed the audited action. ON DELETE RESTRICT prevents deletion of a vendor while audit trail entries remain.'; + +CREATE INDEX "auditLogVendorActors_vendorId_idx" ON audit."auditLogVendorActors" ("vendorId"); + +-- ------------------------------------------------------------ +-- audit."auditLogLicenses" +-- ------------------------------------------------------------ + +CREATE TABLE 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 a license as the resource affected by an audited action. Stores license-specific mutation context in the optional changes column.'; +COMMENT ON COLUMN audit."auditLogLicenses"."auditLogId" IS 'FK to the core audit log entry. Also the PK of this table, enforcing at most one license resource per audit entry.'; +COMMENT ON COLUMN audit."auditLogLicenses"."licenseId" IS 'The license that was the subject of the audited action. ON DELETE RESTRICT prevents deletion of a license while audit trail entries remain.'; +COMMENT ON COLUMN audit."auditLogLicenses"."changes" IS 'Nullable JSONB diff capturing license-specific mutation context (e.g. {"licenseStatusCode": {"from": "ACTIVE", "to": "REVOKED"}}). NULL for read or query actions where no mutation occurred.'; + +-- ------------------------------------------------------------ +-- audit."auditLogSessions" +-- ------------------------------------------------------------ + +CREATE TABLE 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 a session as the resource affected by an audited action. Stores session-specific mutation context in the optional changes column.'; +COMMENT ON COLUMN audit."auditLogSessions"."auditLogId" IS 'FK to the core audit log entry. Also the PK of this table, enforcing at most one session resource per audit entry.'; +COMMENT ON COLUMN audit."auditLogSessions"."sessionId" IS 'The session that was the subject of the audited action. ON DELETE RESTRICT prevents deletion of a session while audit trail entries remain.'; +COMMENT ON COLUMN audit."auditLogSessions"."changes" IS 'Nullable JSONB diff capturing session-specific mutation context (e.g. {"sessionStatusCode": {"from": "ACTIVE", "to": "REVOKED"}}). NULL for read or query actions where no mutation occurred.'; diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..b4e3afd --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,185 @@ +# 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. + +--- + +## 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 + text sessionToken + text deviceFingerprintHash + timestamptz createdAt + timestamptz lastHeartbeatAt + jsonb metadata + } + public_heartbeats { + uuid id + uuid sessionId + text heartbeatRespStatusCode + text errorCode + timestamptz heartbeatAt + } + public_licenseVersions { + uuid id + uuid licenseId + uuid vendorId + text changeType + jsonb previousState + jsonb newState + text changedBy + text changeReason + timestamptz changedAt + } + + %% 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 + public_licenses ||--o{ public_licenseVersions : versions + public_vendors ||--o{ public_licenseVersions : modifies + + 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 ||--|| 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;" +``` + From 1679b9533e062e849496370f65aac19afc641901 Mon Sep 17 00:00:00 2001 From: M Laraib Ali Date: Thu, 26 Feb 2026 16:28:24 +0500 Subject: [PATCH 2/6] down migrations and idempotency in the migration files --- migrations/01_schemas.sql | 7 +- migrations/02_reference.sql | 55 ++++++----- migrations/03_public.sql | 133 ++++++++++---------------- migrations/04_audit.sql | 44 ++++----- migrations/README.md | 48 ++++++---- migrations/down/01_schemas_down.sql | 12 +++ migrations/down/02_reference_down.sql | 19 ++++ migrations/down/03_public_down.sql | 28 ++++++ migrations/down/04_audit_down.sql | 17 ++++ 9 files changed, 212 insertions(+), 151 deletions(-) create mode 100644 migrations/down/01_schemas_down.sql create mode 100644 migrations/down/02_reference_down.sql create mode 100644 migrations/down/03_public_down.sql create mode 100644 migrations/down/04_audit_down.sql diff --git a/migrations/01_schemas.sql b/migrations/01_schemas.sql index d1021e3..2a761c3 100644 --- a/migrations/01_schemas.sql +++ b/migrations/01_schemas.sql @@ -8,9 +8,10 @@ -- ============================================================ CREATE SCHEMA IF NOT EXISTS reference; -COMMENT ON SCHEMA reference IS 'Static lookup/reference tables for enumerations and constants. Rows are inserted once at deploy time and treated as immutable by application code and FK constraints. Separated to isolate migration risk and allow tighter access control (GRANT SELECT only to app role).'; +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 entities with mutable lifecycle state (vendors, licenses, sessions, heartbeats, licenseVersions).'; +CREATE SCHEMA IF NOT EXISTS public; +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).'; CREATE SCHEMA IF NOT EXISTS audit; -COMMENT ON SCHEMA audit IS 'Immutable append-only audit tables. Separated from the public business schema to allow independent access control (audit readers should not have write access to business tables, and business writers should not be able to delete audit records). All tables in this schema are append-only; no UPDATE or DELETE operations are permitted by application roles.'; +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 index 4a26142..ff7ef6e 100644 --- a/migrations/02_reference.sql +++ b/migrations/02_reference.sql @@ -17,70 +17,73 @@ -- reference."licenseStatuses" -- ------------------------------------------------------------ -CREATE TABLE reference."licenseStatuses" ( +CREATE TABLE IF NOT EXISTS reference."licenseStatuses" ( "code" TEXT NOT NULL PRIMARY KEY, "description" TEXT NOT NULL ); -COMMENT ON TABLE reference."licenseStatuses" IS 'Lookup table for license lifecycle states. EXPIRED is intentionally absent: it is a derived state computed at query time from licenses.expiresAt. Storing it would risk inconsistency between the timestamp field and the status field.'; -COMMENT ON COLUMN reference."licenseStatuses"."code" IS 'Machine-readable status code. Used in application logic and stored in public.licenses.licenseStatusCode.'; -COMMENT ON COLUMN reference."licenseStatuses"."description" IS 'Human-readable explanation of this status for developer and operator reference.'; +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.'); + ('REVOKED', 'License was manually revoked by the vendor; no further activations or heartbeats are permitted.') +ON CONFLICT ("code") DO NOTHING; -- ------------------------------------------------------------ -- reference."sessionStatuses" -- ------------------------------------------------------------ -CREATE TABLE reference."sessionStatuses" ( +CREATE TABLE IF NOT EXISTS reference."sessionStatuses" ( "code" TEXT NOT NULL PRIMARY KEY, "description" TEXT NOT NULL ); -COMMENT ON TABLE reference."sessionStatuses" IS 'Lookup table for stored session lifecycle states. Derived states (grace period exceeded, license expired) are not stored here; they are computed at query time by evaluating (NOW() - sessions.lastHeartbeatAt) > licenses.maxGraceSecs and (NOW() > licenses.expiresAt).'; -COMMENT ON COLUMN reference."sessionStatuses"."code" IS 'Machine-readable status code stored in public.sessions.sessionStatusCode.'; -COMMENT ON COLUMN reference."sessionStatuses"."description" IS 'Human-readable explanation of this session state.'; +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.'); + ('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 reference."heartbeatRespStatuses" ( +CREATE TABLE IF NOT EXISTS reference."heartbeatRespStatuses" ( "code" TEXT NOT NULL PRIMARY KEY, "description" TEXT NOT NULL ); -COMMENT ON TABLE reference."heartbeatRespStatuses" IS 'Lookup table for the response codes the server communicates to the SDK after evaluating a heartbeat. The server inspects the DB state and selects the appropriate code; the SDK acts on it. REVOKED and EXPIRED are valid server-to-SDK signals: the server discovers the state by querying the license table and informs the SDK. REFRESH signals a legitimate vendor-initiated configuration change, not an attack — TAMPERED would be semantically incorrect. CONTINUE is SDK behavioral vocabulary directing the application to continue execution; it is not HTTP transport vocabulary, so OK would be a category error.'; -COMMENT ON COLUMN reference."heartbeatRespStatuses"."code" IS 'Machine-readable response code stored in audit.heartbeats.heartbeatRespStatusCode and returned in the heartbeat API response.'; -COMMENT ON COLUMN reference."heartbeatRespStatuses"."description" IS 'Human-readable description of the response code and the SDK action it mandates.'; +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.'); + ('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 reference."errorCodes" ( +CREATE TABLE IF NOT EXISTS reference."errorCodes" ( "code" TEXT NOT NULL PRIMARY KEY, "description" TEXT NOT NULL ); -COMMENT ON TABLE reference."errorCodes" IS 'Canonical lookup table for all standardised business error codes used across the API contract, SDK error handling, and audit trail. HTTP status code mapping is handled exclusively in application code to keep transport and business logic decoupled.'; -COMMENT ON COLUMN reference."errorCodes"."code" IS 'Machine-readable error code constant referenced by API responses and SDK error handling logic.'; -COMMENT ON COLUMN reference."errorCodes"."description" IS 'Human-readable explanation of the error condition for developer and operator reference.'; +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.'), @@ -94,20 +97,21 @@ INSERT INTO reference."errorCodes" ("code", "description") VALUES ('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.'); + ('INTERNAL_ERROR', 'An unexpected internal server error occurred.') +ON CONFLICT ("code") DO NOTHING; -- ------------------------------------------------------------ -- reference."actions" -- ------------------------------------------------------------ -CREATE TABLE reference."actions" ( +CREATE TABLE IF NOT EXISTS reference."actions" ( "code" TEXT NOT NULL PRIMARY KEY, "description" TEXT NOT NULL ); -COMMENT ON TABLE reference."actions" IS 'Lookup table for all auditable action verbs. Codes are intentionally resource-agnostic: the affected resource is captured in the per-type audit junction tables (audit.auditLogLicenses, audit.auditLogSessions, etc.). Encoding the resource in the action code (e.g. LICENSE_CREATED) would duplicate information already held in the junction tables and tightly couple this table to the business schema.'; -COMMENT ON COLUMN reference."actions"."code" IS 'Machine-readable action verb stored in audit.auditLogs.actionCode.'; -COMMENT ON COLUMN reference."actions"."description" IS 'Human-readable description of what this action represents in the system.'; +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.'), @@ -120,4 +124,5 @@ INSERT INTO reference."actions" ("code", "description") VALUES ('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 audit.heartbeats for the audit trail.'), - ('DELETED', 'A resource was soft-deleted.'); + ('DELETED', 'A resource was soft-deleted.') +ON CONFLICT ("code") DO NOTHING; diff --git a/migrations/03_public.sql b/migrations/03_public.sql index 47d3d8e..20e3687 100644 --- a/migrations/03_public.sql +++ b/migrations/03_public.sql @@ -24,7 +24,7 @@ -- public."vendors" -- ------------------------------------------------------------ -CREATE TABLE "vendors" ( +CREATE TABLE IF NOT EXISTS "vendors" ( "id" UUID PRIMARY KEY DEFAULT uuidv7(), "email" TEXT NOT NULL UNIQUE, "passwordHash" TEXT NOT NULL, @@ -33,19 +33,19 @@ CREATE TABLE "vendors" ( "deletedAt" TIMESTAMPTZ ); -COMMENT ON TABLE "vendors" IS 'Software vendors who use the platform to issue, track, and enforce licenses for their products. Root multi-tenant boundary: all RLS policies are anchored to vendors.id.'; -COMMENT ON COLUMN "vendors"."id" IS 'Surrogate primary key generated by uuidv7(). Time-ordered for B-tree locality. Used as the tenant identifier in all RLS policies.'; -COMMENT ON COLUMN "vendors"."email" IS 'Vendor login email address; globally unique across all vendors.'; -COMMENT ON COLUMN "vendors"."passwordHash" IS 'bcrypt hash of the vendor password (minimum 12 rounds). The raw password is never persisted.'; -COMMENT ON COLUMN "vendors"."createdAt" IS 'Timestamp when the vendor account was first created.'; -COMMENT ON COLUMN "vendors"."updatedAt" IS 'Timestamp of the most recent update to any vendor field; must be set by the application on every write.'; -COMMENT ON COLUMN "vendors"."deletedAt" IS 'Soft-delete marker. Non-NULL means the vendor account is deactivated. All downstream data is retained for audit purposes.'; +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 'bcrypt hash (≥12 rounds). Raw password never persisted.'; +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 "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, @@ -58,22 +58,22 @@ CREATE TABLE "licenses" ( 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 is determined by the presence of a corresponding extension table row (e.g. nodeLockedLicenseData) — no type column is stored here.'; -COMMENT ON COLUMN "licenses"."id" IS 'Surrogate primary key generated by uuidv7().'; -COMMENT ON COLUMN "licenses"."vendorId" IS 'Owning vendor. Enforces multi-tenancy: identifies which vendor issued this license. RLS policies filter on this column.'; -COMMENT ON COLUMN "licenses"."clientId" IS 'Nullable forward-compatibility placeholder for the v1.0 clients table. Allows logical grouping of licenses by customer UUID without a FK constraint in MVP. No referential integrity enforced until the clients table exists.'; -COMMENT ON COLUMN "licenses"."licenseStatusCode" IS 'Current stored lifecycle status of the license. FK to reference.licenseStatuses. EXPIRED is not a valid stored status; expiry is derived at query time from expiresAt.'; -COMMENT ON COLUMN "licenses"."expiresAt" IS 'Optional expiry timestamp. NULL means perpetual. Always evaluated at query time via (NOW() > expiresAt); never mutates licenseStatusCode automatically.'; -COMMENT ON COLUMN "licenses"."maxGraceSecs" IS 'Seconds permitted between consecutive successful heartbeats before a session is classified as a zombie. License-level policy shared by all sessions under this license. No column DEFAULT: the application must always supply an explicit value to prevent silent misconfiguration.'; -COMMENT ON COLUMN "licenses"."metadata" IS 'Arbitrary vendor-defined key-value metadata (e.g. product tier, feature flags). Nullable. The platform does not interpret or validate this field.'; -COMMENT ON COLUMN "licenses"."createdAt" IS 'Timestamp when the license was created.'; -COMMENT ON COLUMN "licenses"."deletedAt" IS 'Soft-delete marker. Non-NULL means the license has been removed from active use but is retained for audit history.'; +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 "nodeLockedLicenseData" ( +CREATE TABLE IF NOT EXISTS "nodeLockedLicenseData" ( "licenseId" UUID PRIMARY KEY REFERENCES "licenses"("id") ON DELETE RESTRICT, "licenseKey" TEXT NOT NULL UNIQUE, "deviceFingerprintHash" TEXT, @@ -81,49 +81,48 @@ CREATE TABLE "nodeLockedLicenseData" ( CONSTRAINT "nodeLocked_maxSessions_positive" CHECK ("maxSessions" > 0) ); -COMMENT ON TABLE "nodeLockedLicenseData" IS 'Extension table for the node-locked license subtype. The presence of a row for a given licenseId indicates that license is node-locked. Future subtypes each receive their own extension table; no type discriminator column is required on licenses. Storing licenseKey here rather than on licenses keeps the activation mechanism specific to this subtype.'; -COMMENT ON COLUMN "nodeLockedLicenseData"."licenseId" IS 'FK to and PK of the parent license row. Enforces a strict 1:1 relationship. ON DELETE RESTRICT prevents the parent license from being deleted while this extension row exists.'; -COMMENT ON COLUMN "nodeLockedLicenseData"."licenseKey" IS 'Cryptographically random, human-legible activation key distributed to the end customer (e.g. XXXX-XXXX-XXXX-XXXX). Globally unique across all licenses.'; -COMMENT ON COLUMN "nodeLockedLicenseData"."deviceFingerprintHash" IS 'SHA-256 hash of the combined device hardware identifiers (BIOS UUID + CPU serial + disk serial) computed server-side. Raw identifiers are never persisted. NULL until the first activation; set on first successful activation and verified on every subsequent heartbeat.'; -COMMENT ON COLUMN "nodeLockedLicenseData"."maxSessions" IS 'Maximum number of concurrent ACTIVE sessions permitted on this licensed device. Default 1. Prevents a single node-locked license from running as multiple parallel processes (e.g. in a container cluster).'; +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 "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, "sessionToken" TEXT NOT NULL UNIQUE, "deviceFingerprintHash" TEXT NOT NULL, "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), - "lastHeartbeatAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "metadata" JSONB ); -COMMENT ON TABLE "sessions" IS 'Active and historical sessions created via license activation. Hot-read table for heartbeat validation. On a successful CONTINUE heartbeat, only lastHeartbeatAt is updated in-place; no row is appended. Non-CONTINUE events (errors, revocations, expirations) are appended to the heartbeats table.'; -COMMENT ON COLUMN "sessions"."id" IS 'Surrogate primary key generated by uuidv7().'; -COMMENT ON COLUMN "sessions"."licenseId" IS 'License this session was activated against. Joined on every heartbeat to retrieve maxGraceSecs, licenseStatusCode, and expiresAt.'; -COMMENT ON COLUMN "sessions"."sessionStatusCode" IS 'Stored lifecycle state of the session (ACTIVE, REVOKED, ZOMBIE, CLEANUP). Derived states (grace period exceeded, license expired) are computed at query time.'; -COMMENT ON COLUMN "sessions"."sessionToken" IS 'Cryptographically random opaque token issued to the SDK on activation. Used to identify the session on all subsequent heartbeat requests. Must be treated as a secret: it is the sole bearer credential for heartbeat calls.'; -COMMENT ON COLUMN "sessions"."deviceFingerprintHash" IS 'Snapshot of the device fingerprint hash captured at session creation. Intentionally denormalized from nodeLockedLicenseData to eliminate a JOIN on the hot-path heartbeat validation query.'; -COMMENT ON COLUMN "sessions"."createdAt" IS 'Timestamp when the session was created, equivalent to the first activation timestamp for this device+license pair.'; -COMMENT ON COLUMN "sessions"."lastHeartbeatAt" IS 'Timestamp of the most recent successful (CONTINUE) heartbeat. Updated in-place on every successful heartbeat. Grace-period check: (NOW() - lastHeartbeatAt) > licenses.maxGraceSecs.'; -COMMENT ON COLUMN "sessions"."metadata" IS 'Arbitrary session metadata captured at activation time (e.g. SDK version, OS, hostname). Nullable. Not mutated after session creation.'; +COMMENT ON TABLE "sessions" IS 'Active and historical sessions created via license activation. Mostly immutable after creation. Grace period computed at query time from MAX(heartbeats.heartbeatAt).'; +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"."sessionToken" IS 'Cryptographically random bearer token. Sole credential for heartbeat requests. Treat as secret.'; +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 for non-CONTINUE heartbeat events. --- Successful CONTINUE heartbeats update sessions.lastHeartbeatAt --- only and are NOT appended here to keep write amplification low. +-- Successful CONTINUE heartbeats are not appended here to keep write +-- amplification low; grace period is computed by querying +-- MAX(heartbeatAt) for each session at validation time. -- 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 "heartbeats" ( +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, @@ -132,55 +131,25 @@ CREATE TABLE "heartbeats" ( PRIMARY KEY ("id", "heartbeatAt") ) PARTITION BY RANGE ("heartbeatAt"); -COMMENT ON TABLE "heartbeats" IS 'Append-only time-series log of non-CONTINUE heartbeat events (errors, revocations, expirations, refresh triggers). CONTINUE heartbeats are not appended here; they only update sessions.lastHeartbeatAt. Range-partitioned by heartbeatAt for efficient time-range archival and partition pruning.'; -COMMENT ON COLUMN "heartbeats"."id" IS 'Unique identifier for the heartbeat record, generated by uuidv7(). Forms a composite PK with heartbeatAt: PostgreSQL requires all partition key columns to be included in the primary key of a partitioned table.'; -COMMENT ON COLUMN "heartbeats"."sessionId" IS 'Session that emitted this heartbeat event. ON DELETE CASCADE removes all heartbeat rows when the parent session is hard-deleted during retention cleanup.'; -COMMENT ON COLUMN "heartbeats"."heartbeatRespStatusCode" IS 'Response code returned to the SDK for this heartbeat event. Only non-CONTINUE responses are recorded here.'; -COMMENT ON COLUMN "heartbeats"."errorCode" IS 'Error code if the heartbeat resulted in an ERROR response. NULL for non-error anomaly events (REFRESH, REVOKED, EXPIRED).'; -COMMENT ON COLUMN "heartbeats"."heartbeatAt" IS 'Timestamp when the heartbeat event was received. Partition key for range-based archival. BRIN index supports efficient time-range scans with minimal write overhead.'; +COMMENT ON TABLE "heartbeats" IS 'Append-only time-series log of non-CONTINUE heartbeat events (errors, revocations, expirations, refresh triggers). Range-partitioned by heartbeatAt for efficient archival and partition pruning.'; +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. Only non-CONTINUE responses logged here.'; +COMMENT ON COLUMN "heartbeats"."errorCode" IS 'Error code if response was ERROR. NULL for non-error events (REFRESH, REVOKED, EXPIRED).'; +COMMENT ON COLUMN "heartbeats"."heartbeatAt" IS 'Timestamp when event received. Partition key. BRIN index supports efficient time-range scans.'; -- Partitions: pre-create 5 quarters covering 2026 and 2027 Q1. -- Add future partitions before the current quarter boundary. -CREATE TABLE "heartbeats_2026_q1" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-01-01') TO ('2026-04-01'); -CREATE TABLE "heartbeats_2026_q2" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-04-01') TO ('2026-07-01'); -CREATE TABLE "heartbeats_2026_q3" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-07-01') TO ('2026-10-01'); -CREATE TABLE "heartbeats_2026_q4" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-10-01') TO ('2027-01-01'); -CREATE TABLE "heartbeats_2027_q1" PARTITION OF "heartbeats" FOR VALUES FROM ('2027-01-01') TO ('2027-04-01'); +CREATE TABLE IF NOT EXISTS "heartbeats_2026_q1" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-01-01') TO ('2026-04-01'); +CREATE TABLE IF NOT EXISTS "heartbeats_2026_q2" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-04-01') TO ('2026-07-01'); +CREATE TABLE IF NOT EXISTS "heartbeats_2026_q3" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-07-01') TO ('2026-10-01'); +CREATE TABLE IF NOT EXISTS "heartbeats_2026_q4" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-10-01') TO ('2027-01-01'); +CREATE TABLE IF NOT EXISTS "heartbeats_2027_q1" PARTITION OF "heartbeats" FOR VALUES FROM ('2027-01-01') TO ('2027-04-01'); -- 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 "heartbeats_sessionId_idx" ON "heartbeats" ("sessionId"); -CREATE INDEX "heartbeats_heartbeatAt_idx" ON "heartbeats" USING BRIN ("heartbeatAt"); - --- ------------------------------------------------------------ --- public."licenseVersions" --- ------------------------------------------------------------ - -CREATE TABLE "licenseVersions" ( - "id" UUID PRIMARY KEY DEFAULT uuidv7(), - "licenseId" UUID NOT NULL REFERENCES "licenses"("id") ON DELETE RESTRICT, - "vendorId" UUID NOT NULL REFERENCES "vendors"("id") ON DELETE RESTRICT, - "changeType" TEXT NOT NULL, - "previousState" JSONB NOT NULL, - "newState" JSONB NOT NULL, - "changedBy" TEXT NOT NULL, - "changeReason" TEXT, - "changedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -COMMENT ON TABLE "licenseVersions" IS 'Immutable append-only log of all license state changes. Distinct from audit.auditLogs: this table tracks the evolution of the license business object for point-in-time reconstruction; auditLogs tracks who performed an action and from which IP. Both records are written transactionally on any mutating license operation.'; -COMMENT ON COLUMN "licenseVersions"."id" IS 'Surrogate primary key generated by uuidv7().'; -COMMENT ON COLUMN "licenseVersions"."licenseId" IS 'License whose state was changed.'; -COMMENT ON COLUMN "licenseVersions"."vendorId" IS 'Denormalized vendor ID for efficient RLS filtering and vendor-scoped historical queries without a JOIN through licenses.'; -COMMENT ON COLUMN "licenseVersions"."changeType" IS 'Categorical label describing the nature of the change (e.g. LICENSE_CREATED, LICENSE_EXTENDED, DEVICE_UPDATED, LICENSE_REVOKED, LICENSE_REACTIVATED).'; -COMMENT ON COLUMN "licenseVersions"."previousState" IS 'Full JSONB snapshot of the license row immediately before this change. Enables complete rollback diffing and point-in-time reconstruction.'; -COMMENT ON COLUMN "licenseVersions"."newState" IS 'Full JSONB snapshot of the license row immediately after this change.'; -COMMENT ON COLUMN "licenseVersions"."changedBy" IS 'Identifier of the actor who made the change: vendor UUID for manual changes, or the string "system" for automated transitions (e.g. expiry jobs).'; -COMMENT ON COLUMN "licenseVersions"."changeReason" IS 'Optional human-readable justification for the change (e.g. "Device stolen, reactivation requested by customer"). Useful for customer support and dispute resolution.'; -COMMENT ON COLUMN "licenseVersions"."changedAt" IS 'Timestamp when this version entry was recorded. Immutable after insertion.'; - -CREATE INDEX "licenseVersions_licenseId_idx" ON "licenseVersions" ("licenseId"); -CREATE INDEX "licenseVersions_vendorId_changedAt_idx" ON "licenseVersions" ("vendorId", "changedAt" DESC); +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 index 2d66edb..2d97ad5 100644 --- a/migrations/04_audit.sql +++ b/migrations/04_audit.sql @@ -32,7 +32,7 @@ -- audit."auditLogs" -- ------------------------------------------------------------ -CREATE TABLE 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, @@ -40,54 +40,54 @@ CREATE TABLE audit."auditLogs" ( "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 the security and compliance context: what action was performed, from which IP, via which user agent, and when. The actor (who) and the affected resource (what subject) are captured in separate per-type junction tables, allowing new actor and resource types to be added without migrating this table.'; -COMMENT ON COLUMN audit."auditLogs"."id" IS 'Surrogate primary key generated by uuidv7(). Time-ordered for chronological B-tree locality. Referenced as a FK in all audit junction tables.'; -COMMENT ON COLUMN audit."auditLogs"."actionCode" IS 'Resource-agnostic action verb performed. FK to reference.actions. The resource type and ID are captured in the corresponding junction table (auditLogLicenses, auditLogSessions, etc.).'; -COMMENT ON COLUMN audit."auditLogs"."ipAddress" IS 'Client IP address at time of the action. Stored as INET type to support efficient IP range queries and subnet filtering for anomaly detection.'; -COMMENT ON COLUMN audit."auditLogs"."userAgent" IS 'HTTP User-Agent header from the request. Used to identify SDK versions, detect automated scripts, and flag anomalous client behaviour during security investigations.'; -COMMENT ON COLUMN audit."auditLogs"."createdAt" IS 'Timestamp when this audit entry was recorded. Always UTC. Immutable after insertion.'; +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 audit."auditLogVendorActors" ( +CREATE TABLE IF NOT EXISTS audit."auditLogVendorActors" ( "auditLogId" UUID NOT NULL PRIMARY KEY REFERENCES audit."auditLogs"("id") ON DELETE CASCADE, "vendorId" UUID NOT NULL REFERENCES public."vendors"("id") ON DELETE RESTRICT ); -COMMENT ON TABLE audit."auditLogVendorActors" IS 'Junction table associating an audit log entry with the vendor who performed the action. At most one vendor actor per audit entry (PK enforces this). In v1.0, auditLogClientActors will be added as a separate table using the same pattern — no migration of existing audit rows needed.'; -COMMENT ON COLUMN audit."auditLogVendorActors"."auditLogId" IS 'FK to the core audit log entry. Also the PK of this table, enforcing at most one vendor actor per audit entry.'; -COMMENT ON COLUMN audit."auditLogVendorActors"."vendorId" IS 'Vendor who performed the audited action. ON DELETE RESTRICT prevents deletion of a vendor while audit trail entries remain.'; +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 "auditLogVendorActors_vendorId_idx" ON audit."auditLogVendorActors" ("vendorId"); +CREATE INDEX IF NOT EXISTS "auditLogVendorActors_vendorId_idx" ON audit."auditLogVendorActors" ("vendorId"); -- ------------------------------------------------------------ -- audit."auditLogLicenses" -- ------------------------------------------------------------ -CREATE TABLE 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 a license as the resource affected by an audited action. Stores license-specific mutation context in the optional changes column.'; -COMMENT ON COLUMN audit."auditLogLicenses"."auditLogId" IS 'FK to the core audit log entry. Also the PK of this table, enforcing at most one license resource per audit entry.'; -COMMENT ON COLUMN audit."auditLogLicenses"."licenseId" IS 'The license that was the subject of the audited action. ON DELETE RESTRICT prevents deletion of a license while audit trail entries remain.'; -COMMENT ON COLUMN audit."auditLogLicenses"."changes" IS 'Nullable JSONB diff capturing license-specific mutation context (e.g. {"licenseStatusCode": {"from": "ACTIVE", "to": "REVOKED"}}). NULL for read or query actions where no mutation occurred.'; +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.'; -- ------------------------------------------------------------ -- audit."auditLogSessions" -- ------------------------------------------------------------ -CREATE TABLE 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 a session as the resource affected by an audited action. Stores session-specific mutation context in the optional changes column.'; -COMMENT ON COLUMN audit."auditLogSessions"."auditLogId" IS 'FK to the core audit log entry. Also the PK of this table, enforcing at most one session resource per audit entry.'; -COMMENT ON COLUMN audit."auditLogSessions"."sessionId" IS 'The session that was the subject of the audited action. ON DELETE RESTRICT prevents deletion of a session while audit trail entries remain.'; -COMMENT ON COLUMN audit."auditLogSessions"."changes" IS 'Nullable JSONB diff capturing session-specific mutation context (e.g. {"sessionStatusCode": {"from": "ACTIVE", "to": "REVOKED"}}). NULL for read or query actions where no mutation occurred.'; +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.'; diff --git a/migrations/README.md b/migrations/README.md index b4e3afd..c6bba81 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -6,10 +6,29 @@ This directory contains the PostgreSQL 18 migration scripts for the LaaS (Licens 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. +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 +``` --- @@ -72,7 +91,6 @@ erDiagram text sessionToken text deviceFingerprintHash timestamptz createdAt - timestamptz lastHeartbeatAt jsonb metadata } public_heartbeats { @@ -82,17 +100,7 @@ erDiagram text errorCode timestamptz heartbeatAt } - public_licenseVersions { - uuid id - uuid licenseId - uuid vendorId - text changeType - jsonb previousState - jsonb newState - text changedBy - text changeReason - timestamptz changedAt - } + %% AUDIT SCHEMA audit_auditLogs { @@ -122,8 +130,6 @@ erDiagram public_licenses ||--|| public_nodeLockedLicenseData : extends public_licenses ||--o{ public_sessions : has public_sessions ||--o{ public_heartbeats : emits - public_licenses ||--o{ public_licenseVersions : versions - public_vendors ||--o{ public_licenseVersions : modifies reference_licenseStatuses ||--o{ public_licenses : defines_status reference_sessionStatuses ||--o{ public_sessions : defines_status @@ -147,6 +153,7 @@ erDiagram 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" @@ -154,6 +161,7 @@ docker compose exec db psql -U postgres -d app -c "SELECT schemaname, tablename ``` ### 2. Check Seed Data Counts + ```bash docker compose exec db psql -U postgres -d app -c " SELECT 'licenseStatuses: ' || COUNT(*) FROM reference.\"licenseStatuses\" @@ -164,16 +172,19 @@ 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;" @@ -182,4 +193,3 @@ docker compose exec db psql -U postgres -d app -c "SELECT * FROM reference.\"hea 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..e571def --- /dev/null +++ b/migrations/down/01_schemas_down.sql @@ -0,0 +1,12 @@ +-- ============================================================ +-- 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..11313dd --- /dev/null +++ b/migrations/down/02_reference_down.sql @@ -0,0 +1,19 @@ +-- ============================================================ +-- 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..e2071b4 --- /dev/null +++ b/migrations/down/03_public_down.sql @@ -0,0 +1,28 @@ +-- ============================================================ +-- 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_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..0d44844 --- /dev/null +++ b/migrations/down/04_audit_down.sql @@ -0,0 +1,17 @@ +-- ============================================================ +-- 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 INDEX IF EXISTS audit."auditLogVendorActors_vendorId_idx"; +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; From a7e7cf529efac6f3dc3efcf8993e7a2479015ca7 Mon Sep 17 00:00:00 2001 From: M Laraib Ali Date: Fri, 27 Feb 2026 08:52:02 +0500 Subject: [PATCH 3/6] Refactor migration scripts for improved clarity and consistency in schema comments and downgrade sequences --- migrations/01_schemas.sql | 9 ++++----- migrations/02_reference.sql | 10 +++++----- migrations/03_public.sql | 20 ++++++++++---------- migrations/04_audit.sql | 6 +++--- migrations/down/01_schemas_down.sql | 2 +- migrations/down/02_reference_down.sql | 4 ---- migrations/down/03_public_down.sql | 3 --- migrations/down/04_audit_down.sql | 4 +--- 8 files changed, 24 insertions(+), 34 deletions(-) diff --git a/migrations/01_schemas.sql b/migrations/01_schemas.sql index 2a761c3..0fadc75 100644 --- a/migrations/01_schemas.sql +++ b/migrations/01_schemas.sql @@ -8,10 +8,9 @@ -- ============================================================ CREATE SCHEMA IF NOT EXISTS reference; -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).'; - CREATE SCHEMA IF NOT EXISTS public; -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).'; - CREATE SCHEMA IF NOT EXISTS audit; -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.'; + +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 index ff7ef6e..6c71ffe 100644 --- a/migrations/02_reference.sql +++ b/migrations/02_reference.sql @@ -18,7 +18,7 @@ -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS reference."licenseStatuses" ( - "code" TEXT NOT NULL PRIMARY KEY, + "code" TEXT PRIMARY KEY, "description" TEXT NOT NULL ); @@ -36,7 +36,7 @@ ON CONFLICT ("code") DO NOTHING; -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS reference."sessionStatuses" ( - "code" TEXT NOT NULL PRIMARY KEY, + "code" TEXT PRIMARY KEY, "description" TEXT NOT NULL ); @@ -56,7 +56,7 @@ ON CONFLICT ("code") DO NOTHING; -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS reference."heartbeatRespStatuses" ( - "code" TEXT NOT NULL PRIMARY KEY, + "code" TEXT PRIMARY KEY, "description" TEXT NOT NULL ); @@ -77,7 +77,7 @@ ON CONFLICT ("code") DO NOTHING; -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS reference."errorCodes" ( - "code" TEXT NOT NULL PRIMARY KEY, + "code" TEXT PRIMARY KEY, "description" TEXT NOT NULL ); @@ -105,7 +105,7 @@ ON CONFLICT ("code") DO NOTHING; -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS reference."actions" ( - "code" TEXT NOT NULL PRIMARY KEY, + "code" TEXT PRIMARY KEY, "description" TEXT NOT NULL ); diff --git a/migrations/03_public.sql b/migrations/03_public.sql index 20e3687..e8730ee 100644 --- a/migrations/03_public.sql +++ b/migrations/03_public.sql @@ -81,7 +81,7 @@ CREATE TABLE IF NOT EXISTS "nodeLockedLicenseData" ( 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 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.'; @@ -101,14 +101,14 @@ CREATE TABLE IF NOT EXISTS "sessions" ( "metadata" JSONB ); -COMMENT ON TABLE "sessions" IS 'Active and historical sessions created via license activation. Mostly immutable after creation. Grace period computed at query time from MAX(heartbeats.heartbeatAt).'; +COMMENT ON TABLE "sessions" IS 'Active and historical sessions created via license activation. Mostly immutable after creation. Grace period computed at query time from MAX(heartbeats.heartbeatAt).'; 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"."sessionToken" IS 'Cryptographically random bearer token. Sole credential for heartbeat requests. Treat as secret.'; +COMMENT ON COLUMN "sessions"."sessionToken" IS 'Cryptographically random bearer token. Sole credential for heartbeat requests. Treat as secret.'; 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.'; +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" @@ -131,12 +131,12 @@ CREATE TABLE IF NOT EXISTS "heartbeats" ( PRIMARY KEY ("id", "heartbeatAt") ) PARTITION BY RANGE ("heartbeatAt"); -COMMENT ON TABLE "heartbeats" IS 'Append-only time-series log of non-CONTINUE heartbeat events (errors, revocations, expirations, refresh triggers). Range-partitioned by heartbeatAt for efficient archival and partition pruning.'; -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 TABLE "heartbeats" IS 'Append-only time-series log of non-CONTINUE heartbeat events (errors, revocations, expirations, refresh triggers). Range-partitioned by heartbeatAt for efficient archival and partition pruning.'; +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. Only non-CONTINUE responses logged here.'; -COMMENT ON COLUMN "heartbeats"."errorCode" IS 'Error code if response was ERROR. NULL for non-error events (REFRESH, REVOKED, EXPIRED).'; -COMMENT ON COLUMN "heartbeats"."heartbeatAt" IS 'Timestamp when event received. Partition key. BRIN index supports efficient time-range scans.'; +COMMENT ON COLUMN "heartbeats"."errorCode" IS 'Error code if response was ERROR. NULL for non-error events (REFRESH, REVOKED, EXPIRED).'; +COMMENT ON COLUMN "heartbeats"."heartbeatAt" IS 'Timestamp when event received. Partition key. BRIN index supports efficient time-range scans.'; -- Partitions: pre-create 5 quarters covering 2026 and 2027 Q1. -- Add future partitions before the current quarter boundary. diff --git a/migrations/04_audit.sql b/migrations/04_audit.sql index 2d97ad5..f8d8a38 100644 --- a/migrations/04_audit.sql +++ b/migrations/04_audit.sql @@ -56,7 +56,7 @@ CREATE TABLE IF NOT EXISTS audit."auditLogVendorActors" ( "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 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.'; @@ -72,7 +72,7 @@ CREATE TABLE IF NOT EXISTS audit."auditLogLicenses" ( "changes" JSONB ); -COMMENT ON TABLE audit."auditLogLicenses" IS 'Junction table identifying license as affected resource. Stores mutation context in changes column.'; +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.'; @@ -87,7 +87,7 @@ CREATE TABLE IF NOT EXISTS audit."auditLogSessions" ( "changes" JSONB ); -COMMENT ON TABLE audit."auditLogSessions" IS 'Junction table identifying session as affected resource. Stores mutation context in changes column.'; +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.'; diff --git a/migrations/down/01_schemas_down.sql b/migrations/down/01_schemas_down.sql index e571def..e500526 100644 --- a/migrations/down/01_schemas_down.sql +++ b/migrations/down/01_schemas_down.sql @@ -8,5 +8,5 @@ -- ============================================================ DROP SCHEMA IF EXISTS audit CASCADE; - +DROP SCHEMA IF EXISTS public CASCADE; DROP SCHEMA IF EXISTS reference CASCADE; diff --git a/migrations/down/02_reference_down.sql b/migrations/down/02_reference_down.sql index 11313dd..aa4f563 100644 --- a/migrations/down/02_reference_down.sql +++ b/migrations/down/02_reference_down.sql @@ -9,11 +9,7 @@ -- ============================================================ 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 index e2071b4..0f1a232 100644 --- a/migrations/down/03_public_down.sql +++ b/migrations/down/03_public_down.sql @@ -20,9 +20,6 @@ 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 index 0d44844..1837c99 100644 --- a/migrations/down/04_audit_down.sql +++ b/migrations/down/04_audit_down.sql @@ -8,10 +8,8 @@ -- ============================================================ DROP INDEX IF EXISTS audit."auditLogVendorActors_vendorId_idx"; -DROP TABLE IF EXISTS audit."auditLogVendorActors" 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; From 9d95f9375fb83e3363f18cd74965dc7613fe839f Mon Sep 17 00:00:00 2001 From: M Laraib Ali Date: Fri, 27 Feb 2026 10:52:36 +0500 Subject: [PATCH 4/6] Enhance security and audit schema with immutability controls, session token hashing, and refined comments --- backend/app/core/security.py | 14 +++++ migrations/02_reference.sql | 2 +- migrations/03_public.sql | 25 ++++---- migrations/04_audit.sql | 89 +++++++++++++++++++++++++++++ migrations/README.md | 4 +- migrations/down/01_schemas_down.sql | 1 - migrations/down/03_public_down.sql | 1 + migrations/down/04_audit_down.sql | 26 +++++++-- 8 files changed, 143 insertions(+), 19 deletions(-) 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/02_reference.sql b/migrations/02_reference.sql index 6c71ffe..1f715ca 100644 --- a/migrations/02_reference.sql +++ b/migrations/02_reference.sql @@ -123,6 +123,6 @@ INSERT INTO reference."actions" ("code", "description") VALUES ('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 audit.heartbeats for the audit trail.'), + ('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 index e8730ee..50675c0 100644 --- a/migrations/03_public.sql +++ b/migrations/03_public.sql @@ -95,17 +95,17 @@ 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, - "sessionToken" TEXT NOT NULL UNIQUE, + "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. Grace period computed at query time from MAX(heartbeats.heartbeatAt).'; +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"."sessionToken" IS 'Cryptographically random bearer token. Sole credential for heartbeat requests. Treat as secret.'; +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.'; @@ -113,10 +113,9 @@ COMMENT ON COLUMN "sessions"."metadata" IS 'Arbitrary session metad -- ------------------------------------------------------------ -- public."heartbeats" -- ------------------------------------------------------------ --- Append-only time-series log for non-CONTINUE heartbeat events. --- Successful CONTINUE heartbeats are not appended here to keep write --- amplification low; grace period is computed by querying --- MAX(heartbeatAt) for each session at validation time. +-- 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. @@ -131,21 +130,25 @@ CREATE TABLE IF NOT EXISTS "heartbeats" ( PRIMARY KEY ("id", "heartbeatAt") ) PARTITION BY RANGE ("heartbeatAt"); -COMMENT ON TABLE "heartbeats" IS 'Append-only time-series log of non-CONTINUE heartbeat events (errors, revocations, expirations, refresh triggers). Range-partitioned by heartbeatAt for efficient archival and partition pruning.'; +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. Only non-CONTINUE responses logged here.'; -COMMENT ON COLUMN "heartbeats"."errorCode" IS 'Error code if response was ERROR. NULL for non-error events (REFRESH, REVOKED, EXPIRED).'; -COMMENT ON COLUMN "heartbeats"."heartbeatAt" IS 'Timestamp when event received. Partition key. BRIN index supports efficient time-range scans.'; +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.'; + -- 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 ('2026-01-01') TO ('2026-04-01'); CREATE TABLE IF NOT EXISTS "heartbeats_2026_q2" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-04-01') TO ('2026-07-01'); CREATE TABLE IF NOT EXISTS "heartbeats_2026_q3" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-07-01') TO ('2026-10-01'); CREATE TABLE IF NOT EXISTS "heartbeats_2026_q4" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-10-01') TO ('2027-01-01'); CREATE TABLE IF NOT EXISTS "heartbeats_2027_q1" PARTITION OF "heartbeats" FOR VALUES FROM ('2027-01-01') TO ('2027-04-01'); +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 diff --git a/migrations/04_audit.sql b/migrations/04_audit.sql index f8d8a38..ce7c391 100644 --- a/migrations/04_audit.sql +++ b/migrations/04_audit.sql @@ -91,3 +91,92 @@ COMMENT ON TABLE audit."auditLogSessions" IS 'Junction table ident 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.'; +-- ============================================================ +-- 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 audit.%.%', + 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 %I.%I FOR EACH ROW EXECUTE FUNCTION audit.prevent_audit_update_delete()', + v_obj.schema_name, v_obj.object_identity + ); + 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 index c6bba81..6f13b5f 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -88,7 +88,7 @@ erDiagram uuid id uuid licenseId text sessionStatusCode - text sessionToken + bytea sessionTokenHash text deviceFingerprintHash timestamptz createdAt jsonb metadata @@ -137,7 +137,7 @@ erDiagram reference_errorCodes ||--o{ public_heartbeats : defines_error reference_actions ||--o{ audit_auditLogs : defines_type - audit_auditLogs ||--|| audit_auditLogVendorActors : acts_as + audit_auditLogs ||--o| audit_auditLogVendorActors : acts_as audit_auditLogs ||--o| audit_auditLogLicenses : affects audit_auditLogs ||--o| audit_auditLogSessions : affects diff --git a/migrations/down/01_schemas_down.sql b/migrations/down/01_schemas_down.sql index e500526..8eaf8b2 100644 --- a/migrations/down/01_schemas_down.sql +++ b/migrations/down/01_schemas_down.sql @@ -8,5 +8,4 @@ -- ============================================================ DROP SCHEMA IF EXISTS audit CASCADE; -DROP SCHEMA IF EXISTS public CASCADE; DROP SCHEMA IF EXISTS reference CASCADE; diff --git a/migrations/down/03_public_down.sql b/migrations/down/03_public_down.sql index 0f1a232..1f1af04 100644 --- a/migrations/down/03_public_down.sql +++ b/migrations/down/03_public_down.sql @@ -9,6 +9,7 @@ -- ============================================================ -- 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; diff --git a/migrations/down/04_audit_down.sql b/migrations/down/04_audit_down.sql index 1837c99..9b327c8 100644 --- a/migrations/down/04_audit_down.sql +++ b/migrations/down/04_audit_down.sql @@ -7,9 +7,27 @@ -- Run this BEFORE removing public schema tables. -- ============================================================ +-- Drop event trigger and its associated function. +DROP EVENT TRIGGER IF EXISTS audit_immutability_on_create_table; +DROP FUNCTION IF EXISTS audit.attach_immutability_trigger(); + +-- Drop immutability trigger function (triggers are dropped implicitly with tables, but cleanup for clarity). +DROP FUNCTION IF EXISTS audit.prevent_audit_update_delete(); + +-- Revoke audit_writer role permissions. +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; + +-- 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 $$; + +-- Drop index. DROP INDEX IF EXISTS audit."auditLogVendorActors_vendorId_idx"; -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; +-- Drop tables (triggers are dropped implicitly with cascading). From e0b5270679fa18148d23c1f4f945d68398dd4d1a Mon Sep 17 00:00:00 2001 From: M Laraib Ali Date: Fri, 27 Feb 2026 11:35:43 +0500 Subject: [PATCH 5/6] Refactor audit migration scripts for improved clarity and consistency, including enhanced comments, constraint adjustments, and proper cleanup in downgrade sequences. --- migrations/03_public.sql | 11 +++++++++-- migrations/04_audit.sql | 8 ++++---- migrations/down/04_audit_down.sql | 20 +++++++++++++------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/migrations/03_public.sql b/migrations/03_public.sql index 50675c0..172bf37 100644 --- a/migrations/03_public.sql +++ b/migrations/03_public.sql @@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS "vendors" ( 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 'bcrypt hash (≥12 rounds). Raw password never persisted.'; +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.'; @@ -127,7 +127,11 @@ CREATE TABLE IF NOT EXISTS "heartbeats" ( "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") + 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.'; @@ -137,6 +141,9 @@ COMMENT ON COLUMN "heartbeats"."heartbeatRespStatusCode" IS 'Response code retur 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. diff --git a/migrations/04_audit.sql b/migrations/04_audit.sql index ce7c391..b581bb5 100644 --- a/migrations/04_audit.sql +++ b/migrations/04_audit.sql @@ -52,7 +52,7 @@ COMMENT ON COLUMN audit."auditLogs"."createdAt" IS 'Timestamp when entry recor -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS audit."auditLogVendorActors" ( - "auditLogId" UUID NOT NULL PRIMARY KEY REFERENCES audit."auditLogs"("id") ON DELETE CASCADE, + "auditLogId" UUID NOT NULL PRIMARY KEY REFERENCES audit."auditLogs"("id") ON DELETE RESTRICT, "vendorId" UUID NOT NULL REFERENCES public."vendors"("id") ON DELETE RESTRICT ); @@ -104,7 +104,7 @@ COMMENT ON COLUMN audit."auditLogSessions"."changes" IS 'Optional JSONB diff 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 audit.%.%', + 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; @@ -139,8 +139,8 @@ BEGIN WHERE object_type = 'table' AND schema_name = 'audit' LOOP EXECUTE format( - 'CREATE TRIGGER prevent_audit_update_delete_tr BEFORE UPDATE OR DELETE ON %I.%I FOR EACH ROW EXECUTE FUNCTION audit.prevent_audit_update_delete()', - v_obj.schema_name, v_obj.object_identity + '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; diff --git a/migrations/down/04_audit_down.sql b/migrations/down/04_audit_down.sql index 9b327c8..f30b5a5 100644 --- a/migrations/down/04_audit_down.sql +++ b/migrations/down/04_audit_down.sql @@ -7,11 +7,22 @@ -- Run this BEFORE removing public schema tables. -- ============================================================ --- Drop event trigger and its associated function. +-- 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 immutability trigger function (triggers are dropped implicitly with tables, but cleanup for clarity). +-- 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. @@ -26,8 +37,3 @@ BEGIN DROP ROLE IF EXISTS audit_writer; END IF; END $$; - --- Drop index. -DROP INDEX IF EXISTS audit."auditLogVendorActors_vendorId_idx"; - --- Drop tables (triggers are dropped implicitly with cascading). From d48c74c16e418cf41d0dec01eec235fa469630a1 Mon Sep 17 00:00:00 2001 From: M Laraib Ali Date: Fri, 27 Feb 2026 15:07:47 +0500 Subject: [PATCH 6/6] Update partition definitions to use TIMESTAMPTZ for heartbeats tables and add indexes for audit log tables --- migrations/03_public.sql | 10 +++++----- migrations/04_audit.sql | 5 +++++ migrations/down/04_audit_down.sql | 13 +++++++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/migrations/03_public.sql b/migrations/03_public.sql index 172bf37..901c10a 100644 --- a/migrations/03_public.sql +++ b/migrations/03_public.sql @@ -150,11 +150,11 @@ COMMENT ON COLUMN "heartbeats"."heartbeatAt" IS 'Timestamp when even -- 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 ('2026-01-01') TO ('2026-04-01'); -CREATE TABLE IF NOT EXISTS "heartbeats_2026_q2" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-04-01') TO ('2026-07-01'); -CREATE TABLE IF NOT EXISTS "heartbeats_2026_q3" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-07-01') TO ('2026-10-01'); -CREATE TABLE IF NOT EXISTS "heartbeats_2026_q4" PARTITION OF "heartbeats" FOR VALUES FROM ('2026-10-01') TO ('2027-01-01'); -CREATE TABLE IF NOT EXISTS "heartbeats_2027_q1" PARTITION OF "heartbeats" FOR VALUES FROM ('2027-01-01') TO ('2027-04-01'); +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: diff --git a/migrations/04_audit.sql b/migrations/04_audit.sql index b581bb5..90c6e72 100644 --- a/migrations/04_audit.sql +++ b/migrations/04_audit.sql @@ -77,6 +77,8 @@ COMMENT ON COLUMN audit."auditLogLicenses"."auditLogId" IS 'FK to audit log entr 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" -- ------------------------------------------------------------ @@ -91,6 +93,9 @@ COMMENT ON TABLE audit."auditLogSessions" IS 'Junction table ident 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 -- ============================================================ diff --git a/migrations/down/04_audit_down.sql b/migrations/down/04_audit_down.sql index f30b5a5..929153e 100644 --- a/migrations/down/04_audit_down.sql +++ b/migrations/down/04_audit_down.sql @@ -25,10 +25,15 @@ 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. -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; +-- 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 $$