Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,9 @@ LIFECYCLE_SWEEP_INTERVAL_MS=0
SMS_PROVIDER=mock
RATE_LIMIT_OTP_WINDOW_MS=900000
RATE_LIMIT_OTP_MAX=5

# Data Lifecycle / Audit Configuration — see docs/DATA_LIFECYCLE.md
# HMAC key for the source-IP hash on immutable audit events. Rotating it makes
# older hashes uncorrelatable with newer ones. If unset in production, audit
# events omit the IP hash rather than storing an unkeyed (reversible) digest.
# AUDIT_IP_HASH_SECRET=change-me-in-production
392 changes: 392 additions & 0 deletions docs/DATA_LIFECYCLE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- AUDITABLE DATA LIFECYCLE AND ARCHIVE POLICY
-- ─────────────────────────────────────────────────────────────────────────────
-- Adds the two structures the lifecycle policy needs at the database level:
--
-- 1. "audit_events" — the immutable audit spine. Append-only, enforced by a
-- trigger rather than by convention, because an audit trail an application
-- bug can quietly rewrite is not an audit trail.
--
-- 2. Archive columns on archivable models, so withdrawing a record that other
-- records depend on is a soft delete instead of a cascade.
--
-- See docs/DATA_LIFECYCLE.md for the full lifecycle matrix.

