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
37 changes: 37 additions & 0 deletions db/migrations/000001_bootstrap.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
--
-- SoftlaneIT licenses this file to you under the Apache License,
-- Version 2.0 (the "LICENSE"); you may not use this file except
-- in compliance with the LICENSE.
-- You may obtain a copy of the LICENSE at
--
-- https://softlaneit.com/LICENSE.txt
--
-- Unless required by applicable law or agreed to in writing,
-- software distributed under the LICENSE is distributed on an
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-- KIND, either express or implied. See the LICENSE for the
-- specific language governing permissions and limitations
-- under the LICENSE.

--
-- 000001_bootstrap.down
--
-- Reverses the bootstrap migration. This is intentionally conservative:
-- we drop functions and revoke grants but do NOT drop the extensions because
-- other databases on the same cluster may depend on them, and extension drops
-- require superuser rights that sf_app does not have.
--

REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM sf_app;
REVOKE SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM sf_app;
REVOKE USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public FROM sf_app;
REVOKE USAGE ON SCHEMA public FROM sf_app;
REVOKE CONNECT ON DATABASE serviceforge FROM sf_app;

DROP FUNCTION IF EXISTS set_tenant_context(UUID);
DROP FUNCTION IF EXISTS update_updated_at();

-- NOTE: We intentionally leave the sf_app role intact because dropping a role
-- requires that it owns no objects, which we cannot guarantee in a down migration.
-- Run `DROP ROLE sf_app;` manually after confirming no objects are owned.
99 changes: 99 additions & 0 deletions db/migrations/000001_bootstrap.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
--
-- SoftlaneIT licenses this file to you under the Apache License,
-- Version 2.0 (the "LICENSE"); you may not use this file except
-- in compliance with the LICENSE.
-- You may obtain a copy of the LICENSE at
--
-- https://softlaneit.com/LICENSE.txt
--
-- Unless required by applicable law or agreed to in writing,
-- software distributed under the LICENSE is distributed on an
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-- KIND, either express or implied. See the LICENSE for the
-- specific language governing permissions and limitations
-- under the LICENSE.

--
-- 000001_bootstrap.up
--
-- One-time cluster-level setup that every subsequent migration depends on:
-- • PostgreSQL extensions
-- • The update_updated_at() trigger function (shared by all tables with an
-- updated_at column — avoids duplicating it per-table)
-- • The set_tenant_context() helper (called by services at the start of
-- every DB transaction to arm Row-Level Security)
-- • The sf_app role (services connect as this role, never as the superuser)
--

-- ── Extensions ────
-- uuid_generate_v4() is used as the default for all primary-key columns.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- gen_salt / crypt for bcrypt-style hashing (used by auth-service for API
-- key storage as a defence-in-depth measure alongside SHA-256 lookup).
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Trigram index support — used for fast LIKE / similarity searches on
-- tenant slugs and customer references.
CREATE EXTENSION IF NOT EXISTS "pg_trgm";

-- ── Shared trigger function ─
-- Automatically maintains the updated_at column on any table that attaches
-- this trigger. Using a single shared function rather than per-table copies
-- keeps the schema DRY and avoids drift.
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;

-- ── Tenant-context helper ───
-- Services call this at the beginning of every transaction (or connection pool
-- checkout) to set the GUC that RLS policies read. Using a transaction-scoped
-- setting (is_local = TRUE) means the value is automatically cleared when the
-- transaction ends, so a pooled connection can never leak one tenant's context
-- into the next request.
--
-- Usage (from Go with pgx):
-- _, err = tx.Exec(ctx, "SELECT set_tenant_context($1)", tenantID)
CREATE OR REPLACE FUNCTION set_tenant_context(p_tenant_id UUID)
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM set_config('app.current_tenant_id', p_tenant_id::TEXT, TRUE);
END;
$$;

-- ── Application role ───
-- All five services connect as sf_app — never as the database owner. This
-- limits blast radius: a compromised service cannot DROP TABLE or ALTER ROLE.
-- The password is overridden in production via a secrets manager; this default
-- is safe only for local development.
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'sf_app') THEN
CREATE ROLE sf_app LOGIN PASSWORD 'sf_app_dev_only';
END IF;
END;
$$;

GRANT CONNECT ON DATABASE serviceforge TO sf_app;
GRANT USAGE ON SCHEMA public TO sf_app;

