diff --git a/docs/education/migration/07-decisions.md b/docs/education/migration/07-decisions.md index 9115f62f..d0b7bd71 100644 --- a/docs/education/migration/07-decisions.md +++ b/docs/education/migration/07-decisions.md @@ -49,18 +49,70 @@ The durable artifact classification, adoption matrix, version allocation, backfi 12. Do not expose paid/private practice until entitlement semantics and public target contracts are decided. 13. No tests, builds, PostgreSQL connections, Flyway execution, or runtime verification were performed by the Phase 0 assessment unless a later ticket explicitly records otherwise. +## Resolved decisions (post-Phase 0) + +### Provider-authority decision (resolved 2026-07-30) + +**Decision:** Coexistence with Scalar as the authoritative primary. ScalarCatalogProvider (HTTP data source, `SCALAR_READ`) is the authoritative primary for catalog read operations. JavaCatalogProvider (PostgreSQL direct, `JAVA_READ`) is the conditionally-activated secondary for tenants that have completed a catalog data migration into native PostgreSQL tables. + +**Rules:** +1. `SCALAR_READ` is the default and authoritative mode. It is the source of real tenant catalog content from the running tiku-backend NestJS service. +2. `JAVA_READ` is conditionally activated only when `yudao.education.catalog-mode=JAVA_READ` is explicitly set. +3. Both providers implement the same `CatalogProvider` + `QuestionCatalogProvider` interfaces; `QuestionContentSafety` and `QuestionCatalogServiceImpl` enforce provider-neutral fail-closed rules. +4. JavaCatalogProvider must not be promoted to default without: (a) runtime-verified V4020 catalog tables, (b) dedicated contract test suite, (c) mapper audit of `TenantUtils.executeIgnore` boundary. +5. Provider mode is currently global; per-tenant override is a future concern. + +**Rationale:** Scalar is the running production data source with 1084 lines of dedicated tests; Java native catalog is dirty, uncommitted, runtime-unverified. The code already implements coexistence through the provider interface — this decision formalizes the existing architecture. + +**Actions:** +- Document as ADR in `docs/education/migration/decisions/provider-authority.md` +- Add `JavaCatalogProviderTest` contract test suite +- Add provider-neutral integration test for equivalent safe projections +- Update `CatalogProviderMode` Javadoc + +**Evidence:** Multi-agent code audit of ScalarCatalogProvider (1084 lines of tests, 13 catalog endpoints, comprehensive error handling), JavaCatalogProvider (0 dedicated tests, `TenantUtils.executeIgnore` pattern, dirty V4020), provider switch mechanism. + +### PUBLIC graph-semantics decision (resolved 2026-07-30) + +**Decision:** Combination enforcement via database triggers, immutable ownership scope, and application-layer validation. + +**Rules:** +1. **PUBLIC rows (tenant_id=0, scope='PUBLIC')** may only reference PUBLIC parents. PUBLIC is a self-contained content tree. +2. **Tenant-owned rows (tenant_id>0, scope='TENANT_OWNED')** may reference PUBLIC rows — this is the primary content-sharing mechanism. +3. **Tenant-owned rows referencing other-tenant rows** is forbidden. +4. Composite foreign keys `(tenant_id, parent_id)` are NOT used because the `tenant_id=0` sentinel naturally breaks FK matching for tenant→PUBLIC references. +5. A catalog row's `tenant_id` and `scope` are immutable after insert. Moving content between tenant-owned and PUBLIC scope requires an explicit copy/adoption workflow; publication changes lifecycle, not ownership. + +**Enforcement strategy (combination):** +- **Database triggers** (primary guard): `education_check_reference_scope()` function with BEFORE INSERT/UPDATE triggers on every FK column of every catalog table. SECURITY DEFINER, fail-closed on violation. +- **Ownership immutability triggers** on all 11 catalog tables prevent parent mutations from invalidating existing references and remove the concurrent child-insert/parent-move race. +- **Application-layer validation** in service write paths (first line of defense). +- **Migration pre-validation** DO block that fails if existing data contains cross-scope violations. + +**Flyway delivery:** +- V4070: Create reference and ownership-scope guard functions, attach triggers, then run historical pre-validation while migration DDL locks prevent concurrent writes. + +**Rationale:** Legacy Supabase enforced tenant graph integrity via RLS policies and staged composite FK migration. The target's `tenant_id=0` sentinel breaks composite FK for tenant→PUBLIC references — only triggers handle all three reference scenarios correctly. Application-only enforcement is insufficient per fail-closed requirements (GOAL.md §2.3). Implementation review rejected redundant `UNIQUE (tenant_id, id)` constraints because V4020 IDs are already globally unique primary keys and no current query, conflict target, or composite FK consumes those indexes. + +**Actions:** +- Done: create V4070 (19 reference guards, 11 immutable-scope guards, and historical pre-validation) via `flyway-postgresql` +- Add application-layer scope validation in catalog write services +- Extend `CatalogScopeQuery` with explicit tenant_id/disallowed-scope predicates +- Add focused integration tests for all reference scenarios + +**Evidence:** Schema graph analysis of V4020 (11 catalog tables, all single-column FKs, no composite tenant enforcement), legacy RLS/trigger audit (Supabase RLS + staged composite FK migration), current CHECK constraints provide row-level but not cross-row enforcement. + ## Unresolved decisions 1. The valid option schema for each question type, including whether absent options are legal; whether malformed published content is omitted or produces a controlled source failure. -2. Whether PUBLIC tenant_id=0 rows may reference only PUBLIC parents, whether tenant-owned rows may reference global rows, and the precise composite constraint/trigger strategy. -3. Whether untracked /Users/tiku1/code/ruoyi-vue-pro/sql/postgresql/education files are intended for promotion into Flyway or are design/manual artifacts. -4. Whether V4010/V4020 or any manual core-loop DDL has ever run successfully in PostgreSQL; no runtime migration evidence exists. -5. Which Auth/Profile/extended Learning semantics are replaced by Member/System/Infra versus Education-owned, including vocabulary, leaderboard, stats, trend, feedback, exam dates, notifications, points, and badges. -6. Whether tenant appearance, domains, payment accounts, auth providers, secrets, activation codes, coupons, integrations, marketing, public-bank grants, and sync are in scope or explicitly retired. -7. Whether legacy assets are migrated, re-uploaded, re-scanned, or retired, and who owns ClamAV/scanner integration. -8. Which legacy RLS, triggers, functions, grants, seeds, queue leases, retry behavior, and operational semantics are contractual and need Java/constraint/event/job reproduction. -9. Whether the ten required Phase 0 artifacts must be committed files or may remain in reviewed scratch form during discovery. -10. Whether a future System-owned immutable Tenant Code or signed bootstrap locator is required beyond the accepted public-handle/existence-disclosure contract. +2. Whether untracked /Users/tiku1/code/ruoyi-vue-pro/sql/postgresql/education files are intended for promotion into Flyway or are design/manual artifacts. +3. Whether V4010/V4020 or any manual core-loop DDL has ever run successfully in PostgreSQL; no runtime migration evidence exists. +4. Which Auth/Profile/extended Learning semantics are replaced by Member/System/Infra versus Education-owned, including vocabulary, leaderboard, stats, trend, feedback, exam dates, notifications, points, and badges. +5. Whether tenant appearance, domains, payment accounts, auth providers, secrets, activation codes, coupons, integrations, marketing, public-bank grants, and sync are in scope or explicitly retired. +6. Whether legacy assets are migrated, re-uploaded, re-scanned, or retired, and who owns ClamAV/scanner integration. +7. Which legacy RLS, triggers, functions, grants, seeds, queue leases, retry behavior, and operational semantics are contractual and need Java/constraint/event/job reproduction. +8. Whether the ten required Phase 0 artifacts must be committed files or may remain in reviewed scratch form during discovery. +9. Whether a future System-owned immutable Tenant Code or signed bootstrap locator is required beyond the accepted public-handle/existence-disclosure contract. ## Decision rule diff --git a/docs/education/migration/issues/EDU-010-content-publication.md b/docs/education/migration/issues/EDU-010-content-publication.md index 8d0fe555..b83d839e 100644 --- a/docs/education/migration/issues/EDU-010-content-publication.md +++ b/docs/education/migration/issues/EDU-010-content-publication.md @@ -1,9 +1,9 @@ # EDU-010 — Tenant content publication and graph integrity -- **Status:** blocked +- **Status:** in progress — catalog graph-integrity slice delivered; authoring/publication lifecycle remains - **Type:** implementation program - **Phase:** 3 -- **Blockers:** EDU-004, EDU-009, provider-authority decision, PUBLIC graph-semantics decision +- **Blockers:** EDU-004 ✓ (done), EDU-009 ✓ (done), provider-authority decision ✓ (resolved 2026-07-30, see decisions.md), PUBLIC graph-semantics decision ✓ (resolved 2026-07-30, see decisions.md) ## Tenant-admin outcome @@ -21,11 +21,36 @@ Authorized tenant administrators can author, classify, publish, archive, and ret ## Acceptance criteria - [ ] Admin permission and data-scope matrix is explicit. -- [ ] Cross-tenant graph relationships cannot be persisted. -- [ ] PUBLIC and tenant-owned reference rules are enforced and tested. +- [x] Cross-tenant graph relationships cannot be persisted in the current V4020 catalog graph. +- [x] PUBLIC and tenant-owned reference rules are enforced across the current 19 reference edges, with exact trigger-mapping and focused behavior tests. - [ ] Unpublished/archived content is never student-visible. - [ ] Publication is transactional and auditable. -- [ ] Database changes use `flyway-postgresql` and forward migrations. +- [x] Database graph-integrity changes use `flyway-postgresql` and forward migration V4070. + +## Delivery progress — graph-integrity slice (2026-07-30) + +Delivered: + +- `V4070__enforce_catalog_reference_scope.sql` guards all 19 current foreign-key edges and makes ownership scope immutable on all 11 catalog tables: + - PUBLIC children may reference only PUBLIC parents; + - tenant-owned children may reference PUBLIC or same-tenant parents; + - cross-tenant and PUBLIC-to-tenant references fail closed; + - `tenant_id` and `scope` cannot change after insert, so parent mutations and concurrent ownership moves cannot invalidate existing children. +- V4070 installs write guards before historical pre-validation, eliminating the migration-time validation/write window. The reference trigger function uses a fixed `search_path`, locks referenced rows during validation, and exposes no PUBLIC execute grant. +- Real PostgreSQL/Flyway integration tests cover fresh and 4009-baselined migration histories, allowed and rejected references, parent ownership immutability, exact configuration of all 19 reference guards and 11 scope guards, function ACLs, and historical invalid-data failure. + +No non-Education module changed. Application rollback can disable native authoring when it is introduced; database recovery remains a higher forward migration and must preserve existing content. + +## Remaining publication slice + +Do not add a local-only admin write path while `SCALAR_READ` remains the default authoritative provider: a successful PostgreSQL write would not be visible to students. The next bounded slice is a `JAVA_READ`-only tenant question `draft → publish → archive → student read` path that fails closed in unsupported provider modes. + +Before its Flyway/application implementation, resolve and document: + +1. the exact `DRAFT/PUBLISHED/ARCHIVED/RETIRED` state machine and mapping of V4020 `HIDDEN/INACTIVE` rows; +2. immutable question-version and transactional publication-audit ownership; +3. whether ancestor entry/node/collection/archive state makes a question unavailable to fresh student reads; +4. the explicit tenant-admin versus platform-curator permission/data-scope matrix. ## Risk and rollback diff --git a/docs/education/migration/issues/README.md b/docs/education/migration/issues/README.md index 9104d3b5..2839a821 100644 --- a/docs/education/migration/issues/README.md +++ b/docs/education/migration/issues/README.md @@ -36,7 +36,7 @@ EDU-000 Phase 0 artifacts done └── EDU-005 PostgreSQL/Flyway takeover decision done EDU-009 + provider/content decisions - └── EDU-010 Tenant content publication blocked + └── EDU-010 Tenant content publication in progress (graph integrity delivered; lifecycle remains) └── EDU-011 Import/export/assets/scanning blocked EDU-004 @@ -52,7 +52,8 @@ All owner/contract decisions ## Recommended execution order -1. Select the next unblocked content-management decision/ticket after EDU-009. +1. **EDU-010** — Continue with the provider-consistent question publication lifecycle; V4070 graph integrity is delivered. +2. Continue through EDU-011/EDU-012 as their blockers clear. ## Phase 0 completion caveat diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md index 70676a43..6bb7e852 100644 --- a/yudao-module-education/README.md +++ b/yudao-module-education/README.md @@ -179,6 +179,7 @@ yudao-module-education/src/main/resources/db/migration/education/ - `V4010` 和 `V4020` 是不可变迁移历史,不得修改。 - `V4030` 创建或接管 Practice 核心闭环表,并在存在旧答案/交卷幂等表时向统一 `education_idempotency` 回填数据。 +- `V4070` 对 19 条目录引用边执行历史预校验并安装写入守卫,同时将 11 张目录表的 `tenant_id/scope` 设为插入后不可变,阻止 PUBLIC→租户、跨租户以及父节点归属变更造成的非法关系。 - 旧 `education_answer_idempotency`、`education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。 - `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。 - 禁止使用 `flyway clean` 或 `*-rollback.sql` 回退共享环境。应用回滚后如有 Schema 兼容问题,通过更高版本向前修复。 diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4070__enforce_catalog_reference_scope.sql b/yudao-module-education/src/main/resources/db/migration/education/V4070__enforce_catalog_reference_scope.sql new file mode 100644 index 00000000..0d42ac34 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4070__enforce_catalog_reference_scope.sql @@ -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; diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java index 2ec0eb21..3fd0a410 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java @@ -108,6 +108,11 @@ class QuestionControllerHttpTest { .type("choice") .difficulty("easy") .isPublished(true) + .options(List.of( + cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.QuestionOptionDTO + .builder().label("A").content("Answer A").build(), + cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.QuestionOptionDTO + .builder().label("B").content("Answer B").build())) .build(); when(provider.getQuestion("q1")).thenReturn(dto); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java index 95824b08..fbd1c88c 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java @@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest { assertThat(queryStrings(schema, "SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank")) - .containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060"); + .containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070"); assertThat(queryLong(schema, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " + "AND table_name = 'education_idempotency'")) @@ -181,7 +181,7 @@ class EducationFlywayMigrationIntegrationTest { assertThat(queryStrings(schema, "SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank")) - .containsExactly("4010", "4020", "4030", "4040", "4050", "4060"); + .containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070"); assertThat(queryStrings(schema, "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = current_schema() AND table_name IN (" + @@ -230,6 +230,181 @@ class EducationFlywayMigrationIntegrationTest { .isEqualTo(8L); } + @Test + void shouldEnforcePublicAndTenantCatalogGraphRules() throws SQLException { + String schema = createSchema("catalog_graph"); + configureFlyway(schema, false).load().migrate(); + + execute(schema, """ + INSERT INTO education_region (id, tenant_id, scope, name) + VALUES (100, 0, 'PUBLIC', 'Public region'), + (101, 10, 'TENANT_OWNED', 'Tenant 10 region'), + (102, 20, 'TENANT_OWNED', 'Tenant 20 region'); + + INSERT INTO education_school (tenant_id, scope, region_id, name) + VALUES (0, 'PUBLIC', 100, 'Public school'), + (10, 'TENANT_OWNED', 100, 'Tenant school using public region'), + (10, 'TENANT_OWNED', 101, 'Tenant school using own region'); + """); + + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO education_school (tenant_id, scope, region_id, name) + VALUES (0, 'PUBLIC', 101, 'Invalid public school'); + """)) + .hasMessageContaining("PUBLIC catalog row") + .hasMessageContaining("education_school.region_id"); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO education_school (tenant_id, scope, region_id, name) + VALUES (10, 'TENANT_OWNED', 102, 'Cross-tenant school'); + """)) + .hasMessageContaining("cross-tenant catalog reference") + .hasMessageContaining("education_school.region_id"); + } + + @Test + void shouldAttachGraphGuardToEveryCatalogReference() throws SQLException { + String schema = createSchema("catalog_triggers"); + configureFlyway(schema, false).load().migrate(); + + assertThat(queryLong(schema, """ + WITH expected(table_name, trigger_name, trigger_arguments) AS (VALUES + ('education_school', 'trg_education_school_region_reference_scope', + 'region_id|education_region|'), + ('education_major', 'trg_education_major_region_reference_scope', + 'region_id|education_region|'), + ('education_major', 'trg_education_major_school_reference_scope', + 'school_id|education_school|'), + ('education_subject', 'trg_education_subject_region_reference_scope', + 'region_id|education_region|'), + ('education_subject', 'trg_education_subject_school_reference_scope', + 'school_id|education_school|'), + ('education_subject', 'trg_education_subject_major_reference_scope', + 'major_id|education_major|'), + ('education_category', 'trg_education_category_subject_reference_scope', + 'subject_id|education_subject|'), + ('education_content_entry', 'trg_education_content_entry_region_reference_scope', + 'region_id|education_region|'), + ('education_content_node', 'trg_education_content_node_entry_reference_scope', + 'entry_id|education_content_entry|'), + ('education_content_node', 'trg_education_content_node_parent_reference_scope', + 'parent_id|education_content_node|'), + ('education_question_collection', 'trg_education_question_collection_entry_reference_scope', + 'entry_id|education_content_entry|'), + ('education_question_collection', 'trg_education_question_collection_node_reference_scope', + 'node_id|education_content_node|'), + ('education_question', 'trg_education_question_subject_reference_scope', + 'subject_id|education_subject|'), + ('education_question', 'trg_education_question_node_reference_scope', + 'node_id|education_content_node|'), + ('education_practice_blueprint', 'trg_education_practice_blueprint_entry_reference_scope', + 'entry_id|education_content_entry|'), + ('education_practice_blueprint', 'trg_education_practice_blueprint_node_reference_scope', + 'node_id|education_content_node|'), + ('education_practice_blueprint', 'trg_education_practice_blueprint_collection_reference_scope', + 'collection_id|education_question_collection|'), + ('education_question_collection_question', 'trg_education_qcq_collection_reference_scope', + 'collection_id|education_question_collection|'), + ('education_question_collection_question', 'trg_education_qcq_question_reference_scope', + 'question_id|education_question|') + ) + SELECT COUNT(*) + FROM expected + JOIN pg_namespace catalog_schema ON catalog_schema.nspname = current_schema() + JOIN pg_class catalog_table + ON catalog_table.relnamespace = catalog_schema.oid + AND catalog_table.relname = expected.table_name + JOIN pg_trigger trigger + ON trigger.tgrelid = catalog_table.oid + AND trigger.tgname = expected.trigger_name + AND NOT trigger.tgisinternal + JOIN pg_proc trigger_function + ON trigger_function.oid = trigger.tgfoid + AND trigger_function.pronamespace = catalog_schema.oid + AND trigger_function.proname = 'education_check_reference_scope' + WHERE replace(encode(trigger.tgargs, 'escape'), $$\\000$$, '|') = + expected.trigger_arguments + """)) + .isEqualTo(19L); + assertThat(queryLong(schema, """ + SELECT COUNT(*) + FROM pg_trigger trigger + JOIN pg_class catalog_table ON catalog_table.oid = trigger.tgrelid + JOIN pg_namespace catalog_schema ON catalog_schema.oid = catalog_table.relnamespace + WHERE catalog_schema.nspname = current_schema() + AND trigger.tgname LIKE 'trg_education_%_scope_immutable' + AND NOT trigger.tgisinternal + """)) + .isEqualTo(11L); + assertThat(queryLong(schema, """ + SELECT COUNT(*) + FROM information_schema.routine_privileges + WHERE specific_schema = current_schema() + AND routine_name IN ( + 'education_check_reference_scope', + 'education_prevent_catalog_scope_change') + AND grantee = 'PUBLIC' + AND privilege_type = 'EXECUTE' + """)) + .isZero(); + } + + @Test + void shouldRejectParentScopeChangeThatWouldInvalidateExistingGraph() throws SQLException { + String schema = createSchema("catalog_parent_update"); + configureFlyway(schema, false).load().migrate(); + execute(schema, """ + INSERT INTO education_region (id, tenant_id, scope, name) + VALUES (100, 0, 'PUBLIC', 'Public region'); + INSERT INTO education_school (tenant_id, scope, region_id, name) + VALUES (0, 'PUBLIC', 100, 'Public school'); + """); + + assertThatThrownBy(() -> execute(schema, """ + UPDATE education_region + SET tenant_id = 10, scope = 'TENANT_OWNED' + WHERE id = 100; + """)) + .hasMessageContaining("catalog ownership scope is immutable") + .hasMessageContaining("education_region"); + } + + @Test + void shouldKeepCatalogOwnershipScopeImmutable() throws SQLException { + String schema = createSchema("catalog_scope_immutable"); + configureFlyway(schema, false).load().migrate(); + execute(schema, """ + INSERT INTO education_region (id, tenant_id, scope, name) + VALUES (100, 10, 'TENANT_OWNED', 'Tenant region'); + """); + + assertThatThrownBy(() -> execute(schema, """ + UPDATE education_region + SET tenant_id = 20 + WHERE id = 100; + """)) + .hasMessageContaining("catalog ownership scope is immutable") + .hasMessageContaining("education_region"); + } + + @Test + void shouldFailClosedWhenExistingCatalogGraphViolatesScopeRules() throws SQLException { + String schema = createSchema("catalog_prevalidation"); + configureFlyway(schema, false).target("4060").load().migrate(); + execute(schema, """ + INSERT INTO education_region (id, tenant_id, scope, name) + VALUES (100, 10, 'TENANT_OWNED', 'Tenant region'); + INSERT INTO education_school (tenant_id, scope, region_id, name) + VALUES (0, 'PUBLIC', 100, 'Invalid public school'); + """); + + assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate()) + .hasMessageContaining("existing catalog graph violates scope rules") + .hasMessageContaining("education_school.region_id"); + assertThat(queryLong(schema, + "SELECT COUNT(*) FROM flyway_schema_history WHERE version = '4070' AND success = TRUE")) + .isZero(); + } + private void createCompatibleManualPracticeFixture(String schema) throws SQLException { execute(schema, """ CREATE TABLE education_practice_session (