-- CreateTable "audit_events"
-- Deliberately has no foreign key to "users": a cascade would let erasure
-- destroy the trail, and SET NULL would mutate a row that must never change.
-- "actorId" and "targetId" are soft references.
CREATE TABLE "audit_events" (
"id" TEXT NOT NULL,
-- Actor: who caused the change
"actorType" TEXT NOT NULL,
"actorId" TEXT,
"actorRole" TEXT,
-- Action and target: what changed
"action" TEXT NOT NULL,
"recordClass" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT,
-- Justification and correlation
"reason" TEXT,
"requestId" TEXT,
"correlationId" TEXT,
"source" TEXT,
-- Safe context: redacted JSON, keyed IP hash, coarse User-Agent family
"metadata" TEXT,
"actorIpHash" TEXT,
"userAgentFamily" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "audit_events_targetType_targetId_occurredAt_idx" ON "audit_events"("targetType", "targetId", "occurredAt");

-- CreateIndex
CREATE INDEX "audit_events_actorType_actorId_occurredAt_idx" ON "audit_events"("actorType", "actorId", "occurredAt");

-- CreateIndex
CREATE INDEX "audit_events_action_occurredAt_idx" ON "audit_events"("action", "occurredAt");

-- CreateIndex
CREATE INDEX "audit_events_requestId_idx" ON "audit_events"("requestId");

-- CreateIndex
CREATE INDEX "audit_events_occurredAt_idx" ON "audit_events"("occurredAt");


-- ─────────────────────────────────────────────────────────────────────────────
-- IMMUTABILITY ENFORCEMENT
-- ─────────────────────────────────────────────────────────────────────────────
-- UPDATE is rejected unconditionally. DELETE is rejected unless the caller has
-- set "learnault.audit_purge" for the current transaction, which only
-- AuditEventService.purgeExpired() does — and it deletes by timestamp, so the
-- escape hatch cannot be used to remove one specific inconvenient event.
--
-- SECURITY DEFINER is not used: the check must apply to every role, including
-- the migration owner.
CREATE OR REPLACE FUNCTION "audit_events_reject_mutation"()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'UPDATE' THEN
RAISE EXCEPTION
'audit_events rows are immutable: UPDATE is not permitted (row %)', OLD."id"
USING ERRCODE = 'restrict_violation';
END IF;

-- TG_OP = 'DELETE'. current_setting(..., true) returns NULL rather than
-- raising when the setting has never been assigned in this session.
IF coalesce(current_setting('learnault.audit_purge', true), 'off') <> 'on' THEN
RAISE EXCEPTION
'audit_events rows may only be deleted by the retention purge (row %)', OLD."id"
USING ERRCODE = 'restrict_violation';
END IF;

RETURN OLD;
END;
$$ LANGUAGE plpgsql;

-- DROP ... IF EXISTS before each CREATE so this block can be re-applied. It is
-- also how `prisma db push` environments (which skip migration SQL entirely,
-- and so never get these triggers) can bootstrap them — see
-- tests/integration/audit-immutability.test.ts.
DROP TRIGGER IF EXISTS "audit_events_no_update" ON "audit_events";
CREATE TRIGGER "audit_events_no_update"
BEFORE UPDATE ON "audit_events"
FOR EACH ROW EXECUTE FUNCTION "audit_events_reject_mutation"();

DROP TRIGGER IF EXISTS "audit_events_no_delete" ON "audit_events";
CREATE TRIGGER "audit_events_no_delete"
BEFORE DELETE ON "audit_events"
FOR EACH ROW EXECUTE FUNCTION "audit_events_reject_mutation"();

-- TRUNCATE bypasses row-level triggers entirely, so it needs a statement-level
-- one of its own. Without this, `TRUNCATE audit_events` would erase the whole
-- trail despite the row triggers above.
CREATE OR REPLACE FUNCTION "audit_events_reject_truncate"()
RETURNS TRIGGER AS $$
BEGIN
IF coalesce(current_setting('learnault.audit_purge', true), 'off') <> 'on' THEN
RAISE EXCEPTION 'audit_events may not be truncated'
USING ERRCODE = 'restrict_violation';
END IF;

RETURN NULL;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS "audit_events_no_truncate" ON "audit_events";
CREATE TRIGGER "audit_events_no_truncate"
BEFORE TRUNCATE ON "audit_events"
FOR EACH STATEMENT EXECUTE FUNCTION "audit_events_reject_truncate"();


-- ─────────────────────────────────────────────────────────────────────────────
-- ARCHIVE COLUMNS
-- ─────────────────────────────────────────────────────────────────────────────
-- ARCHIVABLE models only. A row is withdrawn by stamping "archivedAt"; reads
-- exclude archived rows by default via the Prisma extension in
-- src/audit/archive.ts. "archivedById" is a soft reference to users.id, not a
-- foreign key, so archive attribution survives the actor's own erasure.

-- AlterTable: learner_profiles
ALTER TABLE "learner_profiles"
ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedById" TEXT,
ADD COLUMN "archivedReason" TEXT;

-- AlterTable: Module
ALTER TABLE "Module"
ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedById" TEXT,
ADD COLUMN "archivedReason" TEXT;

-- AlterTable: avatars
ALTER TABLE "avatars"
ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedById" TEXT,
ADD COLUMN "archivedReason" TEXT;

-- AlterTable: referral_codes
ALTER TABLE "referral_codes"
ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedById" TEXT,
ADD COLUMN "archivedReason" TEXT;

-- AlterTable: WebhookEndpoint
ALTER TABLE "WebhookEndpoint"
ADD COLUMN "archivedAt" TIMESTAMP(3),
ADD COLUMN "archivedById" TEXT,
ADD COLUMN "archivedReason" TEXT;

-- CreateIndex
-- Serves both sides of the archive filter: the "archivedAt" IS NULL predicate
-- every default read carries, and the range scan the retention purge runs over
-- archived rows. Plain rather than partial so it matches the @@index in
-- schema.prisma and no drift is reported.
CREATE INDEX "learner_profiles_archivedAt_idx" ON "learner_profiles"("archivedAt");
CREATE INDEX "Module_archivedAt_idx" ON "Module"("archivedAt");
CREATE INDEX "avatars_archivedAt_idx" ON "avatars"("archivedAt");
CREATE INDEX "referral_codes_archivedAt_idx" ON "referral_codes"("archivedAt");
CREATE INDEX "WebhookEndpoint_archivedAt_idx" ON "WebhookEndpoint"("archivedAt");

-- An archived row must always say why and when. Enforced in the database
-- because "archive" is a write that lands from several call sites, and a row
-- archived without a reason is indistinguishable from an accident months later.
ALTER TABLE "learner_profiles" ADD CONSTRAINT "learner_profiles_archive_reason_check"
CHECK ("archivedAt" IS NULL OR "archivedReason" IS NOT NULL);
ALTER TABLE "Module" ADD CONSTRAINT "Module_archive_reason_check"
CHECK ("archivedAt" IS NULL OR "archivedReason" IS NOT NULL);
ALTER TABLE "avatars" ADD CONSTRAINT "avatars_archive_reason_check"
CHECK ("archivedAt" IS NULL OR "archivedReason" IS NOT NULL);
ALTER TABLE "referral_codes" ADD CONSTRAINT "referral_codes_archive_reason_check"
CHECK ("archivedAt" IS NULL OR "archivedReason" IS NOT NULL);
ALTER TABLE "WebhookEndpoint" ADD CONSTRAINT "WebhookEndpoint_archive_reason_check"
CHECK ("archivedAt" IS NULL OR "archivedReason" IS NOT NULL);
92 changes: 92 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,19 @@ model LearnerProfile {

visibility String @default("private") // private, employer, public

// Archive (soft delete). Reads exclude archived rows by default — see
// src/audit/archive.ts. Purged 365 days after archivedAt.
archivedAt DateTime?
archivedById String?
archivedReason String?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([country])
@@index([level])
@@index([visibility])
@@index([archivedAt])
@@map("learner_profiles")
}

Expand Down Expand Up @@ -210,6 +217,60 @@ model RefreshToken {
@@map("refresh_tokens")
}

/// Immutable audit spine for security-, money-, credential-, and
/// content-sensitive mutations. See docs/DATA_LIFECYCLE.md.
///
/// Append-only: a database trigger rejects every UPDATE, and rejects DELETE
/// unless the retention purge has set the `learnault.audit_purge` session
/// variable. There is no code path that edits a row here.
///
/// Erasure-safe by construction. Nothing in this table needs scrubbing when a
/// subject is erased, because nothing identifying is stored in the first place:
/// actor and target are opaque ids, the request IP is kept only as a keyed
/// hash, the User-Agent only as a coarse family, and `metadata` passes through
/// the redaction filter in src/audit/redaction.ts on the way in.
///
/// Deliberately has no relation to User. A foreign key would need either
/// `onDelete: Cascade` (erasure destroys the trail) or `onDelete: SetNull`
/// (erasure mutates an immutable row). `actorId` is a soft reference instead.
model AuditEvent {
id String @id @default(uuid())

// ── Actor: who caused the change ──
actorType String // USER, ADMIN, SYSTEM, WORKER, ANONYMOUS
actorId String? // opaque id; null for SYSTEM and ANONYMOUS actors
actorRole String? // role held at the time of the action, not now

// ── Action and target: what changed ──
action String // e.g. "account.deactivated", "wallet.provisioned"
recordClass String // MUTABLE, ARCHIVABLE, DELETABLE, IMMUTABLE
targetType String // Prisma model name, e.g. "User", "Wallet"
targetId String? // primary key of the affected row

// ── Justification and correlation ──
reason String? // caller-supplied; required for ADMIN actors
requestId String? // x-request-id, correlates to request logs
correlationId String? // outbox event or job id for async changes
source String? // e.g. "api.account.deactivate", "worker.sweep"

// ── Safe context: redacted, hashed, coarsened ──
metadata String? // redacted JSON; never secrets, never raw PII
actorIpHash String? // keyed SHA-256 of the request IP, never the raw IP
userAgentFamily String? // e.g. "Chrome", "Android"; never the raw UA

occurredAt DateTime @default(now())

@@index([targetType, targetId, occurredAt])
@@index([actorType, actorId, occurredAt])
@@index([action, occurredAt])
@@index([requestId])
@@index([occurredAt])
@@map("audit_events")
}

/// Superseded by AuditEvent, and retained only for the trail written before the
/// data-lifecycle policy landed. Unlike AuditEvent it holds a raw IP and
/// User-Agent, so erasure scrubs those columns.
model AuditLog {
id String @id @default(uuid())
userId String?
Expand Down Expand Up @@ -276,10 +337,18 @@ model Module {
assetIssuer String?
/// Stellar network: "testnet" or "mainnet".
assetNetwork String @default("testnet")
/// Archive (soft delete). Withdrawn content is archived, never deleted:
/// completions and credentials reference the module a learner actually took.
/// Reads exclude archived rows by default — see src/audit/archive.ts.
archivedAt DateTime?
archivedById String?
archivedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completions Completion[]
credentials Credential[]

@@index([archivedAt])
}

model Completion {
Expand Down Expand Up @@ -339,9 +408,16 @@ model ReferralCode {
code String @unique
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Archive (soft delete). Retiring a code archives it so the Referral rows
/// pointing at it keep a valid referent. Reads exclude archived rows by
/// default — see src/audit/archive.ts.
archivedAt DateTime?
archivedById String?
archivedReason String?
createdAt DateTime @default(now())
referrals Referral[]

@@index([archivedAt])
@@map("referral_codes")
}

Expand Down Expand Up @@ -390,9 +466,17 @@ model WebhookEndpoint {
description String?
isActive Boolean @default(true)
events String // Comma-separated list of events: "module.completed,reward.issued"
/// Archive (soft delete), distinct from isActive: isActive pauses delivery,
/// archiving retires the endpoint while its delivery history keeps a valid
/// referent. Reads exclude archived rows by default — see src/audit/archive.ts.
archivedAt DateTime?
archivedById String?
archivedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deliveries WebhookDelivery[]

@@index([archivedAt])
}

model WebhookDelivery {
Expand Down Expand Up @@ -616,8 +700,16 @@ model Avatar {
replacedBy Avatar? @relation("AvatarReplacement", fields: [replacedById], references: [id])
replacements Avatar[] @relation("AvatarReplacement")
variants AvatarVariant[]
/// Archive (soft delete), distinct from replacedAt: replacedAt records that a
/// newer avatar superseded this one, archivedAt records that it was withdrawn
/// (by the learner or by moderation). Reads exclude archived rows by default —
/// see src/audit/archive.ts.
archivedAt DateTime?
archivedById String?
archivedReason String?

@@index([userId, status])
@@index([archivedAt])
@@map("avatars")
}

Expand Down
Loading
Loading