Skip to content

Commit b1a6d55

Browse files
authored
Merge pull request #4 from SoftLaneIT/feat/db-migrations
feat(db): add golang-migrate SQL migrations and wire docker-compose runner
2 parents 7443f98 + 3182c3e commit b1a6d55

14 files changed

Lines changed: 945 additions & 12 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
2+
--
3+
-- SoftlaneIT licenses this file to you under the Apache License,
4+
-- Version 2.0 (the "LICENSE"); you may not use this file except
5+
-- in compliance with the LICENSE.
6+
-- You may obtain a copy of the LICENSE at
7+
--
8+
-- https://softlaneit.com/LICENSE.txt
9+
--
10+
-- Unless required by applicable law or agreed to in writing,
11+
-- software distributed under the LICENSE is distributed on an
12+
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13+
-- KIND, either express or implied. See the LICENSE for the
14+
-- specific language governing permissions and limitations
15+
-- under the LICENSE.
16+
17+
--
18+
-- 000001_bootstrap.down
19+
--
20+
-- Reverses the bootstrap migration. This is intentionally conservative:
21+
-- we drop functions and revoke grants but do NOT drop the extensions because
22+
-- other databases on the same cluster may depend on them, and extension drops
23+
-- require superuser rights that sf_app does not have.
24+
--
25+
26+
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM sf_app;
27+
REVOKE SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM sf_app;
28+
REVOKE USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public FROM sf_app;
29+
REVOKE USAGE ON SCHEMA public FROM sf_app;
30+
REVOKE CONNECT ON DATABASE serviceforge FROM sf_app;
31+
32+
DROP FUNCTION IF EXISTS set_tenant_context(UUID);
33+
DROP FUNCTION IF EXISTS update_updated_at();
34+
35+
-- NOTE: We intentionally leave the sf_app role intact because dropping a role
36+
-- requires that it owns no objects, which we cannot guarantee in a down migration.
37+
-- Run `DROP ROLE sf_app;` manually after confirming no objects are owned.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
2+
--
3+
-- SoftlaneIT licenses this file to you under the Apache License,
4+
-- Version 2.0 (the "LICENSE"); you may not use this file except
5+
-- in compliance with the LICENSE.
6+
-- You may obtain a copy of the LICENSE at
7+
--
8+
-- https://softlaneit.com/LICENSE.txt
9+
--
10+
-- Unless required by applicable law or agreed to in writing,
11+
-- software distributed under the LICENSE is distributed on an
12+
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13+
-- KIND, either express or implied. See the LICENSE for the
14+
-- specific language governing permissions and limitations
15+
-- under the LICENSE.
16+
17+
--
18+
-- 000001_bootstrap.up
19+
--
20+
-- One-time cluster-level setup that every subsequent migration depends on:
21+
-- • PostgreSQL extensions
22+
-- • The update_updated_at() trigger function (shared by all tables with an
23+
-- updated_at column — avoids duplicating it per-table)
24+
-- • The set_tenant_context() helper (called by services at the start of
25+
-- every DB transaction to arm Row-Level Security)
26+
-- • The sf_app role (services connect as this role, never as the superuser)
27+
--
28+
29+
-- ── Extensions ────
30+
-- uuid_generate_v4() is used as the default for all primary-key columns.
31+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
32+
33+
-- gen_salt / crypt for bcrypt-style hashing (used by auth-service for API
34+
-- key storage as a defence-in-depth measure alongside SHA-256 lookup).
35+
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
36+
37+
-- Trigram index support — used for fast LIKE / similarity searches on
38+
-- tenant slugs and customer references.
39+
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
40+
41+
-- ── Shared trigger function ─
42+
-- Automatically maintains the updated_at column on any table that attaches
43+
-- this trigger. Using a single shared function rather than per-table copies
44+
-- keeps the schema DRY and avoids drift.
45+
CREATE OR REPLACE FUNCTION update_updated_at()
46+
RETURNS TRIGGER
47+
LANGUAGE plpgsql
48+
AS $$
49+
BEGIN
50+
NEW.updated_at = NOW();
51+
RETURN NEW;
52+
END;
53+
$$;
54+
55+
-- ── Tenant-context helper ───
56+
-- Services call this at the beginning of every transaction (or connection pool
57+
-- checkout) to set the GUC that RLS policies read. Using a transaction-scoped
58+
-- setting (is_local = TRUE) means the value is automatically cleared when the
59+
-- transaction ends, so a pooled connection can never leak one tenant's context
60+
-- into the next request.
61+
--
62+
-- Usage (from Go with pgx):
63+
-- _, err = tx.Exec(ctx, "SELECT set_tenant_context($1)", tenantID)
64+
CREATE OR REPLACE FUNCTION set_tenant_context(p_tenant_id UUID)
65+
RETURNS VOID
66+
LANGUAGE plpgsql
67+
AS $$
68+
BEGIN
69+
PERFORM set_config('app.current_tenant_id', p_tenant_id::TEXT, TRUE);
70+
END;
71+
$$;
72+
73+
-- ── Application role ───
74+
-- All five services connect as sf_app — never as the database owner. This
75+
-- limits blast radius: a compromised service cannot DROP TABLE or ALTER ROLE.
76+
-- The password is overridden in production via a secrets manager; this default
77+
-- is safe only for local development.
78+
DO $$
79+
BEGIN
80+
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'sf_app') THEN
81+
CREATE ROLE sf_app LOGIN PASSWORD 'sf_app_dev_only';
82+
END IF;
83+
END;
84+
$$;
85+
86+
GRANT CONNECT ON DATABASE serviceforge TO sf_app;
87+
GRANT USAGE ON SCHEMA public TO sf_app;
88+
89+
-- Grant DML on all *existing* tables now and on all *future* tables created by
90+
-- the migration runner (which runs as the owner, not as sf_app).
91+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sf_app;
92+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO sf_app;
93+
94+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
95+
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO sf_app;
96+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
97+
GRANT USAGE, SELECT ON SEQUENCES TO sf_app;
98+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
99+
GRANT EXECUTE ON FUNCTIONS TO sf_app;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
2+
--
3+
-- SoftlaneIT licenses this file to you under the Apache License,
4+
-- Version 2.0 (the "LICENSE"); you may not use this file except
5+
-- in compliance with the LICENSE.
6+
-- You may obtain a copy of the LICENSE at
7+
--
8+
-- https://softlaneit.com/LICENSE.txt
9+
--
10+
-- Unless required by applicable law or agreed to in writing,
11+
-- software distributed under the LICENSE is distributed on an
12+
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13+
-- KIND, either express or implied. See the LICENSE for the
14+
-- specific language governing permissions and limitations
15+
-- under the LICENSE.
16+
17+
--
18+
-- 000002_create_tenants.down
19+
--
20+
21+
-- CASCADE drops all dependent objects (FK constraints in child tables) before
22+
-- removing the tenants table. This is intentional for a down migration:
23+
-- rolling back migration 2 implies rolling back everything that depends on it.
24+
DROP TABLE IF EXISTS tenants CASCADE;
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
2+
--
3+
-- SoftlaneIT licenses this file to you under the Apache License,
4+
-- Version 2.0 (the "LICENSE"); you may not use this file except
5+
-- in compliance with the LICENSE.
6+
-- You may obtain a copy of the LICENSE at
7+
--
8+
-- https://softlaneit.com/LICENSE.txt
9+
--
10+
-- Unless required by applicable law or agreed to in writing,
11+
-- software distributed under the LICENSE is distributed on an
12+
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13+
-- KIND, either express or implied. See the LICENSE for the
14+
-- specific language governing permissions and limitations
15+
-- under the LICENSE.
16+
17+
--
18+
-- 000002_create_tenants.up
19+
--
20+
-- The tenants table is the root of the entire multi-tenancy hierarchy.
21+
-- Every other tenant-scoped table has a tenant_id FK pointing here.
22+
--
23+
-- Design notes:
24+
-- • `slug` is the human-readable, URL-safe tenant identifier used in API
25+
-- paths and the management UI. It is immutable after creation.
26+
-- • `plan` controls which modules a tenant may subscribe to and what rate
27+
-- limits and quotas apply. Values are enforced by a CHECK constraint so
28+
-- any attempt to insert an invalid plan fails at the DB layer regardless
29+
-- of application logic.
30+
-- • `status` follows a simple lifecycle: active → suspended → deleted.
31+
-- Rows are never hard-deleted; deleted status satisfies audit requirements
32+
-- while allowing foreign-key integrity to hold.
33+
-- • `settings` is a free-form JSONB bag for platform-level settings that do
34+
-- not warrant dedicated columns (e.g. custom branding colours, support
35+
-- email). Module-specific configuration lives in module_configs, not here.
36+
-- • RLS is NOT enabled on tenants itself — tenant_service reads and writes
37+
-- this table using the superuser / migration role and is the only service
38+
-- authorised to do so. All other services work through tenant_id FKs.
39+
--
40+
41+
CREATE TABLE tenants (
42+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
43+
name VARCHAR(255) NOT NULL,
44+
-- Immutable, URL-safe identifier: lowercase letters, digits, hyphens.
45+
-- Max 100 chars keeps it usable in DNS labels and S3 prefixes.
46+
slug VARCHAR(100) NOT NULL,
47+
-- Billing plan determines quotas and available modules.
48+
plan VARCHAR(20) NOT NULL DEFAULT 'starter'
49+
CHECK (plan IN ('starter', 'pro', 'enterprise')),
50+
-- Lifecycle state.
51+
status VARCHAR(20) NOT NULL DEFAULT 'active'
52+
CHECK (status IN ('active', 'suspended', 'deleted')),
53+
-- Platform-level settings (branding, contact, misc flags).
54+
settings JSONB NOT NULL DEFAULT '{}',
55+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
56+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
57+
-- Soft-delete timestamp; NULL means the tenant is not deleted.
58+
deleted_at TIMESTAMPTZ
59+
);
60+
61+
-- ── Constraints ───
62+
-- Slugs must be unique among non-deleted tenants only. A deleted tenant's
63+
-- slug should be reusable so that an organisation can re-register after
64+
-- closure. A partial unique index achieves this cleanly.
65+
CREATE UNIQUE INDEX uq_tenants_slug_active
66+
ON tenants (slug)
67+
WHERE deleted_at IS NULL;
68+
69+
-- ── Indices
70+
-- Management UI lists tenants filtered by status; this supports O(1) lookup.
71+
CREATE INDEX idx_tenants_status ON tenants (status);
72+
73+
-- Trigram index on slug enables fast ILIKE searches in the management UI
74+
-- ("find tenant whose slug contains 'acme'").
75+
CREATE INDEX idx_tenants_slug_trgm ON tenants USING GIN (slug gin_trgm_ops);
76+
77+
-- ── Trigger
78+
CREATE TRIGGER trg_tenants_updated_at
79+
BEFORE UPDATE ON tenants
80+
FOR EACH ROW
81+
EXECUTE FUNCTION update_updated_at();
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
2+
--
3+
-- SoftlaneIT licenses this file to you under the Apache License,
4+
-- Version 2.0 (the "LICENSE"); you may not use this file except
5+
-- in compliance with the LICENSE.
6+
-- You may obtain a copy of the LICENSE at
7+
--
8+
-- https://softlaneit.com/LICENSE.txt
9+
--
10+
-- Unless required by applicable law or agreed to in writing,
11+
-- software distributed under the LICENSE is distributed on an
12+
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13+
-- KIND, either express or implied. See the LICENSE for the
14+
-- specific language governing permissions and limitations
15+
-- under the LICENSE.
16+
17+
-- ──
18+
-- 000003_create_api_keys.down
19+
-- ──
20+
21+
DROP TABLE IF EXISTS api_keys CASCADE;
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
2+
--
3+
-- SoftlaneIT licenses this file to you under the Apache License,
4+
-- Version 2.0 (the "LICENSE"); you may not use this file except
5+
-- in compliance with the LICENSE.
6+
-- You may obtain a copy of the LICENSE at
7+
--
8+
-- https://softlaneit.com/LICENSE.txt
9+
--
10+
-- Unless required by applicable law or agreed to in writing,
11+
-- software distributed under the LICENSE is distributed on an
12+
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
13+
-- KIND, either express or implied. See the LICENSE for the
14+
-- specific language governing permissions and limitations
15+
-- under the LICENSE.
16+
17+
-- ──
18+
-- 000003_create_api_keys.up
19+
--
20+
-- API keys are the primary authentication credential for tenant applications.
21+
-- A tenant may hold multiple keys (e.g. one per environment, one per service
22+
-- integration).
23+
--
24+
-- Security model:
25+
-- • The raw key is generated once by auth-service and shown to the developer
26+
-- exactly once; it is never stored.
27+
-- • `key_hash` stores a SHA-256 hex digest of the raw key. Fast enough for
28+
-- O(1) lookup by hash; no need for bcrypt-level cost here because keys are
29+
-- high-entropy random strings (not passwords).
30+
-- • `key_prefix` stores the first 8 characters of the raw key (e.g. "sf_live_")
31+
-- for display in the management UI so developers can identify which key
32+
-- they are looking at without exposing the full secret.
33+
--
34+
-- Rotation:
35+
-- Keys are never mutated after creation. To rotate, the developer creates a
36+
-- new key, updates their application, then revokes the old key by setting
37+
-- revoked_at. This gives a zero-downtime rotation window.
38+
--
39+
-- Row-Level Security:
40+
-- api_keys are scoped to a tenant. The RLS policy enforces that a service
41+
-- connected as sf_app can only see rows belonging to the tenant whose ID was
42+
-- set via set_tenant_context(). The gateway's auth middleware bypasses RLS
43+
-- during the key-lookup step by calling the function as a superuser; once the
44+
-- tenant is identified it sets the context and all subsequent queries are
45+
-- automatically scoped.
46+
-- ──
47+
48+
CREATE TABLE api_keys (
49+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
50+
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
51+
-- Human-readable label set by the developer (e.g. "mobile-app-prod").
52+
name VARCHAR(255) NOT NULL,
53+
-- SHA-256 hex digest of the raw key. Used for fast O(1) lookup.
54+
key_hash CHAR(64) NOT NULL,
55+
-- First 8 characters of the raw key for UI display (e.g. "sf_live_a").
56+
key_prefix VARCHAR(12) NOT NULL,
57+
-- Environment this key is valid in.
58+
environment VARCHAR(20) NOT NULL DEFAULT 'sandbox'
59+
CHECK (environment IN ('sandbox', 'production')),
60+
-- Optional scope restriction: empty array = access to all subscribed modules.
61+
-- Non-empty = only the listed module names (e.g. '{"booking","payment"}').
62+
module_scope TEXT[] NOT NULL DEFAULT '{}',
63+
-- Lifecycle state. Revoked keys must be retained for audit purposes.
64+
status VARCHAR(20) NOT NULL DEFAULT 'active'
65+
CHECK (status IN ('active', 'revoked')),
66+
-- Populated on successful authentication for anomaly detection.
67+
last_used_at TIMESTAMPTZ,
68+
-- Optional expiry. NULL = no expiry.
69+
expires_at TIMESTAMPTZ,
70+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
71+
-- Soft-revoke timestamp; NULL = key is not revoked.
72+
revoked_at TIMESTAMPTZ,
73+
-- Ensure key_hash is unique across all tenants (prevents hash collisions
74+
-- from different tenants accidentally sharing a credential).
75+
CONSTRAINT uq_api_keys_hash UNIQUE (key_hash)
76+
);
77+
78+
-- ── Indices ──
79+
-- The gateway performs key lookup by hash on every authenticated request;
80+
-- this must be a fast index scan.
81+
CREATE UNIQUE INDEX idx_api_keys_hash ON api_keys (key_hash);
82+
83+
-- Lists all keys for a tenant in the management UI.
84+
CREATE INDEX idx_api_keys_tenant ON api_keys (tenant_id);
85+
86+
-- Prefix lookup for display deduplication.
87+
CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys (key_prefix);
88+
89+
-- ── Row-Level Security ─
90+
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
91+
92+
-- The SELECT / INSERT / UPDATE / DELETE policy:
93+
-- • SELECT: a connection can only read keys that belong to its current tenant.
94+
-- • INSERT: tenant_id must equal the current tenant (the CHECK option enforces
95+
-- this on write so a service cannot accidentally insert a key for another
96+
-- tenant).
97+
-- • The gateway's superuser connection that performs the initial key-lookup
98+
-- (before tenant context is known) must use SET LOCAL to temporarily bypass
99+
-- RLS — it should do so only for the hash lookup, then immediately set the
100+
-- tenant context for the rest of the transaction.
101+
CREATE POLICY api_keys_tenant_isolation ON api_keys
102+
USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID)
103+
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID);
104+
105+
-- ── Trigger ──
106+
-- api_keys are immutable after creation; there is no updated_at column and
107+
-- therefore no trigger needed here.

0 commit comments

Comments
 (0)