From dfd566f0d014e3cbeba767464a0420ce263a8afb Mon Sep 17 00:00:00 2001 From: Lewis Injai Date: Wed, 3 Jun 2026 22:18:43 +0300 Subject: [PATCH 1/2] feat: add core database schema migrations --- migrations/000002_core_schema.down.sql | 23 ++ migrations/000002_core_schema.up.sql | 414 +++++++++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 migrations/000002_core_schema.down.sql create mode 100644 migrations/000002_core_schema.up.sql diff --git a/migrations/000002_core_schema.down.sql b/migrations/000002_core_schema.down.sql new file mode 100644 index 0000000..3dd9a48 --- /dev/null +++ b/migrations/000002_core_schema.down.sql @@ -0,0 +1,23 @@ +-- ============================================================================= +-- Migration 000002: Reversal +-- ============================================================================= +-- Drops all tables created in 000002_core_schema.up.sql. +-- Tables are dropped in reverse dependency order — most dependent first. +-- ============================================================================= + +DROP TABLE IF EXISTS notification_logs; +DROP TABLE IF EXISTS audit_events; +DROP TABLE IF EXISTS breaches; +DROP TABLE IF EXISTS consumer_responses; +DROP TABLE IF EXISTS proposal_changes; +DROP TABLE IF EXISTS proposals; +DROP TABLE IF EXISTS consumer_registrations; +DROP TABLE IF EXISTS contract_columns; +DROP TABLE IF EXISTS contract_versions; +DROP TABLE IF EXISTS contracts; +DROP TABLE IF EXISTS database_connections; +DROP TABLE IF EXISTS api_keys; +DROP TABLE IF EXISTS team_members; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS teams; +DROP TABLE IF EXISTS organizations; \ No newline at end of file diff --git a/migrations/000002_core_schema.up.sql b/migrations/000002_core_schema.up.sql new file mode 100644 index 0000000..15b06df --- /dev/null +++ b/migrations/000002_core_schema.up.sql @@ -0,0 +1,414 @@ +-- ============================================================================= +-- Migration 000002: Core schema +-- ============================================================================= +-- Creates all sixteen tables that make up the Ratify data model. +-- Tables are created in dependency order: referenced tables first. +-- +-- Design decisions applied throughout: +-- - All primary keys are UUIDs generated by gen_random_uuid() +-- - All timestamps use TIMESTAMPTZ (stored in UTC, timezone-aware) +-- - All foreign keys use ON DELETE RESTRICT unless stated otherwise +-- - Indexes on all foreign key columns (PostgreSQL does not create these +-- automatically — missing FK indexes cause slow JOIN queries) +-- - Encrypted values stored as TEXT (AES-256-GCM produces base64 output) +-- - JSONB used for structured data that varies by row (snapshots, values) +-- ============================================================================= + + +-- ----------------------------------------------------------------------------- +-- organizations +-- The top-level tenant. Every other entity belongs to an organization. +-- Stores SMTP credentials for sending proposal and breach notifications. +-- Credentials are encrypted at rest using AES-256-GCM. +-- ----------------------------------------------------------------------------- +CREATE TABLE organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + smtp_host TEXT, + smtp_port INTEGER, + smtp_username TEXT, + smtp_password_encrypted TEXT, + smtp_from_address TEXT, + -- When true: additive-only proposals auto-approve if no consumer responds + -- before the deadline. Configurable per organisation as stated in the FRD. + auto_approve_additive BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + + +-- ----------------------------------------------------------------------------- +-- teams +-- Groups of users within an organisation. Every contract has exactly one +-- producer team. Contracts can have many consumer teams. +-- ----------------------------------------------------------------------------- +CREATE TABLE teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_teams_org_id ON teams(org_id); + + +-- ----------------------------------------------------------------------------- +-- users +-- Individual accounts within an organisation. A user can belong to multiple +-- teams via team_members. +-- ----------------------------------------------------------------------------- +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE RESTRICT, + email TEXT NOT NULL, + display_name TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_org_id ON users(org_id); +-- Email lookups happen on every login and API key verification. +CREATE UNIQUE INDEX idx_users_email_org ON users(email, org_id); + + +-- ----------------------------------------------------------------------------- +-- team_members +-- Join table connecting users to teams. A user can belong to multiple teams. +-- Uses a composite primary key instead of a surrogate UUID — there is no +-- meaningful reason to refer to a team membership by a single ID. +-- ----------------------------------------------------------------------------- +CREATE TABLE team_members ( + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (team_id, user_id) +); + +CREATE INDEX idx_team_members_user_id ON team_members(user_id); + + +-- ----------------------------------------------------------------------------- +-- api_keys +-- Authentication tokens for CLI and API access. The plaintext key is never +-- stored — only a bcrypt hash. key_prefix stores the first 8 characters of +-- the key so users can identify which key is which in the UI. +-- ----------------------------------------------------------------------------- +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + -- 'read' or 'read_write' — scoped access as specified in the tech stack doc + scope TEXT NOT NULL DEFAULT 'read', + is_active BOOLEAN NOT NULL DEFAULT true, + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_api_keys_user_id ON api_keys(user_id); +CREATE INDEX idx_api_keys_org_id ON api_keys(org_id); + + +-- ----------------------------------------------------------------------------- +-- database_connections +-- Registered PostgreSQL databases that Ratify monitors. Credentials are +-- encrypted at rest using AES-256-GCM. The tool only ever reads from these +-- databases — it never writes to them. +-- ----------------------------------------------------------------------------- +CREATE TABLE database_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE RESTRICT, + display_name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 5432, + database_name TEXT NOT NULL, + username TEXT NOT NULL, + password_encrypted TEXT NOT NULL, + ssl_enabled BOOLEAN NOT NULL DEFAULT true, + ssl_mode TEXT NOT NULL DEFAULT 'require', + -- 'active' | 'paused' | 'error' + status TEXT NOT NULL DEFAULT 'active', + last_tested_at TIMESTAMPTZ, + last_test_passed BOOLEAN, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_database_connections_org_id ON database_connections(org_id); + + +-- ----------------------------------------------------------------------------- +-- contracts +-- The central entity. A contract defines what a specific database table +-- promises: its structure, types, constraints, and freshness SLA. +-- +-- status: 'draft' | 'active' | 'deprecated' +-- Only one contract can be active for a given connection + table at a time. +-- This is enforced by the partial unique index below. +-- ----------------------------------------------------------------------------- +CREATE TABLE contracts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE RESTRICT, + connection_id UUID NOT NULL REFERENCES database_connections(id) ON DELETE RESTRICT, + producer_team_id UUID NOT NULL REFERENCES teams(id) ON DELETE RESTRICT, + display_name TEXT NOT NULL, + schema_name TEXT NOT NULL DEFAULT 'public', + table_name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'draft', + current_version INTEGER NOT NULL DEFAULT 0, + -- NULL means no freshness SLA is defined for this contract. + freshness_sla_hours INTEGER, + activated_at TIMESTAMPTZ, + deprecated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_contracts_org_id ON contracts(org_id); +CREATE INDEX idx_contracts_connection_id ON contracts(connection_id); +CREATE INDEX idx_contracts_producer_team_id ON contracts(producer_team_id); + +-- Enforce: only one active contract per table per connection. +-- Partial indexes only include rows matching the WHERE clause. +CREATE UNIQUE INDEX idx_contracts_one_active_per_table + ON contracts(connection_id, schema_name, table_name) + WHERE status = 'active'; + + +-- ----------------------------------------------------------------------------- +-- contract_versions +-- Every time a contract is activated or updated through the proposal workflow, +-- a new version is created. Previous versions are never deleted. +-- schema_snapshot stores the full column definitions as JSONB at the moment +-- of activation — this is the baseline used for breach detection. +-- ----------------------------------------------------------------------------- +CREATE TABLE contract_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE RESTRICT, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + version_number INTEGER NOT NULL, + schema_snapshot JSONB NOT NULL, + change_summary TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (contract_id, version_number) +); + +CREATE INDEX idx_contract_versions_contract_id ON contract_versions(contract_id); +CREATE INDEX idx_contract_versions_created_by ON contract_versions(created_by); + + +-- ----------------------------------------------------------------------------- +-- contract_columns +-- The individual column definitions within a contract version. +-- Stored separately from the JSONB snapshot so they can be queried +-- individually without parsing JSON in application code. +-- ----------------------------------------------------------------------------- +CREATE TABLE contract_columns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_version_id UUID NOT NULL REFERENCES contract_versions(id) ON DELETE CASCADE, + column_name TEXT NOT NULL, + data_type TEXT NOT NULL, + is_nullable BOOLEAN NOT NULL DEFAULT true, + is_primary_key BOOLEAN NOT NULL DEFAULT false, + description TEXT, + -- Optional value constraints: allowed ranges, enum values, regex patterns. + -- NULL when no constraints are defined. + constraints JSONB, + ordinal_position INTEGER NOT NULL +); + +CREATE INDEX idx_contract_columns_version_id ON contract_columns(contract_version_id); + + +-- ----------------------------------------------------------------------------- +-- consumer_registrations +-- Records which teams are registered as consumers of a contract. +-- Registration requires producer team approval. +-- status: 'pending' | 'approved' | 'deregistered' +-- ----------------------------------------------------------------------------- +CREATE TABLE consumer_registrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE RESTRICT, + consumer_team_id UUID NOT NULL REFERENCES teams(id) ON DELETE RESTRICT, + approved_by UUID REFERENCES users(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending', + usage_description TEXT, + registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + approved_at TIMESTAMPTZ, + -- A team can only register once per contract. + UNIQUE (contract_id, consumer_team_id) +); + +CREATE INDEX idx_consumer_registrations_contract_id ON consumer_registrations(contract_id); +CREATE INDEX idx_consumer_registrations_consumer_team_id ON consumer_registrations(consumer_team_id); +CREATE INDEX idx_consumer_registrations_approved_by ON consumer_registrations(approved_by); + + +-- ----------------------------------------------------------------------------- +-- proposals +-- A formal change proposal raised by the producer team before making schema +-- changes. Consumers are notified and must respond before the deadline. +-- status: 'open' | 'approved' | 'rejected' | 'withdrawn' +-- ----------------------------------------------------------------------------- +CREATE TABLE proposals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE RESTRICT, + raised_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + deadline TIMESTAMPTZ NOT NULL, + -- True when the proposal contains at least one breaking change. + -- Determined by the change classification engine, not set manually. + requires_unanimous_approval BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ +); + +CREATE INDEX idx_proposals_contract_id ON proposals(contract_id); +CREATE INDEX idx_proposals_raised_by ON proposals(raised_by); +-- Breach detection and deadline jobs query open proposals frequently. +CREATE INDEX idx_proposals_status ON proposals(status); + + +-- ----------------------------------------------------------------------------- +-- proposal_changes +-- The individual changes within a proposal. Each row is one change item — +-- add column, remove column, modify column type, etc. +-- change_type: 'add_column' | 'remove_column' | 'modify_column' | +-- 'rename_column' | 'add_constraint' | 'remove_constraint' | +-- 'modify_sla' +-- classification: 'breaking' | 'additive' | 'non_breaking' +-- ----------------------------------------------------------------------------- +CREATE TABLE proposal_changes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, + change_type TEXT NOT NULL, + classification TEXT NOT NULL, + affected_element TEXT NOT NULL, + -- JSONB allows storing different value shapes per change type without + -- forcing a fixed column structure for every possible change. + before_value JSONB, + after_value JSONB +); + +CREATE INDEX idx_proposal_changes_proposal_id ON proposal_changes(proposal_id); + + +-- ----------------------------------------------------------------------------- +-- consumer_responses +-- A consumer team's response to a proposal. Can be submitted via a secure +-- one-time link (no account required) or through the web UI / CLI. +-- response_type: 'accepted' | 'rejected' | 'migration_requested' +-- ----------------------------------------------------------------------------- +CREATE TABLE consumer_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE RESTRICT, + consumer_team_id UUID NOT NULL REFERENCES teams(id) ON DELETE RESTRICT, + -- SHA-256 hash of the one-time response token. Never stored in plaintext. + -- NULL when the response was submitted through the web UI or CLI. + response_token_hash TEXT UNIQUE, + response_type TEXT, + rejection_reason TEXT, + migration_days_requested INTEGER, + migration_notes TEXT, + -- True when submitted via the one-time email link, false via UI/CLI. + submitted_via_link BOOLEAN NOT NULL DEFAULT false, + token_expires_at TIMESTAMPTZ, + responded_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- One response record per consumer team per proposal. + -- The most recent response (by responded_at) is the one that counts. + UNIQUE (proposal_id, consumer_team_id) +); + +CREATE INDEX idx_consumer_responses_proposal_id ON consumer_responses(proposal_id); +CREATE INDEX idx_consumer_responses_consumer_team_id ON consumer_responses(consumer_team_id); + + +-- ----------------------------------------------------------------------------- +-- breaches +-- Detected divergences between the live database schema and an active +-- contract. Raised by the scheduled breach detection job. +-- breach_type: 'column_missing' | 'type_changed' | 'nullability_changed' | +-- 'table_missing' | 'freshness_violated' +-- status: 'open' | 'acknowledged' | 'resolved' +-- ----------------------------------------------------------------------------- +CREATE TABLE breaches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE RESTRICT, + breach_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + affected_element TEXT, + expected_value JSONB, + actual_value JSONB, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + acknowledged_at TIMESTAMPTZ, + resolved_at TIMESTAMPTZ, + acknowledged_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX idx_breaches_contract_id ON breaches(contract_id); +CREATE INDEX idx_breaches_acknowledged_by ON breaches(acknowledged_by); +-- Breach detection deduplication queries open breaches by contract + type. +CREATE INDEX idx_breaches_status ON breaches(status); +CREATE INDEX idx_breaches_contract_status ON breaches(contract_id, status); + + +-- ----------------------------------------------------------------------------- +-- audit_events +-- Append-only record of every significant event in the system. +-- This table has no updated_at column — records are never modified. +-- actor_type: 'user' | 'system' +-- ----------------------------------------------------------------------------- +CREATE TABLE audit_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE RESTRICT, + actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + actor_type TEXT NOT NULL DEFAULT 'user', + event_type TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + event_data JSONB NOT NULL DEFAULT '{}', + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_audit_events_org_id ON audit_events(org_id); +CREATE INDEX idx_audit_events_actor_user_id ON audit_events(actor_user_id); +-- Audit trail queries filter heavily by entity and time range. +CREATE INDEX idx_audit_events_entity ON audit_events(entity_type, entity_id); +CREATE INDEX idx_audit_events_occurred_at ON audit_events(occurred_at); + + +-- ----------------------------------------------------------------------------- +-- notification_logs +-- Records every notification attempt — email and Slack. Delivery status +-- is logged regardless of success or failure. +-- channel: 'email' | 'slack' +-- status: 'sent' | 'failed' +-- ----------------------------------------------------------------------------- +CREATE TABLE notification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE RESTRICT, + channel TEXT NOT NULL, + recipient TEXT NOT NULL, + subject TEXT, + status TEXT NOT NULL, + failure_reason TEXT, + related_entity_id UUID, + related_entity_type TEXT, + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_notification_logs_org_id ON notification_logs(org_id); +CREATE INDEX idx_notification_logs_related_entity ON notification_logs(related_entity_type, related_entity_id); \ No newline at end of file From 05acf29cb9ae713025b800212534afde0fef3bf1 Mon Sep 17 00:00:00 2001 From: Lewis Injai Date: Wed, 3 Jun 2026 22:40:51 +0300 Subject: [PATCH 2/2] security: bump go toolchain to 1.25.11 to patch stdlib vulnerabilities --- .github/workflows/ci.yml | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de6bdd3..e5827fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ concurrency: # Define versions once here. When upgrading Go or Node, change # the value here and it applies to every job automatically. env: - GO_VERSION: '1.25' + GO_VERSION: '1.25.11' NODE_VERSION: '20' GOLANGCI_LINT_VERSION: 'v2.4.0' MIGRATE_VERSION: 'v4.17.0' diff --git a/go.mod b/go.mod index 7448578..e51727d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ratifydata/ratify -go 1.25.0 +go 1.25.11 require ( github.com/go-chi/chi/v5 v5.3.0