-- 009_auth.up.sql
-- M13a: Multi-tenant auth schema for authd (port 8804).
--
-- Tenancy model (per M13 survey, decision 1.1 = C, decision 2.1 = C hybrid):
--   - One Postgres DB for everything (M11 already uses it for DLQ, deliveries).
--   - Schema 'auth' is the namespace for authd tables.
--   - A 'tenant' is a customer company. Each tenant has a 'tenant_slug'
--     used in routes (e.g. /v1/tenants/acme-001/sources) and as a
--     partition key for downstream queries.
--   - A 'user' belongs to ONE tenant (super-admins have tenant_id=NULL
--     and role='super_admin'; tenant-admins have role='tenant_admin').
--   - 'global_id' on users is for SSO future (v2). For now it's
--     just a stable opaque id we generate server-side.
--
-- Authentication (per survey 2.2 = A in-house JWT, 2.4 = B magic-link):
--   - 'magic_links' stores the one-time tokens emailed to invited
--     users. After use they're marked 'consumed_at'.
--   - 'refresh_tokens' stores the long-lived tokens (7d TTL by default)
--     that authd issues alongside the short-lived (15m) access JWT.
--     The refresh token is a 256-bit random string, NOT a JWT — the
--     server-side row is the source of truth (decision: refresh in
--     server-side Postgres, rotated on use).
--   - 'sessions' is the audit trail of every login/logout, used for
--     security investigations ("who was logged in when X happened").
--
-- Audit (per survey: every service writes its own audit_log row):
--   - 'audit_log' is shared across services. action is namespaced
--     as 'auth.login', 'auth.logout', 'auth.invite', 'cert.issue',
--     'cert.revoke', etc.
--   - actor_user_id is nullable for system actions (cron, service tokens).
--   - payload is jsonb for service-specific structured data.
--
-- Surfaces: authd (CRUD users/magic_links/sessions/refresh_tokens + reads
-- audit_log), admind (reads audit_log for the admin UI), and a
-- 'verify_session' SQL function used by ingestd/admind to validate
-- a presented access JWT against the refresh-token table (so we can
-- revoke a session by deleting its row).

CREATE SCHEMA IF NOT EXISTS auth;
SET search_path TO auth, public;

-- pgcrypto provides gen_random_bytes() and digest() (used by the
-- refresh-token functions below for secure random + sha256).
-- gen_random_uuid() comes from pgcrypto on older Postgres and is
-- built-in on 13+, but the extension is harmless to enable.
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- ---------------------------------------------------------------------------
-- Tenants
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS tenants (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    slug            TEXT NOT NULL UNIQUE
                    CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'),
    display_name    TEXT NOT NULL,
    status          TEXT NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'suspended', 'archived')),
    contact_email   TEXT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    archived_at     TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS tenants_status_idx ON tenants(status) WHERE status != 'archived';

-- ---------------------------------------------------------------------------
-- Users
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    -- global_id is for SSO migration (v2). Until then, the per-tenant
    -- (id, tenant_id) pair is the unique key.
    global_id       UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
    tenant_id       UUID REFERENCES tenants(id) ON DELETE CASCADE,
    email           TEXT NOT NULL,
    role            TEXT NOT NULL
                    CHECK (role IN ('super_admin', 'tenant_admin', 'viewer')),
    status          TEXT NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending', 'active', 'disabled')),
    display_name    TEXT,
    -- bcrypt of the password. NULL until the magic link is consumed.
    password_hash   TEXT,
    last_login_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    disabled_at     TIMESTAMPTZ
);

-- Email is unique per tenant (or unique globally for super_admins).
-- Partial unique index for super_admins (tenant_id IS NULL).
CREATE UNIQUE INDEX IF NOT EXISTS users_email_global_uniq
    ON users(email) WHERE tenant_id IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS users_email_per_tenant_uniq
    ON users(email, tenant_id) WHERE tenant_id IS NOT NULL;