-- Grant DML on all *existing* tables now and on all *future* tables created by
-- the migration runner (which runs as the owner, not as sf_app).
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sf_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO sf_app;

ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO sf_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO sf_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT EXECUTE ON FUNCTIONS TO sf_app;
24 changes: 24 additions & 0 deletions db/migrations/000002_create_tenants.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
--
-- SoftlaneIT licenses this file to you under the Apache License,
-- Version 2.0 (the "LICENSE"); you may not use this file except
-- in compliance with the LICENSE.
-- You may obtain a copy of the LICENSE at
--
-- https://softlaneit.com/LICENSE.txt
--
-- Unless required by applicable law or agreed to in writing,
-- software distributed under the LICENSE is distributed on an
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-- KIND, either express or implied. See the LICENSE for the
-- specific language governing permissions and limitations
-- under the LICENSE.

--
-- 000002_create_tenants.down
--

-- CASCADE drops all dependent objects (FK constraints in child tables) before
-- removing the tenants table. This is intentional for a down migration:
-- rolling back migration 2 implies rolling back everything that depends on it.
DROP TABLE IF EXISTS tenants CASCADE;
81 changes: 81 additions & 0 deletions db/migrations/000002_create_tenants.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
--
-- SoftlaneIT licenses this file to you under the Apache License,
-- Version 2.0 (the "LICENSE"); you may not use this file except
-- in compliance with the LICENSE.
-- You may obtain a copy of the LICENSE at
--
-- https://softlaneit.com/LICENSE.txt
--
-- Unless required by applicable law or agreed to in writing,
-- software distributed under the LICENSE is distributed on an
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-- KIND, either express or implied. See the LICENSE for the
-- specific language governing permissions and limitations
-- under the LICENSE.

--
-- 000002_create_tenants.up
--
-- The tenants table is the root of the entire multi-tenancy hierarchy.
-- Every other tenant-scoped table has a tenant_id FK pointing here.
--
-- Design notes:
-- • `slug` is the human-readable, URL-safe tenant identifier used in API
-- paths and the management UI. It is immutable after creation.
-- • `plan` controls which modules a tenant may subscribe to and what rate
-- limits and quotas apply. Values are enforced by a CHECK constraint so
-- any attempt to insert an invalid plan fails at the DB layer regardless
-- of application logic.
-- • `status` follows a simple lifecycle: active → suspended → deleted.
-- Rows are never hard-deleted; deleted status satisfies audit requirements
-- while allowing foreign-key integrity to hold.
-- • `settings` is a free-form JSONB bag for platform-level settings that do
-- not warrant dedicated columns (e.g. custom branding colours, support
-- email). Module-specific configuration lives in module_configs, not here.
-- • RLS is NOT enabled on tenants itself — tenant_service reads and writes
-- this table using the superuser / migration role and is the only service
-- authorised to do so. All other services work through tenant_id FKs.
--

CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
-- Immutable, URL-safe identifier: lowercase letters, digits, hyphens.
-- Max 100 chars keeps it usable in DNS labels and S3 prefixes.
slug VARCHAR(100) NOT NULL,
-- Billing plan determines quotas and available modules.
plan VARCHAR(20) NOT NULL DEFAULT 'starter'
CHECK (plan IN ('starter', 'pro', 'enterprise')),
-- Lifecycle state.
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'deleted')),
-- Platform-level settings (branding, contact, misc flags).
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Soft-delete timestamp; NULL means the tenant is not deleted.
deleted_at TIMESTAMPTZ
);

-- ── Constraints ───
-- Slugs must be unique among non-deleted tenants only. A deleted tenant's
-- slug should be reusable so that an organisation can re-register after
-- closure. A partial unique index achieves this cleanly.
CREATE UNIQUE INDEX uq_tenants_slug_active
ON tenants (slug)
WHERE deleted_at IS NULL;

-- ── Indices
-- Management UI lists tenants filtered by status; this supports O(1) lookup.
CREATE INDEX idx_tenants_status ON tenants (status);

-- Trigram index on slug enables fast ILIKE searches in the management UI
-- ("find tenant whose slug contains 'acme'").
CREATE INDEX idx_tenants_slug_trgm ON tenants USING GIN (slug gin_trgm_ops);

-- ── Trigger
CREATE TRIGGER trg_tenants_updated_at
BEFORE UPDATE ON tenants
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
21 changes: 21 additions & 0 deletions db/migrations/000003_create_api_keys.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
--
-- SoftlaneIT licenses this file to you under the Apache License,
-- Version 2.0 (the "LICENSE"); you may not use this file except
-- in compliance with the LICENSE.
-- You may obtain a copy of the LICENSE at
--
-- https://softlaneit.com/LICENSE.txt
--
-- Unless required by applicable law or agreed to in writing,
-- software distributed under the LICENSE is distributed on an
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-- KIND, either express or implied. See the LICENSE for the
-- specific language governing permissions and limitations
-- under the LICENSE.

