Replace the entity slugs on organizations, workspaces, resource pools, and plan ladders with nullable `key` columns and add keys to products, prices, and entitlement sets. Rename `providers.slug` to `provider` and add partial unique indexes for system and org role names. Assign invoice numbers per billing account from a gapless transactional counter; Stripe's number moves to the invoice mapping as an external reference. Seeds, fixtures, and the operator lookup address rows by key, and the returning-login resync no longer blanks a display name when the IdP sends no `name` claim.
260 lines
12 KiB
SQL
260 lines
12 KiB
SQL
-- +goose Up
|
|
-- +goose StatementBegin
|
|
|
|
-- Domain registry (domains-registry, M10): the single authority for the
|
|
-- deployment's DNS namespace. `claims` are disjoint subtrees with one live
|
|
-- owner each; `placements` bind an exact name inside a claim to a
|
|
-- provider-qualified resource; `name_rules` carry operator label policy.
|
|
-- FKs reference core.workspaces and core.providers only (Decision 2 — the
|
|
-- FK DAG is one-directional; this stream depends on core, never the
|
|
-- reverse, and never on an integration schema).
|
|
--
|
|
-- Role triple (Decision 7 — self-contained DB ownership): this stream
|
|
-- creates and grants its own domains_owner/writer/reader. Core always runs
|
|
-- first (core -> domains -> integrations, per internal/migrate/sources.go),
|
|
-- so core_reader exists by the time the cross-schema grant below runs. The
|
|
-- reverse runtime edge — fedwiki activities writing domains.* — is granted
|
|
-- by the fedwiki stream (fedwiki roles do not exist yet on a fresh install
|
|
-- at this point).
|
|
--
|
|
-- Roles are cluster-global, not database-scoped, so creation is guarded:
|
|
-- a second database in the same cluster must not abort this stream.
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'domains_owner') THEN
|
|
CREATE ROLE domains_owner NOLOGIN;
|
|
END IF;
|
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'domains_writer') THEN
|
|
CREATE ROLE domains_writer NOLOGIN;
|
|
END IF;
|
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'domains_reader') THEN
|
|
CREATE ROLE domains_reader NOLOGIN;
|
|
END IF;
|
|
END
|
|
$$;
|
|
|
|
CREATE SCHEMA domains;
|
|
|
|
-- A claim denotes a root name AND its entire subtree. Three kinds:
|
|
-- `operator_root` (a deployment shared domain, system-owned), `member`
|
|
-- (carved strictly inside exactly one live operator root, which it records
|
|
-- as its parent), and `external` (bring-your-own, proven by DNS
|
|
-- verification).
|
|
--
|
|
-- Claims subsume the retired fedwiki.custom_domain_verifications table: the
|
|
-- verification row IS a pending claim, so the probe columns, their
|
|
-- CHECK-guarded state vocabulary, and the live-name partial unique index
|
|
-- carry over verbatim. Non-external claims are born 'active' and never
|
|
-- carry a token. `released` is the terminal state for a claim freed after
|
|
-- being active; 'expired'/'canceled' preserve verification history.
|
|
--
|
|
-- `reversed_labels` is root_fqdn with its labels reversed
|
|
-- ("alice.wiki.cafe" -> "cafe.wiki.alice"), giving descendant queries a
|
|
-- text_pattern_ops prefix scan (WHERE reversed_labels LIKE $root || '.%').
|
|
-- Ancestor checks walk the candidate's suffixes with exact root lookups.
|
|
CREATE TABLE domains.claims (
|
|
claim_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
workspace_id UUID NOT NULL REFERENCES core.workspaces(workspace_id),
|
|
root_fqdn TEXT NOT NULL,
|
|
reversed_labels TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
parent_claim_id UUID REFERENCES domains.claims(claim_id),
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
|
-- Verification lifecycle. Populated for `external` claims only; the
|
|
-- other kinds keep the ''/NULL defaults. *_observed holds the values
|
|
-- the resolver actually returned, newline-joined (the store is
|
|
-- database/sql — no array columns), so the UI can show
|
|
-- observed-vs-expected on a mismatch.
|
|
token TEXT NOT NULL DEFAULT '',
|
|
expires_at TIMESTAMPTZ,
|
|
verified_at TIMESTAMPTZ,
|
|
txt_state TEXT NOT NULL DEFAULT 'unchecked',
|
|
txt_observed TEXT NOT NULL DEFAULT '',
|
|
connect_state TEXT NOT NULL DEFAULT 'unchecked',
|
|
connect_observed TEXT NOT NULL DEFAULT '',
|
|
last_checked_at TIMESTAMPTZ,
|
|
|
|
CONSTRAINT claims_kind_valid
|
|
CHECK (kind IN ('operator_root', 'member', 'external')),
|
|
CONSTRAINT claims_status_valid
|
|
CHECK (status IN ('pending', 'active', 'expired', 'canceled', 'released')),
|
|
CONSTRAINT claims_txt_state_valid
|
|
CHECK (txt_state IN ('unchecked', 'missing', 'mismatch', 'match', 'error')),
|
|
CONSTRAINT claims_connect_state_valid
|
|
CHECK (connect_state IN ('unchecked', 'missing', 'mismatch', 'match', 'error')),
|
|
-- Exactly the `member` kind carves out of a parent root; operator roots
|
|
-- and external claims are top-level by construction.
|
|
CONSTRAINT claims_parent_matches_kind
|
|
CHECK ((kind = 'member') = (parent_claim_id IS NOT NULL))
|
|
);
|
|
|
|
-- One live claim per exact root: pending/active rows hold the name,
|
|
-- terminal rows free it for re-claiming. Last-resort arbiter for
|
|
-- exact-root races that slip past the registry advisory lock.
|
|
CREATE UNIQUE INDEX idx_claims_live_root
|
|
ON domains.claims (root_fqdn)
|
|
WHERE status IN ('pending', 'active');
|
|
|
|
-- Descendant scans (does any live claim sit inside this subtree?).
|
|
-- text_pattern_ops is required for LIKE prefix matching to use the index
|
|
-- under a non-C collation.
|
|
CREATE INDEX idx_claims_live_reversed_labels
|
|
ON domains.claims (reversed_labels text_pattern_ops)
|
|
WHERE status IN ('pending', 'active');
|
|
|
|
-- Member-facing claim lists and the per-workspace pending cap.
|
|
CREATE INDEX idx_claims_workspace_id ON domains.claims (workspace_id);
|
|
|
|
CREATE TRIGGER trigger_claims_updated_at
|
|
BEFORE UPDATE ON domains.claims
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION public.update_updated_at_column();
|
|
|
|
-- A placement binds one exact name inside a claim's subtree (the root
|
|
-- itself included — the apex placement) to a provider-qualified resource.
|
|
-- `resource_ref` is opaque and provider-scoped (fedwiki: site_id as text) —
|
|
-- deliberately not a foreign key into provider storage, so the registry
|
|
-- stays generic. `servable` is provider-maintained: the registry cannot
|
|
-- join per-provider lifecycle tables, so the owning integration flips it
|
|
-- (fedwiki: active/readonly => TRUE, archived => FALSE), making
|
|
-- /domains/ask a single indexed lookup by fqdn.
|
|
--
|
|
-- The UNIQUE on fqdn is total, not partial: placements are hard-deleted on
|
|
-- release (the registry stores current state, not history).
|
|
CREATE TABLE domains.placements (
|
|
placement_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
claim_id UUID NOT NULL REFERENCES domains.claims(claim_id),
|
|
fqdn TEXT UNIQUE NOT NULL,
|
|
reversed_labels TEXT NOT NULL,
|
|
-- References core.providers' primary key without naming the column: the
|
|
-- core stream runs to its head before this stream's 00001 does, and core
|
|
-- 00014 renamed that column from `slug` to `provider`. Naming no column
|
|
-- keeps this baseline correct across that rename and any future one.
|
|
provider TEXT NOT NULL REFERENCES core.providers,
|
|
resource_ref TEXT NOT NULL,
|
|
servable BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- Auto-release re-counts a claim's placements under the registry lock.
|
|
CREATE INDEX idx_placements_claim_id ON domains.placements (claim_id);
|
|
|
|
-- Provider-side lifecycle writes (servability flips, delete sweeps) address
|
|
-- placements by their own resource identity, not by name.
|
|
CREATE INDEX idx_placements_provider_resource_ref
|
|
ON domains.placements (provider, resource_ref);
|
|
|
|
-- Descendant scans on placements as well as claims: an operator placement
|
|
-- inside a candidate subtree blocks carving it, which a claims-only column
|
|
-- cannot answer without scanning an operator root's whole placement set.
|
|
CREATE INDEX idx_placements_reversed_labels
|
|
ON domains.placements (reversed_labels text_pattern_ops);
|
|
|
|
CREATE TRIGGER trigger_placements_updated_at
|
|
BEFORE UPDATE ON domains.placements
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION public.update_updated_at_column();
|
|
|
|
-- Operator name policy: exact labels, scoped to one operator root or global
|
|
-- (root_claim_id NULL). The registry ships no built-in word blocklist —
|
|
-- mechanism, not list; deployments seed their own rows. Policy applies at
|
|
-- allocation time only, so new rules never invalidate existing claims.
|
|
CREATE TABLE domains.name_rules (
|
|
rule_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
root_claim_id UUID REFERENCES domains.claims(claim_id),
|
|
label TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
note TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
|
|
CONSTRAINT name_rules_kind_valid
|
|
CHECK (kind IN ('reserved', 'blocked', 'premium')),
|
|
-- NULLS NOT DISTINCT (PG 15+; deployment runs 18) so the global scope
|
|
-- admits one rule per label rather than unbounded duplicates.
|
|
CONSTRAINT name_rules_scope_label_unique
|
|
UNIQUE NULLS NOT DISTINCT (root_claim_id, label)
|
|
);
|
|
|
|
-- Per-schema role grants.
|
|
GRANT USAGE ON SCHEMA domains TO domains_reader, domains_writer, domains_owner;
|
|
GRANT CREATE ON SCHEMA domains TO domains_owner;
|
|
|
|
GRANT ALL ON ALL TABLES IN SCHEMA domains TO domains_owner;
|
|
GRANT ALL ON ALL TABLES IN SCHEMA domains TO domains_writer;
|
|
GRANT SELECT ON ALL TABLES IN SCHEMA domains TO domains_reader;
|
|
|
|
-- Cross-schema reader inheritance: registry tables FK into core.workspaces
|
|
-- and core.providers, and this stream's code paths read core tables
|
|
-- (Decision 7).
|
|
GRANT core_reader TO domains_writer;
|
|
|
|
-- member_console is the DSN login role; state its application-level
|
|
-- privilege explicitly. Owner roles stay reserved for migrations
|
|
-- (Decision 7).
|
|
GRANT domains_writer TO member_console;
|
|
|
|
-- The external-domain gate's resource key. Platform-owned: `provider` stays
|
|
-- NULL and the name is deliberately NOT `domains_`-prefixed — platform keys
|
|
-- are unprefixed by the provider-registry contract, and a leading `domains_`
|
|
-- token would permanently foreclose `domains` as a provider slug
|
|
-- (slug-nesting rule, internal/integration/registration.go). `kind`
|
|
-- declared explicitly per core 00008.
|
|
-- ON CONFLICT: idempotent so a manual dev-stream rewind (drop schema +
|
|
-- delete goose row) replays cleanly once the key already exists.
|
|
INSERT INTO core.resource_keys (resource_key, display_name, description, unit, kind)
|
|
VALUES ('external_domain_claims', 'External Domains', 'Ability to verify and use member-owned domains.', 'flag', 'boolean')
|
|
ON CONFLICT (resource_key) DO NOTHING;
|
|
|
|
-- +goose StatementEnd
|
|
|
|
-- +goose Down
|
|
-- +goose StatementBegin
|
|
|
|
-- Precondition (same contract as fedwiki 00002's Down): entitlement rules
|
|
-- and materialized entitlements referencing this key must be removed first —
|
|
-- their FKs have no CASCADE, so this DELETE fails (rolling the Down back)
|
|
-- rather than silently orphaning operator-authored rules.
|
|
DELETE FROM core.resource_keys WHERE resource_key = 'external_domain_claims';
|
|
|
|
REVOKE domains_writer FROM member_console;
|
|
REVOKE core_reader FROM domains_writer;
|
|
|
|
REVOKE ALL ON ALL TABLES IN SCHEMA domains FROM domains_owner;
|
|
REVOKE ALL ON ALL TABLES IN SCHEMA domains FROM domains_writer;
|
|
REVOKE ALL ON ALL TABLES IN SCHEMA domains FROM domains_reader;
|
|
REVOKE ALL ON SCHEMA domains FROM domains_reader, domains_writer, domains_owner;
|
|
|
|
DROP TABLE IF EXISTS domains.name_rules;
|
|
|
|
DROP TRIGGER IF EXISTS trigger_placements_updated_at ON domains.placements;
|
|
DROP TABLE IF EXISTS domains.placements;
|
|
|
|
DROP TRIGGER IF EXISTS trigger_claims_updated_at ON domains.claims;
|
|
DROP TABLE IF EXISTS domains.claims;
|
|
|
|
DROP SCHEMA IF EXISTS domains;
|
|
|
|
-- Roles are cluster-global: another database in this cluster may still own
|
|
-- objects or hold grants under them, in which case DROP ROLE raises
|
|
-- dependent_objects_still_exist. Leave such a role standing rather than
|
|
-- failing this database's rollback over another database's state.
|
|
DO $$
|
|
DECLARE
|
|
role_name TEXT;
|
|
BEGIN
|
|
FOREACH role_name IN ARRAY ARRAY['domains_reader', 'domains_writer', 'domains_owner'] LOOP
|
|
BEGIN
|
|
EXECUTE format('DROP ROLE IF EXISTS %I', role_name);
|
|
EXCEPTION WHEN dependent_objects_still_exist THEN
|
|
RAISE NOTICE 'role % is still in use elsewhere in this cluster; left in place', role_name;
|
|
END;
|
|
END LOOP;
|
|
END
|
|
$$;
|
|
|
|
-- +goose StatementEnd
|