CREATE INDEX IF NOT EXISTS users_tenant_idx ON users(tenant_id) WHERE tenant_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS users_status_idx ON users(status) WHERE status != 'active';

-- ---------------------------------------------------------------------------
-- Magic links (invite + password-set)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS magic_links (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    -- token_hash is sha256(token). The plaintext token is emailed and
    -- never stored. Lookup is by hash.
    token_hash      BYTEA NOT NULL UNIQUE,
    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    purpose         TEXT NOT NULL
                    CHECK (purpose IN ('invite', 'password_reset', 'mfa_reset')),
    expires_at      TIMESTAMPTZ NOT NULL,
    consumed_at     TIMESTAMPTZ,
    -- ip that consumed it (audit trail)
    consumed_ip     INET,
    -- user-agent that consumed it (audit trail)
    consumed_ua     TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS magic_links_user_idx ON magic_links(user_id);
CREATE INDEX IF NOT EXISTS magic_links_unconsumed_idx
    ON magic_links(expires_at) WHERE consumed_at IS NULL;

-- ---------------------------------------------------------------------------
-- Refresh tokens (server-side session table)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS refresh_tokens (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    -- token_hash is sha256(token). The plaintext is in the httpOnly
    -- cookie / mobile secure storage. Token is a 32-byte random hex string.
    token_hash      BYTEA NOT NULL UNIQUE,
    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    -- jti of the access JWT that this refresh was issued with. Used
    -- to correlate logs and to make rotation auditable.
    access_jti      TEXT NOT NULL,
    -- The previous token's hash (if rotated). Lets us detect a stolen
    -- token being used after rotation.
    parent_hash     BYTEA,
    expires_at      TIMESTAMPTZ NOT NULL,
    revoked_at      TIMESTAMPTZ,
    -- The 'family' id groups all rotations of one login. If a non-head
    -- token in a family is used, the family is killed (re-use detection).
    family_id       UUID NOT NULL DEFAULT gen_random_uuid(),
    created_ip      INET,
    created_ua      TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS refresh_tokens_family_idx ON refresh_tokens(family_id);
CREATE INDEX IF NOT EXISTS refresh_tokens_active_idx
    ON refresh_tokens(expires_at) WHERE revoked_at IS NULL;

-- ---------------------------------------------------------------------------
-- Sessions (read-only audit view of refresh_tokens, denormalized for
-- fast "what devices is this user logged in from" queries).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sessions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    refresh_id      UUID NOT NULL REFERENCES refresh_tokens(id) ON DELETE CASCADE,
    ip              INET,
    user_agent      TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_seen_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    revoked_at      TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);
CREATE INDEX IF NOT EXISTS sessions_active_idx
    ON sessions(user_id) WHERE revoked_at IS NULL;

-- ---------------------------------------------------------------------------
-- Audit log
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS audit_log (
    id              BIGSERIAL PRIMARY KEY,
    occurred_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    -- 'auth.login', 'auth.logout', 'auth.invite', 'auth.magic_consume',
    -- 'cert.issue', 'cert.revoke', 'tenant.create', 'user.disable', etc.
    action          TEXT NOT NULL,
    actor_user_id   UUID REFERENCES users(id) ON DELETE SET NULL,
    actor_ip        INET,
    actor_ua        TEXT,
    -- The target of the action. For 'auth.invite' it's the invited
    -- user. For 'cert.revoke' it's the source/cert id. Free-form uuid.
    target_id       UUID,
    -- Tenant scope (NULL for super-admin global actions).
    tenant_id       UUID REFERENCES tenants(id) ON DELETE SET NULL,
    -- Action-specific structured data. Example for 'auth.login':
    --   {"email": "...", "success": true, "failure_reason": null}
    payload         JSONB NOT NULL DEFAULT '{}'::jsonb
);

CREATE INDEX IF NOT EXISTS audit_log_action_time_idx
    ON audit_log(action, occurred_at DESC);
CREATE INDEX IF NOT EXISTS audit_log_actor_idx
    ON audit_log(actor_user_id, occurred_at DESC)
    WHERE actor_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS audit_log_tenant_idx
    ON audit_log(tenant_id, occurred_at DESC)
    WHERE tenant_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS audit_log_target_idx
    ON audit_log(target_id) WHERE target_id IS NOT NULL;

-- ---------------------------------------------------------------------------
-- SQL functions
-- ---------------------------------------------------------------------------

-- generate_magic_link_token returns a 32-byte cryptographically random
-- token, hex-encoded. Callers hash it before storing.
CREATE OR REPLACE FUNCTION generate_magic_link_token()
RETURNS TEXT
LANGUAGE plpgsql
SET search_path = auth, public
AS $$
DECLARE
    raw BYTEA;
BEGIN
    raw := gen_random_bytes(32);
    RETURN encode(raw, 'hex');
END;
$$;

-- issue_refresh_token: create a new refresh token row + the matching
-- session row in one transaction. Returns the plaintext token (caller
-- hashes it for the response and stores only the hash).
CREATE OR REPLACE FUNCTION issue_refresh_token(
    p_user_id    UUID,
    p_access_jti TEXT,
    p_ttl_seconds INTEGER,
    p_ip         INET,
    p_ua         TEXT
)
RETURNS TABLE (id UUID, token TEXT, family_id UUID, expires_at TIMESTAMPTZ)
LANGUAGE plpgsql
SET search_path = auth, public
AS $$
DECLARE
    v_raw        BYTEA := gen_random_bytes(32);
    v_token      TEXT  := encode(v_raw, 'hex');
    v_hash       BYTEA := digest(v_raw, 'sha256');
    v_new_id     UUID;
    v_family     UUID := gen_random_uuid();
    v_expires    TIMESTAMPTZ := NOW() + (p_ttl_seconds || ' seconds')::INTERVAL;
BEGIN
    INSERT INTO auth.refresh_tokens
        (token_hash, user_id, access_jti, family_id, expires_at, created_ip, created_ua)
    VALUES
        (v_hash, p_user_id, p_access_jti, v_family, v_expires, p_ip, p_ua)
    RETURNING auth.refresh_tokens.id INTO v_new_id;

    INSERT INTO auth.sessions
        (user_id, refresh_id, ip, user_agent)
    VALUES
        (p_user_id, v_new_id, p_ip, p_ua);

    RETURN QUERY SELECT v_new_id, v_token, v_family, v_expires;
END;
$$;

-- rotate_refresh_token: consume a refresh token and issue a new one
-- in the same family. If the presented token was already revoked OR
-- the family has a re-use signal, the whole family is killed.
CREATE OR REPLACE FUNCTION rotate_refresh_token(
    p_presented_token TEXT,
    p_new_access_jti  TEXT,
    p_ttl_seconds     INTEGER,
    p_ip              INET,
    p_ua              TEXT
)
RETURNS TABLE (id UUID, token TEXT, family_id UUID, expires_at TIMESTAMPTZ, killed_family BOOLEAN)
LANGUAGE plpgsql
SET search_path = auth, public
AS $$
DECLARE
    v_presented_hash BYTEA := digest(decode(p_presented_token, 'hex'), 'sha256');
    v_old            RECORD;
    v_raw            BYTEA;
    v_token          TEXT;
    v_hash           BYTEA;
    v_new_id         UUID;
    v_expires        TIMESTAMPTZ;
    v_killed         BOOLEAN := FALSE;
BEGIN
    SELECT rt.id, rt.user_id, rt.family_id, rt.expires_at, rt.revoked_at, rt.parent_hash
      INTO v_old
      FROM auth.refresh_tokens rt
     WHERE rt.token_hash = v_presented_hash;

    IF NOT FOUND THEN
        -- Unknown token — likely forgeries or already cleaned up.
        RAISE EXCEPTION 'unknown_refresh_token' USING ERRCODE = '22023';
    END IF;

    IF v_old.expires_at < NOW() THEN
        RAISE EXCEPTION 'expired_refresh_token' USING ERRCODE = '22023';
    END IF;

    -- Re-use detection: if this token is already revoked OR has a
    -- child (parent_hash set), someone is replaying a stolen token.
    IF v_old.revoked_at IS NOT NULL OR v_old.parent_hash IS NOT NULL THEN
        -- Kill the whole family. Every token in it is suspect.
        UPDATE auth.refresh_tokens
           SET revoked_at = NOW()
         WHERE auth.refresh_tokens.family_id = v_old.family_id
           AND auth.refresh_tokens.revoked_at IS NULL;
        UPDATE auth.sessions
           SET revoked_at = NOW()
         WHERE auth.sessions.user_id = v_old.user_id
           AND auth.sessions.refresh_id IN (
               SELECT auth.refresh_tokens.id
                 FROM auth.refresh_tokens
                WHERE auth.refresh_tokens.family_id = v_old.family_id
           )
           AND auth.sessions.revoked_at IS NULL;
        v_killed := TRUE;
        RAISE EXCEPTION 'refresh_token_reuse' USING ERRCODE = '22023';
    END IF;

    -- Revoke the old, issue the new
    UPDATE auth.refresh_tokens
       SET revoked_at = NOW()
     WHERE auth.refresh_tokens.id = v_old.id;

    v_raw     := gen_random_bytes(32);
    v_token   := encode(v_raw, 'hex');
    v_hash    := digest(v_raw, 'sha256');
    v_expires := NOW() + (p_ttl_seconds || ' seconds')::INTERVAL;

    INSERT INTO auth.refresh_tokens
        (token_hash, user_id, access_jti, family_id, parent_hash, expires_at, created_ip, created_ua)
    VALUES
        (v_hash, v_old.user_id, p_new_access_jti, v_old.family_id, v_presented_hash, v_expires, p_ip, p_ua)
    RETURNING auth.refresh_tokens.id INTO v_new_id;

    UPDATE auth.sessions
       SET last_seen_at = NOW(),
           refresh_id   = v_new_id,
           ip           = p_ip,
           user_agent   = p_ua
     WHERE refresh_id = v_old.id;

    RETURN QUERY SELECT v_new_id, v_token, v_old.family_id, v_expires, v_killed;
END;
$$;

-- revoke_refresh_token: revoke a single token by plaintext.
CREATE OR REPLACE FUNCTION revoke_refresh_token(p_token TEXT)
RETURNS BOOLEAN
LANGUAGE plpgsql
SET search_path = auth, public
AS $$
DECLARE
    v_hash BYTEA := digest(decode(p_token, 'hex'), 'sha256');
    v_id   UUID;
BEGIN
    UPDATE auth.refresh_tokens
       SET revoked_at = NOW()
     WHERE token_hash = v_hash
       AND revoked_at IS NULL
    RETURNING auth.refresh_tokens.id INTO v_id;

    IF v_id IS NULL THEN
        RETURN FALSE;
    END IF;

    UPDATE auth.sessions
       SET revoked_at = NOW()
     WHERE refresh_id = v_id
       AND revoked_at IS NULL;

    RETURN TRUE;
END;
$$;

-- ---------------------------------------------------------------------------
-- updated_at triggers
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION auth_set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    NEW.updated_at := NOW();
    RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS tenants_set_updated_at ON tenants;
CREATE TRIGGER tenants_set_updated_at
    BEFORE UPDATE ON tenants
    FOR EACH ROW EXECUTE FUNCTION auth_set_updated_at();

DROP TRIGGER IF EXISTS users_set_updated_at ON users;
CREATE TRIGGER users_set_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION auth_set_updated_at();
