diff --git a/db/migrations/000001_bootstrap.down.sql b/db/migrations/000001_bootstrap.down.sql new file mode 100644 index 0000000..d2c2183 --- /dev/null +++ b/db/migrations/000001_bootstrap.down.sql @@ -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. diff --git a/db/migrations/000001_bootstrap.up.sql b/db/migrations/000001_bootstrap.up.sql new file mode 100644 index 0000000..ec3b37b --- /dev/null +++ b/db/migrations/000001_bootstrap.up.sql @@ -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; diff --git a/db/migrations/000002_create_tenants.down.sql b/db/migrations/000002_create_tenants.down.sql new file mode 100644 index 0000000..47455a4 --- /dev/null +++ b/db/migrations/000002_create_tenants.down.sql @@ -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; diff --git a/db/migrations/000002_create_tenants.up.sql b/db/migrations/000002_create_tenants.up.sql new file mode 100644 index 0000000..0a9716e --- /dev/null +++ b/db/migrations/000002_create_tenants.up.sql @@ -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(); diff --git a/db/migrations/000003_create_api_keys.down.sql b/db/migrations/000003_create_api_keys.down.sql new file mode 100644 index 0000000..6a71f68 --- /dev/null +++ b/db/migrations/000003_create_api_keys.down.sql @@ -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; diff --git a/db/migrations/000003_create_api_keys.up.sql b/db/migrations/000003_create_api_keys.up.sql new file mode 100644 index 0000000..a32304f --- /dev/null +++ b/db/migrations/000003_create_api_keys.up.sql @@ -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. diff --git a/db/migrations/000004_create_module_configs.down.sql b/db/migrations/000004_create_module_configs.down.sql new file mode 100644 index 0000000..d67d32e --- /dev/null +++ b/db/migrations/000004_create_module_configs.down.sql @@ -0,0 +1,25 @@ +-- 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. + +-- +-- 000004_create_module_configs.down +-- +-- Drop in reverse dependency order: history first, then configs, then schemas. +-- + +DROP TABLE IF EXISTS config_history CASCADE; +DROP TABLE IF EXISTS module_configs CASCADE; +DROP TABLE IF EXISTS module_schemas CASCADE; diff --git a/db/migrations/000004_create_module_configs.up.sql b/db/migrations/000004_create_module_configs.up.sql new file mode 100644 index 0000000..37b5644 --- /dev/null +++ b/db/migrations/000004_create_module_configs.up.sql @@ -0,0 +1,226 @@ +-- 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. + +-- +-- 000004_create_module_configs.up +-- +-- Three tables implement the configuration engine described in the architecture: +-- +-- 1. module_schemas — JSON Schema registry (one row per module, platform-wide) +-- The config-service owns this table. It stores the JSON Schema document +-- that defines valid configuration parameters for each module, the default +-- values, and the current schema version. When a schema evolves, a new +-- version is inserted and the old one is retained for audit purposes. +-- +-- 2. module_configs — Per-tenant, per-module configuration (the live config) +-- One row per (tenant, module) pair. The `config` JSONB column holds the +-- developer's current settings, validated by the config-service against the +-- corresponding module_schemas.schema document before insertion or update. +-- The `schema_version` column records which schema version the stored +-- config was validated against — important when the schema is bumped. +-- +-- 3. config_history — Immutable audit log of every config change +-- Append-only. Never updated or deleted. Enables "who changed what and +-- when" queries in the management UI and satisfies compliance requirements. +-- +-- RLS strategy: +-- • module_schemas has NO RLS — it is a platform-wide registry, not tenant- +-- scoped. The config-service reads it as a superuser / migration role. +-- • module_configs and config_history ARE RLS-protected so that tenant +-- context automatically scopes every query. +-- + +-- ── 1. module_schemas — platform-wide JSON Schema registry ─ +CREATE TABLE module_schemas ( + -- Canonical module identifier, lowercase (e.g. "booking", "payment"). + module VARCHAR(100) NOT NULL, + -- Monotonically increasing version counter. Increment on every breaking + -- or additive schema change. + version INTEGER NOT NULL DEFAULT 1, + -- The full JSON Schema (draft-07) document describing valid config keys, + -- types, min/max values, required fields, and UI rendering hints. + schema JSONB NOT NULL, + -- Default configuration values applied to new tenant subscriptions. + -- Must conform to the schema above. + defaults JSONB NOT NULL DEFAULT '{}', + -- Human-readable description shown in the service catalog. + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (module, version) +); + +CREATE TRIGGER trg_module_schemas_updated_at + BEFORE UPDATE ON module_schemas + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- ── 2. module_configs — per-tenant live configuration ─ +CREATE TABLE module_configs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + -- Must reference a known module in module_schemas. + module VARCHAR(100) NOT NULL, + -- Tenant's current configuration for this module. Validated by the + -- config-service before every write; stored raw here. + config JSONB NOT NULL DEFAULT '{}', + -- The schema version against which `config` was last validated. + -- Used by config-service to detect when a tenant's config needs migration + -- after a schema version bump. + schema_version INTEGER NOT NULL DEFAULT 1, + -- Whether this module subscription is active. + status VARCHAR(20) NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'suspended')), + subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Enforce one config row per (tenant, module) pair. + CONSTRAINT uq_module_configs_tenant_module UNIQUE (tenant_id, module) +); + +CREATE TRIGGER trg_module_configs_updated_at + BEFORE UPDATE ON module_configs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- ── Indices for module_configs ─── +-- config-service fetches a tenant's config for a specific module on every +-- request — this covers the hot path. +CREATE INDEX idx_module_configs_tenant_module + ON module_configs (tenant_id, module); + +-- ── RLS for module_configs ── +ALTER TABLE module_configs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY module_configs_tenant_isolation ON module_configs + USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID) + WITH CHECK (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID); + +-- ── 3. config_history — immutable audit log ─── +CREATE TABLE config_history ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + module VARCHAR(100) NOT NULL, + -- NULL on the first subscription (no "before" state exists). + config_before JSONB, + config_after JSONB NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + -- UUID of the management-UI user or service account that made the change. + -- NULL if the change was made programmatically without an actor context. + changed_by UUID, + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- Deliberately no updated_at — this table is append-only. +); + +-- ── Indices for config_history ─── +-- Primary access pattern: "show me all config changes for tenant X, module Y, +-- newest first". +CREATE INDEX idx_config_history_tenant_module_time + ON config_history (tenant_id, module, changed_at DESC); + +-- ── RLS for config_history ── +ALTER TABLE config_history ENABLE ROW LEVEL SECURITY; + +-- Read-only: a tenant can only see their own history. +-- config-service always inserts with the tenant context set, so the WITH CHECK +-- clause prevents accidentally writing a history row for the wrong tenant. +CREATE POLICY config_history_tenant_isolation ON config_history + USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID) + WITH CHECK (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID); + +-- +-- Seed data: initial module schema for the booking module +-- +-- This seed is part of the migration so that dev environments start with a +-- usable config-service without requiring a separate seed script. Production +-- deployments also apply this migration and therefore get the same baseline. +-- +INSERT INTO module_schemas (module, version, description, schema, defaults) +VALUES ( + 'booking', + 1, + 'Slot-based appointment booking with configurable capacity, duration, and cancellation policy.', + '{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Booking Module Configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "slotDurationMinutes": { + "type": "integer", + "minimum": 5, + "maximum": 480, + "description": "Duration of each bookable slot in minutes.", + "x-ui": {"component": "slider", "step": 5} + }, + "maxBookingsPerDay": { + "type": "integer", + "minimum": 1, + "description": "Maximum bookings accepted per calendar day (UTC).", + "x-ui": {"component": "number_input"} + }, + "advanceBookingDays": { + "type": "integer", + "minimum": 1, + "maximum": 365, + "description": "How many days ahead a booking can be created.", + "x-ui": {"component": "number_input"} + }, + "autoConfirm": { + "type": "boolean", + "description": "If true, bookings move to confirmed automatically. If false, they require manual approval.", + "x-ui": {"component": "toggle"} + }, + "bufferMinutes": { + "type": "integer", + "minimum": 0, + "maximum": 120, + "description": "Gap required between consecutive bookings.", + "x-ui": {"component": "slider", "step": 5} + }, + "cancellationPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "freeCancelHoursBefore": { + "type": "integer", + "minimum": 0, + "description": "Hours before the slot start within which cancellation is free." + }, + "refundPercent": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Percentage of payment refunded on late cancellation." + } + }, + "required": ["freeCancelHoursBefore", "refundPercent"], + "x-ui": {"component": "nested_form"} + } + }, + "required": ["slotDurationMinutes", "maxBookingsPerDay", "autoConfirm"] + }'::jsonb, + '{ + "slotDurationMinutes": 30, + "maxBookingsPerDay": 100, + "advanceBookingDays": 30, + "autoConfirm": true, + "bufferMinutes": 0, + "cancellationPolicy": { + "freeCancelHoursBefore": 24, + "refundPercent": 100 + } + }'::jsonb +); diff --git a/db/migrations/000005_create_bookings.down.sql b/db/migrations/000005_create_bookings.down.sql new file mode 100644 index 0000000..09b513e --- /dev/null +++ b/db/migrations/000005_create_bookings.down.sql @@ -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. + +-- ── +-- 000005_create_bookings.down +-- ── + +DROP TABLE IF EXISTS bookings CASCADE; diff --git a/db/migrations/000005_create_bookings.up.sql b/db/migrations/000005_create_bookings.up.sql new file mode 100644 index 0000000..9ecf4a8 --- /dev/null +++ b/db/migrations/000005_create_bookings.up.sql @@ -0,0 +1,108 @@ +-- 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. + +-- ── +-- 000005_create_bookings.up +-- +-- The bookings table is the primary data store for the booking-service. +-- +-- Column mapping vs. the existing in-memory booking struct: +-- booking.Customer → customer_ref (external caller-supplied customer ID) +-- booking.Service → service_ref (external caller-supplied service ID) +-- booking.StartAt → slot_start (explicit name; slot_end derived from config) +-- +-- Lifecycle states: +-- pending → booking created, waiting for confirmation +-- confirmed → accepted (auto or manual depending on autoConfirm config) +-- completed → service was delivered +-- cancelled → cancelled by either party +-- no_show → customer did not appear; slot elapsed without cancellation +-- +-- Concurrency / double-booking: +-- A partial unique index on (tenant_id, service_ref, slot_start) where +-- status NOT IN ('cancelled', 'no_show') prevents double-booking at the DB +-- layer regardless of application-level race conditions. This is the safest +-- backstop; the booking-service should also use SELECT FOR UPDATE when +-- checking availability. +-- +-- Row-Level Security: +-- Identical to api_keys — scoped to current_setting('app.current_tenant_id'). +-- ── + +CREATE TABLE bookings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + -- Opaque reference to the customer in the tenant's own system. We do not + -- own customer data; this is a correlation handle only. + customer_ref VARCHAR(255) NOT NULL, + -- Opaque reference to the service/resource being booked (e.g. "doctor-123", + -- "room-A", "stylist-7"). + service_ref VARCHAR(255) NOT NULL, + -- Slot boundaries. slot_end is stored explicitly so bookings can have + -- variable durations without reading the module config on every query. + slot_start TIMESTAMPTZ NOT NULL, + slot_end TIMESTAMPTZ NOT NULL, + -- Booking lifecycle state. + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'confirmed', 'completed', 'cancelled', 'no_show')), + -- Free-form JSONB for tenant-defined extra fields (e.g. notes, location, + -- custom attributes). Not validated by the platform; opaque pass-through. + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Set when the booking transitions to 'cancelled'. + cancelled_at TIMESTAMPTZ, + -- Enforce slot validity at the DB layer. + CONSTRAINT chk_bookings_slot_order CHECK (slot_end > slot_start) +); + +-- ── Constraints ─── +-- Prevent double-booking: only one active (non-cancelled, non-no_show) booking +-- is allowed per (tenant, service, start slot). +CREATE UNIQUE INDEX uq_bookings_active_slot + ON bookings (tenant_id, service_ref, slot_start) + WHERE status NOT IN ('cancelled', 'no_show'); + +-- ── Indices ── +-- List all bookings for a tenant, newest first (management UI). +CREATE INDEX idx_bookings_tenant_created + ON bookings (tenant_id, created_at DESC); + +-- Availability check: "is service X booked between time A and time B?" +-- Covers the slot range overlap query pattern used in availability checks. +CREATE INDEX idx_bookings_tenant_service_slot + ON bookings (tenant_id, service_ref, slot_start, slot_end); + +-- Customer view: "show all bookings for customer Y". +CREATE INDEX idx_bookings_tenant_customer + ON bookings (tenant_id, customer_ref); + +-- Status filter: "show all pending bookings for tenant X". +CREATE INDEX idx_bookings_tenant_status + ON bookings (tenant_id, status); + +-- ── Trigger ── +CREATE TRIGGER trg_bookings_updated_at + BEFORE UPDATE ON bookings + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- ── Row-Level Security ─ +ALTER TABLE bookings ENABLE ROW LEVEL SECURITY; + +CREATE POLICY bookings_tenant_isolation ON bookings + USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID) + WITH CHECK (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID); diff --git a/db/migrations/000006_create_webhooks.down.sql b/db/migrations/000006_create_webhooks.down.sql new file mode 100644 index 0000000..768f4a7 --- /dev/null +++ b/db/migrations/000006_create_webhooks.down.sql @@ -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. + +-- ───────────────────────────────────────────────────────────────────────────── +-- 000006_create_webhooks.down +-- +-- Drop deliveries first (FK → webhooks), then webhooks. +-- ───────────────────────────────────────────────────────────────────────────── + +DROP TABLE IF EXISTS webhook_deliveries CASCADE; +DROP TABLE IF EXISTS webhooks CASCADE; diff --git a/db/migrations/000006_create_webhooks.up.sql b/db/migrations/000006_create_webhooks.up.sql new file mode 100644 index 0000000..b49df9d --- /dev/null +++ b/db/migrations/000006_create_webhooks.up.sql @@ -0,0 +1,137 @@ +-- 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. + +-- ───────────────────────────────────────────────────────────────────────────── +-- 000006_create_webhooks.up +-- +-- Two tables support the outbound webhook delivery system: +-- +-- 1. webhooks — Endpoint registrations +-- Each tenant registers one or more HTTPS endpoints and declares which +-- event types they care about. The platform signs each delivery with an +-- HMAC-SHA256 of the payload using the per-endpoint `secret`, so the +-- tenant can verify authenticity without TLS client certificates. +-- +-- 2. webhook_deliveries — Immutable delivery log +-- Every delivery attempt is recorded here. This enables: +-- • "Why didn't my webhook fire?" debugging in the management UI +-- • Automatic retry with exponential back-off (the dispatcher reads +-- rows with status='pending' or status='failed' and next_attempt_at +-- in the past) +-- • Replay: a developer can trigger a re-delivery of any past event +-- by resetting the status and clearing attempt counts +-- +-- The dispatcher (Phase 2, event-bus consumer) is responsible for writing +-- delivery rows and updating their status. The management UI reads them. +-- ───────────────────────────────────────────────────────────────────────────── + +-- ── 1. webhooks — endpoint registrations ───────────────────────────────────── +CREATE TABLE webhooks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + -- HTTPS URL of the tenant's endpoint. Validated by the platform on create. + url TEXT NOT NULL, + -- Event type filter. Empty array = subscribe to all events from all + -- subscribed modules. Non-empty = only the listed types + -- (e.g. '{"booking.created","payment.failed"}'). + events TEXT[] NOT NULL DEFAULT '{}', + -- HMAC-SHA256 signing secret. Generated by the platform on creation, + -- shown once, stored hashed (SHA-256) here. + -- TODO Phase 2: store only the hash; return the raw secret once on create. + secret_hash CHAR(64) NOT NULL, + -- Delivery retry policy stored as JSONB for flexibility. + -- Default: 5 retries with exponential back-off, max delay 3600 s. + retry_policy JSONB NOT NULL DEFAULT + '{"maxRetries": 5, "backoffType": "exponential", "initialDelaySeconds": 5, "maxDelaySeconds": 3600}', + status VARCHAR(20) NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'paused', 'deleted')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TRIGGER trg_webhooks_updated_at + BEFORE UPDATE ON webhooks + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- ── Indices for webhooks ────────────────────────────────────────────────────── +-- Dispatcher fetches active endpoints for a tenant + event type. +CREATE INDEX idx_webhooks_tenant_status + ON webhooks (tenant_id, status); + +-- ── RLS for webhooks ────────────────────────────────────────────────────────── +ALTER TABLE webhooks ENABLE ROW LEVEL SECURITY; + +CREATE POLICY webhooks_tenant_isolation ON webhooks + USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID) + WITH CHECK (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID); + +-- ── 2. webhook_deliveries — delivery attempt log ────────────────────────────── +CREATE TABLE webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + -- The event envelope JSON that was (or will be) delivered. + event_type VARCHAR(100) NOT NULL, + event_id UUID NOT NULL, -- maps to events.Envelope traceId field + payload JSONB NOT NULL, + -- Delivery status state machine: + -- pending → delivering → succeeded + -- → failed → pending (retry scheduled) + -- → dead_letter (max retries exhausted) + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'delivering', 'succeeded', 'failed', 'dead_letter')), + -- HTTP response from the tenant's endpoint (NULL until first attempt). + response_status INTEGER, + response_body TEXT, + -- Number of attempts made so far. + attempt_count INTEGER NOT NULL DEFAULT 0, + -- Timestamp for the next delivery attempt. Set by the dispatcher after + -- each failed attempt using the retry policy. NULL = not yet scheduled. + next_attempt_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Timestamp of the most recent attempt. + last_attempted_at TIMESTAMPTZ, + -- Timestamp when delivery succeeded or was declared dead-letter. + resolved_at TIMESTAMPTZ + -- No updated_at: individual columns are updated atomically by the dispatcher. +); + +-- ── Indices for webhook_deliveries ──────────────────────────────────────────── +-- Dispatcher polling query: "give me pending/failed deliveries due for retry". +CREATE INDEX idx_webhook_deliveries_dispatch + ON webhook_deliveries (status, next_attempt_at) + WHERE status IN ('pending', 'failed'); + +-- Management UI: "show recent deliveries for this webhook, newest first". +CREATE INDEX idx_webhook_deliveries_webhook_created + ON webhook_deliveries (webhook_id, created_at DESC); + +-- Tenant-level delivery history. +CREATE INDEX idx_webhook_deliveries_tenant_created + ON webhook_deliveries (tenant_id, created_at DESC); + +-- Idempotency: prevent the dispatcher from double-inserting the same event +-- for the same endpoint. +CREATE UNIQUE INDEX uq_webhook_deliveries_event_endpoint + ON webhook_deliveries (webhook_id, event_id); + +-- ── RLS for webhook_deliveries ──────────────────────────────────────────────── +ALTER TABLE webhook_deliveries ENABLE ROW LEVEL SECURITY; + +CREATE POLICY webhook_deliveries_tenant_isolation ON webhook_deliveries + USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID) + WITH CHECK (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID); diff --git a/deploy/docker/docker-compose.dev.yml b/deploy/docker/docker-compose.dev.yml index a464f67..2194fd8 100644 --- a/deploy/docker/docker-compose.dev.yml +++ b/deploy/docker/docker-compose.dev.yml @@ -28,6 +28,29 @@ services: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U serviceforge -d serviceforge"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + # golang-migrate runner — applies all pending migrations at startup and exits. + # Re-run with: docker compose run --rm migrate up + # Roll back one step: docker compose run --rm migrate down 1 + migrate: + image: migrate/migrate:v4.17.1 + container_name: serviceforge-migrate + depends_on: + postgres: + condition: service_healthy + volumes: + - ../../db/migrations:/migrations:ro + command: + - "-path=/migrations" + - "-database=postgres://serviceforge:serviceforge@postgres:5432/serviceforge?sslmode=disable" + - "up" + restart: on-failure redis: image: redis:7 diff --git a/docs/on-prem/docker-compose.yml b/docs/on-prem/docker-compose.yml index d9346d3..77c7bfc 100644 --- a/docs/on-prem/docker-compose.yml +++ b/docs/on-prem/docker-compose.yml @@ -43,9 +43,9 @@ x-service-defaults: &service-defaults services: - # ───────────────────────────────────────────────── + # # INFRASTRUCTURE - # ───────────────────────────────────────────────── + # postgres: image: postgres:16-alpine @@ -136,9 +136,9 @@ services: limits: memory: 1G - # ───────────────────────────────────────────────── + # # REVERSE PROXY - # ───────────────────────────────────────────────── + # traefik: image: traefik:v3.1 @@ -162,9 +162,9 @@ services: networks: - serviceforge - # ───────────────────────────────────────────────── + # # APPLICATION SERVICES - # ───────────────────────────────────────────────── + # gateway: <<: *service-defaults @@ -285,9 +285,9 @@ services: SF_SERVICE_NAME: logging SF_LOGGING_PORT: 8005 - # ───────────────────────────────────────────────── + # # MANAGEMENT UI - # ───────────────────────────────────────────────── + # web: <<: *service-defaults @@ -312,9 +312,9 @@ services: limits: memory: 512M - # ───────────────────────────────────────────────── + # # DATABASE MIGRATIONS (run once) - # ───────────────────────────────────────────────── + # migrate: image: serviceforge/migrate:${SF_VERSION:-latest} @@ -331,9 +331,9 @@ services: - serviceforge restart: "no" - # ───────────────────────────────────────────────── + # # MONITORING (optional, activate with --profile monitoring) - # ───────────────────────────────────────────────── + # prometheus: image: prom/prometheus:v2.52.0