-- ──
-- 000003_create_api_keys.down
-- ──

DROP TABLE IF EXISTS api_keys CASCADE;
107 changes: 107 additions & 0 deletions db/migrations/000003_create_api_keys.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
-- Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved.
--
-- SoftlaneIT licenses this file to you under the Apache License,
-- Version 2.0 (the "LICENSE"); you may not use this file except
-- in compliance with the LICENSE.
-- You may obtain a copy of the LICENSE at
--
-- https://softlaneit.com/LICENSE.txt
--
-- Unless required by applicable law or agreed to in writing,
-- software distributed under the LICENSE is distributed on an
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-- KIND, either express or implied. See the LICENSE for the
-- specific language governing permissions and limitations
-- under the LICENSE.

-- ──
-- 000003_create_api_keys.up
--
-- API keys are the primary authentication credential for tenant applications.
-- A tenant may hold multiple keys (e.g. one per environment, one per service
-- integration).
--
-- Security model:
-- • The raw key is generated once by auth-service and shown to the developer
-- exactly once; it is never stored.
-- • `key_hash` stores a SHA-256 hex digest of the raw key. Fast enough for
-- O(1) lookup by hash; no need for bcrypt-level cost here because keys are
-- high-entropy random strings (not passwords).
-- • `key_prefix` stores the first 8 characters of the raw key (e.g. "sf_live_")
-- for display in the management UI so developers can identify which key
-- they are looking at without exposing the full secret.
--
-- Rotation:
-- Keys are never mutated after creation. To rotate, the developer creates a
-- new key, updates their application, then revokes the old key by setting
-- revoked_at. This gives a zero-downtime rotation window.
--
-- Row-Level Security:
-- api_keys are scoped to a tenant. The RLS policy enforces that a service
-- connected as sf_app can only see rows belonging to the tenant whose ID was
-- set via set_tenant_context(). The gateway's auth middleware bypasses RLS
-- during the key-lookup step by calling the function as a superuser; once the
-- tenant is identified it sets the context and all subsequent queries are
-- automatically scoped.
-- ──

CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Human-readable label set by the developer (e.g. "mobile-app-prod").
name VARCHAR(255) NOT NULL,
-- SHA-256 hex digest of the raw key. Used for fast O(1) lookup.
key_hash CHAR(64) NOT NULL,
-- First 8 characters of the raw key for UI display (e.g. "sf_live_a").
key_prefix VARCHAR(12) NOT NULL,
-- Environment this key is valid in.
environment VARCHAR(20) NOT NULL DEFAULT 'sandbox'
CHECK (environment IN ('sandbox', 'production')),
-- Optional scope restriction: empty array = access to all subscribed modules.
-- Non-empty = only the listed module names (e.g. '{"booking","payment"}').
module_scope TEXT[] NOT NULL DEFAULT '{}',
-- Lifecycle state. Revoked keys must be retained for audit purposes.
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'revoked')),
-- Populated on successful authentication for anomaly detection.
last_used_at TIMESTAMPTZ,
-- Optional expiry. NULL = no expiry.
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Soft-revoke timestamp; NULL = key is not revoked.
revoked_at TIMESTAMPTZ,
-- Ensure key_hash is unique across all tenants (prevents hash collisions
-- from different tenants accidentally sharing a credential).
CONSTRAINT uq_api_keys_hash UNIQUE (key_hash)
);

-- ── Indices ──
-- The gateway performs key lookup by hash on every authenticated request;
-- this must be a fast index scan.
CREATE UNIQUE INDEX idx_api_keys_hash ON api_keys (key_hash);

-- Lists all keys for a tenant in the management UI.
CREATE INDEX idx_api_keys_tenant ON api_keys (tenant_id);

-- Prefix lookup for display deduplication.
CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys (key_prefix);

-- ── Row-Level Security ─
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;

-- The SELECT / INSERT / UPDATE / DELETE policy:
-- • SELECT: a connection can only read keys that belong to its current tenant.
-- • INSERT: tenant_id must equal the current tenant (the CHECK option enforces
-- this on write so a service cannot accidentally insert a key for another
-- tenant).
-- • The gateway's superuser connection that performs the initial key-lookup
-- (before tenant context is known) must use SET LOCAL to temporarily bypass
-- RLS — it should do so only for the hash lookup, then immediately set the
-- tenant context for the rest of the transaction.
CREATE POLICY api_keys_tenant_isolation ON api_keys
USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID);

-- ── Trigger ──
-- api_keys are immutable after creation; there is no updated_at column and
-- therefore no trigger needed here.
Loading
Loading