feat(education): enforce catalog graph scope

This commit is contained in:
2026-07-30 19:53:05 +08:00
parent 55a991d3e4
commit 4db4a7d371
7 changed files with 517 additions and 18 deletions

View File

@@ -0,0 +1,240 @@
CREATE FUNCTION education_check_reference_scope()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, pg_temp
AS $$
DECLARE
reference_id BIGINT;
reference_tenant_id BIGINT;
reference_scope VARCHAR(20);
reference_count INTEGER;
BEGIN
reference_id := (to_jsonb(NEW) ->> TG_ARGV[0])::BIGINT;
IF reference_id IS NULL THEN
RETURN NEW;
END IF;
EXECUTE format('SELECT tenant_id, scope FROM %I.%I WHERE id = $1 FOR SHARE',
TG_TABLE_SCHEMA, TG_ARGV[1])
INTO reference_tenant_id, reference_scope
USING reference_id;
GET DIAGNOSTICS reference_count = ROW_COUNT;
IF reference_count = 0 THEN
RAISE EXCEPTION 'catalog reference %.% points to missing %.id %',
TG_TABLE_NAME, TG_ARGV[0], TG_ARGV[1], reference_id
USING ERRCODE = '23503';
END IF;
IF NEW.scope = 'PUBLIC' THEN
IF NEW.tenant_id <> 0 OR reference_scope <> 'PUBLIC' OR reference_tenant_id <> 0 THEN
RAISE EXCEPTION 'PUBLIC catalog row %.% may only reference PUBLIC parents',
TG_TABLE_NAME, TG_ARGV[0]
USING ERRCODE = '23514';
END IF;
ELSIF NEW.scope = 'TENANT_OWNED' THEN
IF NEW.tenant_id <= 0 OR NOT (
(reference_scope = 'PUBLIC' AND reference_tenant_id = 0) OR
(reference_scope = 'TENANT_OWNED' AND reference_tenant_id = NEW.tenant_id)
) THEN
RAISE EXCEPTION 'cross-tenant catalog reference rejected at %.%',
TG_TABLE_NAME, TG_ARGV[0]
USING ERRCODE = '23514';
END IF;
ELSE
RAISE EXCEPTION 'unsupported catalog scope % at %.%',
NEW.scope, TG_TABLE_NAME, TG_ARGV[0]
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION education_check_reference_scope() IS
'Rejects PUBLIC-to-tenant and cross-tenant references in the Education catalog graph';
CREATE FUNCTION education_prevent_catalog_scope_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = pg_catalog, pg_temp
AS $$
BEGIN
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
OR NEW.scope IS DISTINCT FROM OLD.scope THEN
RAISE EXCEPTION 'catalog ownership scope is immutable for %', TG_TABLE_NAME
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION education_prevent_catalog_scope_change() IS
'Makes Education catalog tenant ownership and PUBLIC/TENANT_OWNED scope immutable after insert';
REVOKE ALL ON FUNCTION education_check_reference_scope() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_prevent_catalog_scope_change() FROM PUBLIC;
CREATE TRIGGER trg_education_school_region_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, region_id ON education_school
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('region_id', 'education_region');
CREATE TRIGGER trg_education_major_region_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, region_id ON education_major
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('region_id', 'education_region');
CREATE TRIGGER trg_education_major_school_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, school_id ON education_major
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('school_id', 'education_school');
CREATE TRIGGER trg_education_subject_region_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, region_id ON education_subject
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('region_id', 'education_region');
CREATE TRIGGER trg_education_subject_school_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, school_id ON education_subject
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('school_id', 'education_school');
CREATE TRIGGER trg_education_subject_major_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, major_id ON education_subject
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('major_id', 'education_major');
CREATE TRIGGER trg_education_category_subject_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, subject_id ON education_category
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('subject_id', 'education_subject');
CREATE TRIGGER trg_education_content_entry_region_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, region_id ON education_content_entry
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('region_id', 'education_region');
CREATE TRIGGER trg_education_content_node_entry_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, entry_id ON education_content_node
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('entry_id', 'education_content_entry');
CREATE TRIGGER trg_education_content_node_parent_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, parent_id ON education_content_node
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('parent_id', 'education_content_node');
CREATE TRIGGER trg_education_question_collection_entry_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, entry_id ON education_question_collection
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('entry_id', 'education_content_entry');
CREATE TRIGGER trg_education_question_collection_node_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, node_id ON education_question_collection
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('node_id', 'education_content_node');
CREATE TRIGGER trg_education_question_subject_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, subject_id ON education_question
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('subject_id', 'education_subject');
CREATE TRIGGER trg_education_question_node_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, node_id ON education_question
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('node_id', 'education_content_node');
CREATE TRIGGER trg_education_practice_blueprint_entry_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, entry_id ON education_practice_blueprint
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('entry_id', 'education_content_entry');
CREATE TRIGGER trg_education_practice_blueprint_node_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, node_id ON education_practice_blueprint
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('node_id', 'education_content_node');
CREATE TRIGGER trg_education_practice_blueprint_collection_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, collection_id ON education_practice_blueprint
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('collection_id', 'education_question_collection');
CREATE TRIGGER trg_education_qcq_collection_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, collection_id ON education_question_collection_question
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('collection_id', 'education_question_collection');
CREATE TRIGGER trg_education_qcq_question_reference_scope
BEFORE INSERT OR UPDATE OF tenant_id, scope, question_id ON education_question_collection_question
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('question_id', 'education_question');
CREATE TRIGGER trg_education_region_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_region
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_school_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_school
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_major_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_major
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_subject_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_subject
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_category_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_category
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_content_entry_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_content_entry
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_content_node_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_content_node
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_question_collection_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_question_collection
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_question_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_question
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_practice_blueprint_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_practice_blueprint
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
CREATE TRIGGER trg_education_qcq_scope_immutable
BEFORE UPDATE OF tenant_id, scope ON education_question_collection_question
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
-- CREATE TRIGGER takes a SHARE ROW EXCLUSIVE lock on each protected table. Run
-- historical validation only after all guards exist so concurrent writes cannot
-- slip an invalid edge between validation and trigger installation.
DO $$
DECLARE
catalog_schema TEXT := current_schema();
edge RECORD;
violation_exists BOOLEAN;
BEGIN
FOR edge IN
SELECT *
FROM (VALUES
('education_school', 'region_id', 'education_region'),
('education_major', 'region_id', 'education_region'),
('education_major', 'school_id', 'education_school'),
('education_subject', 'region_id', 'education_region'),
('education_subject', 'school_id', 'education_school'),
('education_subject', 'major_id', 'education_major'),
('education_category', 'subject_id', 'education_subject'),
('education_content_entry', 'region_id', 'education_region'),
('education_content_node', 'entry_id', 'education_content_entry'),
('education_content_node', 'parent_id', 'education_content_node'),
('education_question_collection', 'entry_id', 'education_content_entry'),
('education_question_collection', 'node_id', 'education_content_node'),
('education_question', 'subject_id', 'education_subject'),
('education_question', 'node_id', 'education_content_node'),
('education_practice_blueprint', 'entry_id', 'education_content_entry'),
('education_practice_blueprint', 'node_id', 'education_content_node'),
('education_practice_blueprint', 'collection_id', 'education_question_collection'),
('education_question_collection_question', 'collection_id', 'education_question_collection'),
('education_question_collection_question', 'question_id', 'education_question')
) AS catalog_edge(child_table, reference_column, parent_table)
LOOP
EXECUTE format(
'SELECT EXISTS (' ||
'SELECT 1 FROM %1$I.%2$I child ' ||
'JOIN %1$I.%3$I parent ON parent.id = child.%4$I ' ||
'WHERE child.%4$I IS NOT NULL AND NOT (' ||
' (child.scope = ''PUBLIC'' AND child.tenant_id = 0 ' ||
' AND parent.scope = ''PUBLIC'' AND parent.tenant_id = 0) OR ' ||
' (child.scope = ''TENANT_OWNED'' AND child.tenant_id > 0 AND (' ||
' (parent.scope = ''PUBLIC'' AND parent.tenant_id = 0) OR ' ||
' (parent.scope = ''TENANT_OWNED'' AND parent.tenant_id = child.tenant_id)' ||
' ))' ||
'))',
catalog_schema, edge.child_table, edge.parent_table, edge.reference_column)
INTO violation_exists;
IF violation_exists THEN
RAISE EXCEPTION 'existing catalog graph violates scope rules at %.%',
edge.child_table, edge.reference_column
USING ERRCODE = '23514';
END IF;
END LOOP;
END $$;
-- Verification examples:
-- SELECT tgname FROM pg_trigger
-- WHERE tgname LIKE 'trg_education_%_reference_scope' AND NOT tgisinternal ORDER BY tgname;
-- SELECT conname FROM pg_constraint
-- WHERE conname LIKE 'uk_education_%_tenant_id_id' ORDER BY conname;