Merge pull request #650 from iflytek/feature/identity-binding-contract-gate

feat(auth): enforce identity binding v2 contract gate
This commit is contained in:
XiaoSeS 2026-07-31 00:32:04 +08:00 • committed by GitHub
commit 50a35f3aa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 745 additions and 5 deletions

View file

@ -10,15 +10,29 @@ POSTGRES_PASSWORD="identity-v2-test-password"
POSTGRES_DB="identity_v2"
MAVEN_CACHE_DIR="${MAVEN_CACHE_DIR:-${HOME}/.m2}"
log() {
printf '[identity-binding-v2] %s\n' "$*"
}
cleanup() {
exit_code="$?"
if [[ "${exit_code}" -ne 0 ]]; then
log "failed with exit code ${exit_code}"
docker ps -a \
--filter "label=skillhub.test.run=${RUN_ID}" \
--format 'resource={{.Names}} status={{.Status}}' || true
docker logs "${POSTGRES_CONTAINER}" 2>&1 || true
fi
docker rm -f "${POSTGRES_CONTAINER}" >/dev/null 2>&1 || true
docker network rm "${NETWORK}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
log "creating isolated Docker network ${NETWORK}"
docker network create \
--label "skillhub.test.run=${RUN_ID}" \
"${NETWORK}" >/dev/null
log "starting isolated PostgreSQL container ${POSTGRES_CONTAINER}"
docker run -d \
--name "${POSTGRES_CONTAINER}" \
--label "skillhub.test.run=${RUN_ID}" \
@ -31,6 +45,7 @@ docker run -d \
-p 127.0.0.1::5432 \
postgres:16-alpine >/dev/null
log "waiting for PostgreSQL readiness"
for _ in $(seq 1 60); do
if docker exec "${POSTGRES_CONTAINER}" \
pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
@ -42,21 +57,29 @@ done
docker exec "${POSTGRES_CONTAINER}" \
pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
>/dev/null
log "PostgreSQL is ready"
run_test() {
test_class="$1"
flyway_target="${2:-}"
java_version=""
if command -v java >/dev/null 2>&1; then
java_version="$(java -version 2>&1 | head -n 1)"
java_version="$(java -version 2>&1)"
fi
if [[ "${java_version}" == *'"21.'* ]]; then
host_port="$(docker port "${POSTGRES_CONTAINER}" 5432/tcp \
| sed -n 's/.*://p')"
if [[ -z "${host_port}" ]]; then
log "Docker did not publish a PostgreSQL host port"
return 1
fi
log "running ${test_class} with host Java 21"
(
cd "${REPO_ROOT}/server"
IDENTITY_BINDING_V2_POSTGRES_URL="jdbc:postgresql://127.0.0.1:${host_port}/${POSTGRES_DB}" \
IDENTITY_BINDING_V2_POSTGRES_USERNAME="${POSTGRES_USER}" \
IDENTITY_BINDING_V2_POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \
IDENTITY_BINDING_V2_FLYWAY_TARGET="${flyway_target}" \
MAVEN_OPTS="-Xmx2g -XX:MaxMetaspaceSize=512m" \
./mvnw \
-pl skillhub-app \
@ -68,6 +91,7 @@ run_test() {
return
fi
log "running ${test_class} with containerized Java 21"
mkdir -p "${MAVEN_CACHE_DIR}"
docker run --rm \
--name "${RUN_ID}-java" \
@ -81,6 +105,7 @@ run_test() {
-e "IDENTITY_BINDING_V2_POSTGRES_URL=jdbc:postgresql://${POSTGRES_CONTAINER}:5432/${POSTGRES_DB}" \
-e "IDENTITY_BINDING_V2_POSTGRES_USERNAME=${POSTGRES_USER}" \
-e "IDENTITY_BINDING_V2_POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" \
-e "IDENTITY_BINDING_V2_FLYWAY_TARGET=${flyway_target}" \
-v "${REPO_ROOT}:/workspace" \
-v "${MAVEN_CACHE_DIR}:/tmp/skillhub-maven-home/.m2" \
-w /workspace/server \
@ -95,4 +120,10 @@ run_test() {
}
run_test IdentityBindingV2MigrationPostgresTest
run_test IdentityBindingV2PostgresIntegrationTest
run_test \
"IdentityBindingV2PostgresIntegrationTest#upgradesMixedVersionWriteAndPreservesLegacyReadColumn+concurrentFirstLoginConvergesOnOneBinding" \
45
run_test IdentityBindingV2ContractPostgresTest 46
run_test \
"IdentityBindingV2PostgresIntegrationTest#concurrentFirstLoginConvergesAfterContractGate" \
46

View file

@ -0,0 +1,128 @@
-- Binding V2 contract gate.
--
-- Deploy this migration only after every pre-Binding-V2 application instance
-- has exited. Unlike the V45 expand migration, this gate rejects transactions
-- that leave an ACTIVE binding without exactly one ACTIVE primary subject.
DO $$
DECLARE
violation_summary TEXT;
BEGIN
SELECT string_agg(
format(
'%s (%s active primary subjects)',
binding_id,
active_primary_count
),
', ' ORDER BY binding_id
)
INTO violation_summary
FROM (
SELECT
binding.id AS binding_id,
COUNT(subject.id) FILTER (
WHERE subject.status = 'ACTIVE'
AND subject.is_primary = TRUE
) AS active_primary_count
FROM identity_binding binding
LEFT JOIN identity_binding_subject subject
ON subject.binding_id = binding.id
WHERE binding.status = 'ACTIVE'
GROUP BY binding.id
HAVING COUNT(subject.id) FILTER (
WHERE subject.status = 'ACTIVE'
AND subject.is_primary = TRUE
) <> 1
ORDER BY binding.id
LIMIT 20
) violations;
IF violation_summary IS NOT NULL THEN
RAISE EXCEPTION
'Binding V2 contract preflight failed: ACTIVE bindings must have exactly one ACTIVE primary subject: %',
violation_summary;
END IF;
END
$$;
CREATE FUNCTION assert_identity_binding_active_primary(
target_binding_id BIGINT
)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
binding_active BOOLEAN;
active_primary_count BIGINT;
BEGIN
SELECT
binding.status = 'ACTIVE',
COUNT(subject.id) FILTER (
WHERE subject.status = 'ACTIVE'
AND subject.is_primary = TRUE
)
INTO binding_active, active_primary_count
FROM identity_binding binding
LEFT JOIN identity_binding_subject subject
ON subject.binding_id = binding.id
WHERE binding.id = target_binding_id
GROUP BY binding.status;
IF NOT FOUND OR NOT binding_active THEN
RETURN;
END IF;
IF active_primary_count <> 1 THEN
RAISE EXCEPTION
'ACTIVE identity binding % must have exactly one ACTIVE primary subject; found %',
target_binding_id,
active_primary_count
USING
ERRCODE = '23514',
CONSTRAINT = 'chk_identity_binding_active_primary';
END IF;
END
$$;
CREATE FUNCTION enforce_identity_binding_subject_primary()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
PERFORM assert_identity_binding_active_primary(NEW.binding_id);
ELSIF TG_OP = 'DELETE' THEN
PERFORM assert_identity_binding_active_primary(OLD.binding_id);
ELSE
PERFORM assert_identity_binding_active_primary(OLD.binding_id);
IF NEW.binding_id IS DISTINCT FROM OLD.binding_id THEN
PERFORM assert_identity_binding_active_primary(NEW.binding_id);
END IF;
END IF;
RETURN NULL;
END
$$;
CREATE FUNCTION enforce_identity_binding_primary()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM assert_identity_binding_active_primary(NEW.id);
RETURN NULL;
END
$$;
CREATE CONSTRAINT TRIGGER ct_identity_binding_subject_active_primary
AFTER INSERT OR UPDATE OR DELETE
ON identity_binding_subject
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION enforce_identity_binding_subject_primary();
CREATE CONSTRAINT TRIGGER ct_identity_binding_active_primary
AFTER INSERT OR UPDATE
ON identity_binding
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION enforce_identity_binding_primary();

View file

@ -0,0 +1,547 @@
package com.iflytek.skillhub.auth.identity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationVersion;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
@EnabledIfEnvironmentVariable(
named = "IDENTITY_BINDING_V2_POSTGRES_URL",
matches = "jdbc:postgresql:.*")
class IdentityBindingV2ContractPostgresTest {
private static final String PREFLIGHT_SCHEMA =
"identity_v2_contract_preflight";
@Test
void contractMigrationRejectsActiveBindingWithoutPrimary()
throws Exception {
Database database = database();
dropSchema(database, PREFLIGHT_SCHEMA);
try {
Flyway.configure()
.dataSource(
database.url(),
database.username(),
database.password())
.locations("classpath:db/migration")
.schemas(PREFLIGHT_SCHEMA)
.defaultSchema(PREFLIGHT_SCHEMA)
.createSchemas(true)
.target(MigrationVersion.fromVersion("45"))
.load()
.migrate();
try (Connection connection = database.connect();
Statement statement = connection.createStatement()) {
statement.execute(
"SET search_path TO " + PREFLIGHT_SCHEMA);
insertUser(statement, "contract-preflight-user");
statement.executeUpdate("""
INSERT INTO identity_binding (
user_id,
provider_code,
subject,
login_name,
created_at,
updated_at
) VALUES (
'contract-preflight-user',
'contract-provider',
'contract-preflight-subject',
'contract-preflight',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
}
Throwable failure = catchThrowable(() ->
Flyway.configure()
.dataSource(
database.url(),
database.username(),
database.password())
.locations("classpath:db/migration")
.schemas(PREFLIGHT_SCHEMA)
.defaultSchema(PREFLIGHT_SCHEMA)
.createSchemas(true)
.load()
.migrate());
assertThat(failure).isNotNull();
assertThat(rootCause(failure).getMessage())
.contains(
"Binding V2 contract preflight failed");
try (Connection connection = database.connect();
Statement statement = connection.createStatement()) {
statement.execute(
"SET search_path TO " + PREFLIGHT_SCHEMA);
assertThat(singleString(
statement,
"""
SELECT version
FROM flyway_schema_history
WHERE success = TRUE
ORDER BY installed_rank DESC
LIMIT 1
""")).isEqualTo("45");
assertThat(singleLong(
statement,
"""
SELECT COUNT(*)
FROM pg_trigger trigger
JOIN pg_class relation
ON relation.oid = trigger.tgrelid
JOIN pg_namespace namespace
ON namespace.oid = relation.relnamespace
WHERE namespace.nspname =
'identity_v2_contract_preflight'
AND trigger.tgname =
'ct_identity_binding_active_primary'
""")).isZero();
}
} finally {
dropSchema(database, PREFLIGHT_SCHEMA);
}
}
@Test
void contractGateEnforcesExactlyOnePrimaryAndAtomicReplacement()
throws Exception {
Database database = database();
Flyway.configure()
.dataSource(
database.url(),
database.username(),
database.password())
.locations("classpath:db/migration")
.load()
.migrate();
long bindingId = createBindingWithPrimary(database);
SQLException zeroPrimary = expectSqlFailure(
database,
connection -> update(
connection,
"""
UPDATE identity_binding_subject
SET is_primary = FALSE
WHERE binding_id = ?
AND is_primary = TRUE
""",
bindingId));
assertConstraintViolation(zeroPrimary);
inTransaction(database, connection -> update(
connection,
"""
INSERT INTO identity_binding_subject (
binding_id,
provider_code,
subject_type,
subject_value,
is_primary,
status
) VALUES (?, 'contract-provider', 'alias_id',
'contract-alias-subject', FALSE, 'ACTIVE')
""",
bindingId));
inTransaction(database, connection -> {
update(
connection,
"""
UPDATE identity_binding_subject
SET is_primary = FALSE
WHERE binding_id = ?
AND is_primary = TRUE
""",
bindingId);
update(
connection,
"""
UPDATE identity_binding_subject
SET is_primary = TRUE
WHERE binding_id = ?
AND subject_type = 'alias_id'
""",
bindingId);
});
assertThat(activePrimaryType(database, bindingId))
.isEqualTo("alias_id");
SQLException twoPrimaries = expectSqlFailure(
database,
connection -> update(
connection,
"""
INSERT INTO identity_binding_subject (
binding_id,
provider_code,
subject_type,
subject_value,
is_primary,
status
) VALUES (?, 'contract-provider',
'other_primary',
'contract-other-primary',
TRUE,
'ACTIVE')
""",
bindingId));
assertThat(twoPrimaries.getSQLState()).isEqualTo("23505");
SQLException bindingWithoutPrimary = expectSqlFailure(
database,
connection -> {
try (Statement statement =
connection.createStatement()) {
insertUser(
statement,
"contract-zero-primary-user");
statement.executeUpdate("""
INSERT INTO identity_binding (
user_id,
provider_code,
subject,
login_name,
created_at,
updated_at
) VALUES (
'contract-zero-primary-user',
'contract-provider',
'contract-zero-primary-subject',
'contract-zero-primary',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""");
}
});
assertConstraintViolation(bindingWithoutPrimary);
SQLException deletedPrimary = expectSqlFailure(
database,
connection -> update(
connection,
"""
DELETE FROM identity_binding_subject
WHERE binding_id = ?
AND is_primary = TRUE
""",
bindingId));
assertConstraintViolation(deletedPrimary);
inTransaction(database, connection -> {
update(
connection,
"""
UPDATE identity_binding
SET status = 'REVOKED',
revoked_at = CURRENT_TIMESTAMP,
revoked_by = 'contract-test',
revocation_reason = 'contract test'
WHERE id = ?
""",
bindingId);
update(
connection,
"""
UPDATE identity_binding_subject
SET status = 'REVOKED',
revoked_at = CURRENT_TIMESTAMP,
is_primary = FALSE
WHERE binding_id = ?
AND is_primary = TRUE
""",
bindingId);
});
SQLException reactivatedWithoutPrimary = expectSqlFailure(
database,
connection -> activateBinding(
connection,
bindingId));
assertConstraintViolation(reactivatedWithoutPrimary);
inTransaction(database, connection -> {
update(
connection,
"""
UPDATE identity_binding_subject
SET is_primary = TRUE
WHERE binding_id = ?
AND status = 'ACTIVE'
""",
bindingId);
activateBinding(connection, bindingId);
});
assertThat(activePrimaryType(database, bindingId))
.isEqualTo("stable_id");
inTransaction(database, connection -> update(
connection,
"DELETE FROM identity_binding WHERE id = ?",
bindingId));
try (Connection connection = database.connect();
PreparedStatement statement = connection.prepareStatement(
"""
SELECT COUNT(*)
FROM identity_binding_subject
WHERE binding_id = ?
""")) {
statement.setLong(1, bindingId);
try (ResultSet result = statement.executeQuery()) {
assertThat(result.next()).isTrue();
assertThat(result.getLong(1)).isZero();
}
}
}
private static long createBindingWithPrimary(Database database)
throws Exception {
final long[] bindingId = new long[1];
inTransaction(database, connection -> {
try (Statement statement = connection.createStatement()) {
insertUser(statement, "contract-valid-user");
}
try (PreparedStatement statement = connection.prepareStatement(
"""
INSERT INTO identity_binding (
user_id,
provider_code,
subject,
login_name,
created_at,
updated_at
) VALUES (
'contract-valid-user',
'contract-provider',
'contract-stable-subject',
'contract-valid',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
RETURNING id
""")) {
try (ResultSet result = statement.executeQuery()) {
assertThat(result.next()).isTrue();
bindingId[0] = result.getLong(1);
}
}
update(
connection,
"""
INSERT INTO identity_binding_subject (
binding_id,
provider_code,
subject_type,
subject_value,
is_primary,
status
) VALUES (?, 'contract-provider', 'stable_id',
'contract-stable-subject', TRUE, 'ACTIVE')
""",
bindingId[0]);
});
return bindingId[0];
}
private static void activateBinding(
Connection connection,
long bindingId) throws SQLException {
update(
connection,
"""
UPDATE identity_binding
SET status = 'ACTIVE',
revoked_at = NULL,
revoked_by = NULL,
revocation_reason = NULL
WHERE id = ?
""",
bindingId);
}
private static String activePrimaryType(
Database database,
long bindingId) throws Exception {
try (Connection connection = database.connect();
PreparedStatement statement = connection.prepareStatement(
"""
SELECT subject_type
FROM identity_binding_subject
WHERE binding_id = ?
AND status = 'ACTIVE'
AND is_primary = TRUE
""")) {
statement.setLong(1, bindingId);
try (ResultSet result = statement.executeQuery()) {
assertThat(result.next()).isTrue();
String subjectType = result.getString(1);
assertThat(result.next()).isFalse();
return subjectType;
}
}
}
private static void assertConstraintViolation(
SQLException failure) {
assertThat(failure.getSQLState()).isEqualTo("23514");
assertThat(failure.getMessage())
.contains(
"must have exactly one ACTIVE primary subject");
}
private static SQLException expectSqlFailure(
Database database,
SqlWork work) throws Exception {
try (Connection connection = database.connect()) {
connection.setAutoCommit(false);
try {
work.execute(connection);
connection.commit();
} catch (SQLException failure) {
connection.rollback();
return failure;
}
}
throw new AssertionError("Expected SQL transaction to fail");
}
private static void inTransaction(
Database database,
SqlWork work) throws Exception {
try (Connection connection = database.connect()) {
connection.setAutoCommit(false);
try {
work.execute(connection);
connection.commit();
} catch (Exception failure) {
connection.rollback();
throw failure;
}
}
}
private static int update(
Connection connection,
String sql,
long bindingId) throws SQLException {
try (PreparedStatement statement =
connection.prepareStatement(sql)) {
statement.setLong(1, bindingId);
return statement.executeUpdate();
}
}
private static void insertUser(
Statement statement,
String userId) throws SQLException {
statement.executeUpdate("""
INSERT INTO user_account (
id,
display_name,
status,
created_at,
updated_at
) VALUES (
'%s',
'Binding V2 Contract User',
'ACTIVE',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
""".formatted(userId));
}
private static long singleLong(
Statement statement,
String sql) throws SQLException {
try (ResultSet result = statement.executeQuery(sql)) {
assertThat(result.next()).isTrue();
return result.getLong(1);
}
}
private static String singleString(
Statement statement,
String sql) throws SQLException {
try (ResultSet result = statement.executeQuery(sql)) {
assertThat(result.next()).isTrue();
return result.getString(1);
}
}
private static void dropSchema(
Database database,
String schema) throws SQLException {
if (!PREFLIGHT_SCHEMA.equals(schema)) {
throw new IllegalArgumentException(
"Unexpected test schema " + schema);
}
try (Connection connection = database.connect();
Statement statement = connection.createStatement()) {
statement.execute(
"DROP SCHEMA IF EXISTS " + schema + " CASCADE");
}
}
private static Throwable rootCause(Throwable failure) {
Throwable current = failure;
while (current.getCause() != null
&& current.getCause() != current) {
current = current.getCause();
}
return current;
}
private static Database database() {
return new Database(
requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_URL"),
requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_USERNAME"),
requiredEnvironment(
"IDENTITY_BINDING_V2_POSTGRES_PASSWORD"));
}
private static String requiredEnvironment(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"Missing required environment variable " + name);
}
return value;
}
@FunctionalInterface
private interface SqlWork {
void execute(Connection connection) throws Exception;
}
private record Database(
String url,
String username,
String password) {
Connection connect() throws SQLException {
return DriverManager.getConnection(
url,
username,
password);
}
}
}

View file

@ -91,6 +91,7 @@ class IdentityBindingV2MigrationPostgresTest {
Flyway.configure()
.dataSource(url, username, password)
.locations("classpath:db/migration")
.target(MigrationVersion.fromVersion("45"))
.load()
.migrate();

View file

@ -32,6 +32,8 @@ class IdentityBindingV2PostgresIntegrationTest {
private static final String CONCURRENT_SUBJECT =
"900000000003";
private static final String CONTRACT_CONCURRENT_SUBJECT =
"900000000004";
@Autowired
private ExternalIdentityLoginService loginService;
@ -72,6 +74,13 @@ class IdentityBindingV2PostgresIntegrationTest {
registry.add(
"spring.flyway.enabled",
() -> "true");
String flywayTarget = System.getenv(
"IDENTITY_BINDING_V2_FLYWAY_TARGET");
if (flywayTarget != null && !flywayTarget.isBlank()) {
registry.add(
"spring.flyway.target",
() -> flywayTarget);
}
}
@Test
@ -155,9 +164,20 @@ class IdentityBindingV2PostgresIntegrationTest {
@Test
void concurrentFirstLoginConvergesOnOneBinding() throws Exception {
assertConcurrentFirstLogin(CONCURRENT_SUBJECT);
}
@Test
void concurrentFirstLoginConvergesAfterContractGate()
throws Exception {
assertConcurrentFirstLogin(CONTRACT_CONCURRENT_SUBJECT);
}
private void assertConcurrentFirstLogin(String subject)
throws Exception {
ResolvedProviderHandle provider = githubProvider();
ProviderAuthenticationResult result =
providerResult(CONCURRENT_SUBJECT);
providerResult(subject);
CountDownLatch start = new CountDownLatch(1);
List<Future<IdentityLoginOutcome>> futures =
new ArrayList<>();
@ -200,7 +220,7 @@ class IdentityBindingV2PostgresIntegrationTest {
AND status = 'ACTIVE'
""",
Long.class,
CONCURRENT_SUBJECT)).isEqualTo(1L);
subject)).isEqualTo(1L);
assertThat(jdbcTemplate.queryForObject(
"""
SELECT COUNT(*)
@ -212,7 +232,7 @@ class IdentityBindingV2PostgresIntegrationTest {
AND is_primary = TRUE
""",
Long.class,
CONCURRENT_SUBJECT)).isEqualTo(1L);
subject)).isEqualTo(1L);
}
private IdentityLoginOutcome authenticate(String subject) {

View file

@ -102,6 +102,19 @@ class FlywayMigrationGuardrailTest {
assertThat(migration).contains("bad_namespace.slug <> 'global'");
}
@Test
void identityBindingContractGate_mustRemainDeferred() throws IOException {
String migration = Files.readString(
migrationPath(
"V46__identity_binding_v2_contract_gate.sql"));
assertThat(migration)
.contains("CREATE CONSTRAINT TRIGGER")
.contains("DEFERRABLE INITIALLY DEFERRED")
.contains("chk_identity_binding_active_primary")
.contains("Binding V2 contract preflight failed");
}
private List<Path> migrationFiles() throws IOException {
Path root = repoRoot()
.resolve("server")