fix(education): enforce migration role separation

This commit is contained in:
2026-07-31 16:26:39 +08:00
parent 26f9bcf696
commit 4573c41c0e
9 changed files with 349 additions and 13 deletions

View File

@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.education.config;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.util.List;
/**
* Verifies that the runtime database role cannot forge protected lifecycle tokens.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class EducationProtectedTokenRoleValidator implements ApplicationRunner {
private static final List<String> PROTECTED_TABLES = List.of(
"education_question_lifecycle_transition_token",
"education_content_node_lifecycle_transition_token",
"education_question_collection_lifecycle_transition_token",
"education_question_collection_membership_token");
private final DataSource dataSource;
@Override
public void run(ApplicationArguments args) {
validate(dataSource);
}
public static void validate(DataSource dataSource) {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
List<String> unsafeTables = jdbcTemplate.queryForList("""
SELECT protected.table_name
FROM (VALUES (?), (?), (?), (?)) AS protected(table_name)
LEFT JOIN pg_namespace namespace ON namespace.nspname = current_schema()
LEFT JOIN pg_class token_table
ON token_table.relnamespace = namespace.oid
AND token_table.relname = protected.table_name
AND token_table.relkind IN ('r', 'p')
WHERE token_table.oid IS NULL
OR pg_has_role(current_user, token_table.relowner, 'MEMBER')
OR has_table_privilege(current_user, token_table.oid, 'INSERT,UPDATE,DELETE,TRUNCATE')
ORDER BY protected.table_name
""", String.class, PROTECTED_TABLES.toArray());
if (!unsafeTables.isEmpty()) {
throw new IllegalStateException("Education runtime database role must not own or write protected token tables: "
+ String.join(", ", unsafeTables));
}
}
}

View File

@@ -0,0 +1,92 @@
-- EDU-006 follow-up: fail closed when adopted Practice tables have incompatible scalar shapes.
-- Published migrations V4030-V4060 remain immutable; this validates the post-adoption contract forward-only.
DO $$
DECLARE
mismatch TEXT;
BEGIN
WITH expected(table_name, column_name, data_type, nullable, min_length) AS (
VALUES
('education_practice_session','id','bigint',false,NULL::INTEGER),
('education_practice_session','tenant_id','bigint',false,NULL),
('education_practice_session','user_id','bigint',false,NULL),
('education_practice_session','client_session_id','character varying',false,36),
('education_practice_session','status','character varying',false,20),
('education_practice_session','question_count','integer',false,NULL),
('education_practice_session','version','integer',false,NULL),
('education_practice_question','id','bigint',false,NULL),
('education_practice_question','tenant_id','bigint',false,NULL),
('education_practice_question','session_id','bigint',false,NULL),
('education_practice_question','sequence','integer',false,NULL),
('education_practice_question','question_id','character varying',false,64),
('education_practice_question','content_version','character varying',false,64),
('education_practice_question','stem','text',false,NULL),
('education_practice_question','type','character varying',false,32),
('education_practice_question','options','jsonb',false,NULL),
('education_practice_question','is_answered','boolean',false,NULL),
('education_practice_report','id','bigint',false,NULL),
('education_practice_report','tenant_id','bigint',false,NULL),
('education_practice_report','user_id','bigint',false,NULL),
('education_practice_report','session_id','bigint',false,NULL),
('education_practice_report','question_count','integer',false,NULL),
('education_practice_report','score','integer',false,NULL),
('education_practice_report_detail','id','bigint',false,NULL),
('education_practice_report_detail','tenant_id','bigint',false,NULL),
('education_practice_report_detail','user_id','bigint',false,NULL),
('education_practice_report_detail','report_id','bigint',false,NULL),
('education_practice_report_detail','session_id','bigint',false,NULL),
('education_practice_report_detail','question_id','character varying',false,64),
('education_practice_report_detail','sequence','integer',false,NULL),
('education_practice_report_detail','stem','text',false,NULL),
('education_practice_report_detail','type','character varying',false,32),
('education_practice_report_detail','is_correct','boolean',false,NULL),
('education_wrong_question','id','bigint',false,NULL),
('education_wrong_question','tenant_id','bigint',false,NULL),
('education_wrong_question','user_id','bigint',false,NULL),
('education_wrong_question','question_id','character varying',false,64),
('education_wrong_question','stem','text',false,NULL),
('education_wrong_question','type','character varying',false,32),
('education_wrong_question','options','jsonb',false,NULL),
('education_wrong_question','first_wrong_time','timestamp without time zone',false,NULL),
('education_wrong_question','last_wrong_time','timestamp without time zone',false,NULL),
('education_wrong_question','wrong_count','integer',false,NULL),
('education_wrong_question','master_status','character varying',false,20),
('education_favorite','id','bigint',false,NULL),
('education_favorite','tenant_id','bigint',false,NULL),
('education_favorite','user_id','bigint',false,NULL),
('education_favorite','target_type','character varying',false,32),
('education_favorite','target_id','character varying',false,64),
('education_favorite','content_version','character varying',false,64),
('education_favorite','available','boolean',false,NULL),
('education_idempotency','id','bigint',false,NULL),
('education_idempotency','tenant_id','bigint',false,NULL),
('education_idempotency','user_id','bigint',false,NULL),
('education_idempotency','operation','character varying',false,32),
('education_idempotency','idempotency_key','character varying',false,64),
('education_idempotency','request_hash','character varying',false,64),
('education_idempotency','session_id','bigint',false,NULL),
('education_idempotency','status','character varying',false,20),
('education_idempotency','business_payload','jsonb',true,NULL)
), actual AS (
SELECT e.*, c.data_type AS actual_type, c.is_nullable = 'YES' AS actual_nullable,
c.character_maximum_length AS actual_length
FROM expected e
LEFT JOIN information_schema.columns c
ON c.table_schema = current_schema() AND c.table_name = e.table_name
AND c.column_name = e.column_name
)
SELECT STRING_AGG(format('%s.%s expected %s%s%s but found %s%s', table_name, column_name, data_type,
CASE WHEN nullable THEN '' ELSE ' NOT NULL' END,
CASE WHEN min_length IS NULL THEN '' ELSE format(' length >= %s', min_length) END,
COALESCE(actual_type, 'MISSING'),
CASE WHEN actual_type IS NULL THEN '' ELSE format(' nullable=%s length=%s', actual_nullable,
COALESCE(actual_length::TEXT, 'n/a')) END), '; ' ORDER BY table_name, column_name)
INTO mismatch
FROM actual
WHERE actual_type IS NULL OR actual_type <> data_type OR (NOT nullable AND actual_nullable)
OR (min_length IS NOT NULL AND COALESCE(actual_length, 0) < min_length);
IF mismatch IS NOT NULL THEN
RAISE EXCEPTION 'Incompatible adopted Education Practice schema: %', mismatch;
END IF;
END $$;

View File

@@ -0,0 +1,57 @@
-- Harden Education classroom tenant triggers and reconcile the System notify-template sequence.
-- V4140 and V4160 remain immutable release history.
CREATE OR REPLACE FUNCTION education_validate_class_relationship_tenant()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $$
DECLARE owner_tenant BIGINT;
BEGIN
EXECUTE format('SELECT tenant_id FROM %I.education_class WHERE id = $1 AND deleted = false', TG_TABLE_SCHEMA)
INTO owner_tenant USING NEW.class_id;
IF owner_tenant IS NULL OR owner_tenant <> NEW.tenant_id THEN
RAISE EXCEPTION 'education class relationship cannot cross tenants';
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION education_validate_class_relationship_tenant() FROM PUBLIC;
CREATE OR REPLACE FUNCTION education_validate_invitation_audit_tenant()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $$
DECLARE owner_tenant BIGINT;
BEGIN
EXECUTE format('SELECT tenant_id FROM %I.education_class_invitation WHERE id = $1', TG_TABLE_SCHEMA)
INTO owner_tenant USING NEW.invitation_id;
IF owner_tenant IS NULL OR owner_tenant <> NEW.tenant_id THEN
RAISE EXCEPTION 'education invitation audit cannot cross tenants';
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION education_validate_invitation_audit_tenant() FROM PUBLIC;
DO $$
DECLARE
sequence_name TEXT;
maximum_id BIGINT;
BEGIN
IF to_regclass('system_notify_template') IS NULL THEN
RETURN;
END IF;
SELECT pg_get_serial_sequence(format('%I.system_notify_template', current_schema()), 'id') INTO sequence_name;
IF sequence_name IS NULL THEN
sequence_name := CASE WHEN to_regclass('system_notify_template_seq') IS NOT NULL
THEN format('%I.system_notify_template_seq', current_schema()) ELSE NULL END;
END IF;
IF sequence_name IS NOT NULL THEN
SELECT COALESCE(MAX(id), 1) INTO maximum_id FROM system_notify_template;
PERFORM setval(sequence_name::regclass, maximum_id, true);
END IF;
END $$;

View File

@@ -0,0 +1,18 @@
-- EDU-010 forward fix: PUBLIC and tenant-owned collections are lifecycle-managed records.
-- V4110 already prevented tenant-owned physical deletion; replace the function without
-- changing the published migration checksum so adopted PUBLIC rows receive the same guard.
CREATE OR REPLACE FUNCTION education_prevent_question_collection_delete()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = pg_catalog, pg_temp
AS $$
BEGIN
IF OLD.scope IN ('PUBLIC', 'TENANT_OWNED') THEN
RAISE EXCEPTION 'collection cannot be physically deleted' USING ERRCODE = '23514';
END IF;
RETURN OLD;
END
$$;
REVOKE ALL ON FUNCTION education_prevent_question_collection_delete() FROM PUBLIC;

View File

@@ -1,8 +1,10 @@
package cn.iocoder.yudao.module.education.test;
import cn.iocoder.yudao.module.education.config.EducationProtectedTokenRoleValidator;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.postgresql.ds.PGSimpleDataSource;
import java.sql.Connection;
import java.sql.DriverManager;
@@ -119,7 +121,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", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170");
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170", "4180", "4190", "4200", "4210");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name = 'education_idempotency'"))
@@ -171,6 +173,31 @@ class EducationFlywayMigrationIntegrationTest {
.isEqualTo(2L);
}
@Test
void shouldRejectIncompatibleAdoptedPracticeScalarShape() throws SQLException {
String schema = createSchema("bad_shape");
createCompatibleManualPracticeFixture(schema);
execute(schema, "ALTER TABLE education_practice_session ALTER COLUMN tenant_id DROP NOT NULL");
assertThatThrownBy(() -> configureFlyway(schema, true).load().migrate())
.hasMessageContaining("Incompatible adopted Education Practice schema")
.hasMessageContaining("education_practice_session.tenant_id");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM flyway_schema_history WHERE version = '4180' AND success = TRUE"))
.isZero();
}
@Test
void shouldRejectUndersizedAdoptedPracticeIdentifier() throws SQLException {
String schema = createSchema("bad_length");
createCompatibleManualPracticeFixture(schema);
execute(schema, "ALTER TABLE education_practice_session ALTER COLUMN client_session_id TYPE VARCHAR(16)");
assertThatThrownBy(() -> configureFlyway(schema, true).load().migrate())
.hasMessageContaining("Incompatible adopted Education Practice schema")
.hasMessageContaining("education_practice_session.client_session_id");
}
@Test
void shouldCreateClassRelationshipSchemaAndEnforceTenantBoundary() throws SQLException {
String schema = createSchema("classes");
@@ -211,7 +238,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", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170");
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110", "4120", "4130", "4140", "4150", "4160", "4170", "4180", "4190", "4200", "4210");
assertThat(queryStrings(schema,
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = current_schema() AND table_name IN (" +
@@ -748,6 +775,62 @@ class EducationFlywayMigrationIntegrationTest {
}
}
@Test
void shouldRejectPhysicalDeleteOfAdoptedEmptyPublicCollectionForRuntimeRole() throws SQLException {
String schema = createSchema("public_collection_delete");
configureFlyway(schema, false).target("4100").load().migrate();
execute(schema, """
INSERT INTO education_content_entry(id,tenant_id,scope,entry_key,name,entry_type)
VALUES(100,0,'PUBLIC','questions','Questions','question');
INSERT INTO education_content_node(id,tenant_id,scope,entry_id,name,node_type,publication_status,is_active,is_hidden)
VALUES(200,0,'PUBLIC',100,'Public node','category','ACTIVE',true,false);
INSERT INTO education_question_collection(id,tenant_id,scope,entry_id,node_id,name,collection_type,question_count,is_active,is_hidden)
VALUES(300,0,'PUBLIC',100,200,'Adopted empty','MANUAL',0,true,false);
""");
configureFlyway(schema, false).load().migrate();
String runtimeRole = "edu_delete_runtime_" + UUID.randomUUID().toString().replace("-", "");
try {
execute(schema, "CREATE ROLE " + runtimeRole + " LOGIN PASSWORD 'runtime_test'");
execute(schema, "GRANT USAGE ON SCHEMA " + schema + " TO " + runtimeRole);
execute(schema, "GRANT SELECT, DELETE ON " + schema + ".education_question_collection TO " + runtimeRole);
try (Connection connection = DriverManager.getConnection(jdbcUrl(schema), runtimeRole, "runtime_test");
var statement = connection.createStatement()) {
assertThatThrownBy(() -> statement.execute("DELETE FROM education_question_collection WHERE id=300"))
.hasMessageContaining("collection cannot be physically deleted");
}
} finally {
execute(schema, "DROP OWNED BY " + runtimeRole + "; DROP ROLE " + runtimeRole);
}
}
@Test
void shouldRejectRuntimeRoleThatOwnsProtectedTokenTablesAndAcceptSeparatedRole() throws SQLException {
String schema = createSchema("token_role_separation");
configureFlyway(schema, false).load().migrate();
PGSimpleDataSource ownerDataSource = dataSource(schema, USER, PASSWORD);
assertThatThrownBy(() -> EducationProtectedTokenRoleValidator.validate(ownerDataSource))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("education_question_lifecycle_transition_token")
.hasMessageContaining("education_content_node_lifecycle_transition_token")
.hasMessageContaining("education_question_collection_lifecycle_transition_token")
.hasMessageContaining("education_question_collection_membership_token");
String runtimeRole = "edu_safe_runtime_" + UUID.randomUUID().toString().replace("-", "");
try {
execute(schema, "CREATE ROLE " + runtimeRole + " LOGIN PASSWORD 'runtime_test'");
execute(schema, "GRANT USAGE ON SCHEMA " + schema + " TO " + runtimeRole);
execute(schema, "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA " + schema + " TO " + runtimeRole);
execute(schema, "REVOKE ALL ON " + schema + ".education_question_lifecycle_transition_token, "
+ schema + ".education_content_node_lifecycle_transition_token, "
+ schema + ".education_question_collection_lifecycle_transition_token, "
+ schema + ".education_question_collection_membership_token FROM " + runtimeRole);
EducationProtectedTokenRoleValidator.validate(dataSource(schema, runtimeRole, "runtime_test"));
} finally {
execute(schema, "DROP OWNED BY " + runtimeRole + "; DROP ROLE " + runtimeRole);
}
}
@Test
void shouldUseMigrationLocksThatBlockMembershipAndQuestionWrites() throws SQLException {
String schema = createSchema("collection_lock_modes");
@@ -1335,6 +1418,14 @@ class EducationFlywayMigrationIntegrationTest {
}
}
private static PGSimpleDataSource dataSource(String schema, String user, String password) {
PGSimpleDataSource dataSource = new PGSimpleDataSource();
dataSource.setUrl(jdbcUrl(schema));
dataSource.setUser(user);
dataSource.setPassword(password);
return dataSource;
}
private static String jdbcUrl(String schema) {
return adminUrl() + "?currentSchema=" + schema + "&stringtype=unspecified";
}

View File

@@ -47,15 +47,21 @@ public abstract class PostgreSqlDbIntegrationTest {
private static final String USER = requiredEnv("EDU_TEST_POSTGRES_USER");
private static final String PASSWORD = requiredEnv("EDU_TEST_POSTGRES_PASSWORD");
private static final String SCHEMA = "edu_test_" + UUID.randomUUID().toString().replace("-", "");
private static final String ROLE_SUFFIX = UUID.randomUUID().toString().replace("-", "");
private static final String FLYWAY_ROLE = "edu_flyway_" + ROLE_SUFFIX;
private static final String RUNTIME_ROLE = "edu_runtime_" + ROLE_SUFFIX;
private static final String ROLE_PASSWORD = "education_test";
@BeforeAll
static void createSchema() throws SQLException {
try (var connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute("CREATE SCHEMA " + SCHEMA);
statement.execute("CREATE ROLE " + FLYWAY_ROLE + " LOGIN PASSWORD '" + ROLE_PASSWORD + "'");
statement.execute("CREATE ROLE " + RUNTIME_ROLE + " LOGIN PASSWORD '" + ROLE_PASSWORD + "'");
statement.execute("CREATE SCHEMA " + SCHEMA + " AUTHORIZATION " + FLYWAY_ROLE);
}
Flyway.configure()
.dataSource(jdbcUrl(), USER, PASSWORD)
.dataSource(jdbcUrl(FLYWAY_ROLE), FLYWAY_ROLE, ROLE_PASSWORD)
.locations("classpath:db/migration/education")
.schemas(SCHEMA)
.defaultSchema(SCHEMA)
@@ -65,6 +71,16 @@ public abstract class PostgreSqlDbIntegrationTest {
.outOfOrder(false)
.load()
.migrate();
try (var connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute("GRANT USAGE ON SCHEMA " + SCHEMA + " TO " + RUNTIME_ROLE);
statement.execute("GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA " + SCHEMA + " TO " + RUNTIME_ROLE);
statement.execute("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA " + SCHEMA + " TO " + RUNTIME_ROLE);
statement.execute("REVOKE ALL ON " + SCHEMA + ".education_question_lifecycle_transition_token, "
+ SCHEMA + ".education_content_node_lifecycle_transition_token, "
+ SCHEMA + ".education_question_collection_lifecycle_transition_token, "
+ SCHEMA + ".education_question_collection_membership_token FROM " + RUNTIME_ROLE);
}
}
@AfterAll
@@ -72,6 +88,10 @@ public abstract class PostgreSqlDbIntegrationTest {
try (var connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute("DROP SCHEMA IF EXISTS " + SCHEMA + " CASCADE");
statement.execute("DROP OWNED BY " + RUNTIME_ROLE);
statement.execute("DROP ROLE " + RUNTIME_ROLE);
statement.execute("DROP OWNED BY " + FLYWAY_ROLE);
statement.execute("DROP ROLE " + FLYWAY_ROLE);
}
}
@@ -79,14 +99,18 @@ public abstract class PostgreSqlDbIntegrationTest {
static void postgresqlProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", PostgreSqlDbIntegrationTest::jdbcUrl);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.datasource.username", () -> USER);
registry.add("spring.datasource.password", () -> PASSWORD);
registry.add("spring.datasource.username", () -> RUNTIME_ROLE);
registry.add("spring.datasource.password", () -> ROLE_PASSWORD);
registry.add("spring.sql.init.mode", () -> "never");
}
private static String jdbcUrl() {
return jdbcUrl(RUNTIME_ROLE);
}
private static String jdbcUrl(String role) {
return "jdbc:postgresql://" + HOST + ":" + PORT + "/" + DATABASE
+ "?currentSchema=" + SCHEMA + "&stringtype=unspecified";
+ "?currentSchema=" + SCHEMA + "&stringtype=unspecified&ApplicationName=" + role;
}
private static String adminUrl() {

View File

@@ -16,7 +16,6 @@ TRUNCATE TABLE
education_content_import_asset,
education_question_collection_lifecycle_audit,
education_content_node_lifecycle_audit,
education_question_lifecycle_transition_token,
education_question_lifecycle_audit,
education_question_version,
education_question_collection_question,
@@ -33,4 +32,4 @@ TRUNCATE TABLE
education_content_node,
education_content_entry,
education_subject
RESTART IDENTITY CASCADE;
CASCADE;

View File

@@ -26,8 +26,8 @@ spring:
flyway:
enabled: true
url: ${FLYWAY_URL:${spring.datasource.dynamic.datasource.master.url}}
user: ${FLYWAY_USER:${spring.datasource.dynamic.datasource.master.username}}
password: ${FLYWAY_PASSWORD:${spring.datasource.dynamic.datasource.master.password}}
user: ${FLYWAY_USER}
password: ${FLYWAY_PASSWORD}
locations: classpath:db/migration
table: flyway_schema_history
baseline-on-migrate: true

View File

@@ -26,8 +26,8 @@ spring:
flyway:
enabled: true
url: ${FLYWAY_URL:${spring.datasource.dynamic.datasource.master.url}}
user: ${FLYWAY_USER:${spring.datasource.dynamic.datasource.master.username}}
password: ${FLYWAY_PASSWORD:${spring.datasource.dynamic.datasource.master.password}}
user: ${FLYWAY_USER}
password: ${FLYWAY_PASSWORD}
locations: classpath:db/migration
table: flyway_schema_history
baseline-on-migrate: true