- Pin Dockerfile to Go 1.23 to match go.mod - Record README front-door audit findings
168 lines
7.4 KiB
SQL
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
|