Files
cgalo5758 fe19ee415c Add CLA and SPDX headers, fix docs
- Pin Dockerfile to Go 1.23 to match go.mod
- Record README front-door audit findings
2026-09-07 21:32:14 -05:00

168 lines
7.4 KiB
SQL

-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- +goose StatementBegin
-- Discourse provider schema: person↔forum-user links, the operator-managed
-- entitlement→group mapping, and the observed projection of managed-group
-- membership. FKs reference core only (the FK DAG is one-directional:
-- provider schemas depend on core, never the reverse, and never on another
-- integration's schema).
--
-- Role triple (self-contained DB ownership): each integration stream creates
-- and grants its own {slug}_owner/writer/reader roles in its own 00001. Core
-- always runs first (see internal/migrate/sources.go), so core_reader
-- already exists by the time the cross-schema grant below runs.
--
-- 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 = 'discourse_owner') THEN
CREATE ROLE discourse_owner NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'discourse_writer') THEN
CREATE ROLE discourse_writer NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'discourse_reader') THEN
CREATE ROLE discourse_reader NOLOGIN;
END IF;
END
$$;
CREATE SCHEMA discourse;
-- A user link binds a member-console person to a Discourse user. Provenance
-- records which linkage mode established the link. `conflict` marks a link
-- whose identity resolution later disagreed (e.g. the OIDC subject resolves
-- to a different forum user than the one linked); conflicted links are
-- excluded from convergence and surfaced to the operator — never silently
-- reassigned. One person links to at most one forum user and vice versa.
CREATE TABLE discourse.user_links (
person_id UUID PRIMARY KEY REFERENCES core.persons(person_id),
discourse_user_id BIGINT NOT NULL,
discourse_username TEXT NOT NULL,
linked_via TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'linked',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_user_links_discourse_user_id UNIQUE (discourse_user_id),
CONSTRAINT user_links_linked_via_valid CHECK (linked_via IN ('email', 'oidc', 'discourseconnect')),
CONSTRAINT user_links_status_valid CHECK (status IN ('linked', 'conflict'))
);
CREATE TRIGGER trigger_user_links_updated_at
BEFORE UPDATE ON discourse.user_links
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Operator-configured delivery mapping: which Discourse group a boolean
-- resource key's conferral delivers into. A group is managed by at most one
-- mapping (UNIQUE group_name) so the reconciler never receives two desired
-- member-sets for one group; a resource key may map to several groups.
-- discourse_group_id is captured at save time when the mapping is validated
-- against the Discourse API (group must exist and not be automatic); group
-- add/remove API calls use the id, display uses the name.
CREATE TABLE discourse.group_mappings (
mapping_id UUID PRIMARY KEY DEFAULT uuidv7(),
resource_key VARCHAR(100) NOT NULL,
group_name TEXT NOT NULL,
discourse_group_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- A group is managed by at most one mapping (the reconciler must never
-- receive two desired member-sets for one group); named so the operator
-- form can translate the violation (web.ConstraintMessages).
CONSTRAINT uq_group_mappings_group_name UNIQUE (group_name),
CONSTRAINT fk_group_mappings_resource_key FOREIGN KEY (resource_key) REFERENCES core.resource_keys(resource_key)
);
CREATE INDEX idx_group_mappings_resource_key ON discourse.group_mappings(resource_key);
CREATE TRIGGER trigger_group_mappings_updated_at
BEFORE UPDATE ON discourse.group_mappings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Observed projection of a managed group's membership, refreshed by sweeps
-- and webhook events. Discourse remains canonical; rows here are what the
-- console last saw, diffed against desired state during convergence.
CREATE TABLE discourse.observed_group_members (
mapping_id UUID NOT NULL REFERENCES discourse.group_mappings(mapping_id) ON DELETE CASCADE,
discourse_user_id BIGINT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_observed_group_members PRIMARY KEY (mapping_id, discourse_user_id)
);
-- Own the discourse_* resource key rows (FK target for group_mappings and
-- entitlement-set rules). `provider` stays NULL here; boot registration
-- stamps it from the manifest.
INSERT INTO core.resource_keys (resource_key, display_name, description, unit)
VALUES ('discourse_posting', 'Forum Posting', 'Posting access on the community forum, delivered as membership in the mapped Discourse group(s).', 'flag');
-- Per-schema role grants.
GRANT USAGE ON SCHEMA discourse TO discourse_reader, discourse_writer, discourse_owner;
GRANT CREATE ON SCHEMA discourse TO discourse_owner;
GRANT ALL ON ALL TABLES IN SCHEMA discourse TO discourse_owner;
GRANT ALL ON ALL TABLES IN SCHEMA discourse TO discourse_writer;
GRANT SELECT ON ALL TABLES IN SCHEMA discourse TO discourse_reader;
-- Cross-schema reader inheritance: discourse tables FK into core and this
-- stream's code paths read core tables (persons, org membership, boolean
-- entitlements) to compute desired group membership.
GRANT core_reader TO discourse_writer;
-- member_console (the DSN login role) acquires this stream's write
-- privileges explicitly, stated in this stream's own migration.
GRANT discourse_writer TO member_console;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
REVOKE discourse_writer FROM member_console;
REVOKE core_reader FROM discourse_writer;
REVOKE ALL ON ALL TABLES IN SCHEMA discourse FROM discourse_owner;
REVOKE ALL ON ALL TABLES IN SCHEMA discourse FROM discourse_writer;
REVOKE ALL ON ALL TABLES IN SCHEMA discourse FROM discourse_reader;
REVOKE ALL ON SCHEMA discourse FROM discourse_reader, discourse_writer, discourse_owner;
DELETE FROM core.resource_keys WHERE resource_key = 'discourse_posting';
DROP TABLE IF EXISTS discourse.observed_group_members;
DROP TRIGGER IF EXISTS trigger_group_mappings_updated_at ON discourse.group_mappings;
DROP TABLE IF EXISTS discourse.group_mappings;
DROP TRIGGER IF EXISTS trigger_user_links_updated_at ON discourse.user_links;
DROP TABLE IF EXISTS discourse.user_links;
DROP SCHEMA IF EXISTS discourse;
-- 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['discourse_reader', 'discourse_writer', 'discourse_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