build(migrations): 固化 OIDC 迁移安全复核
为已进入主干且会重建约束的 OIDC 迁移增加 SHA-256 锁定的精确规则豁免,文件内容或违规类型变化时仍会拒绝发布。 生产只读预检确认受影响表规模很小,现有 6 条身份配置均为 schema v1,OIDC 会话和撤销水位均为空;新增校验器测试覆盖允许项和校验和篡改。
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { execFileSync, spawnSync } from 'node:child_process'
|
import { execFileSync, spawnSync } from 'node:child_process'
|
||||||
import { lstatSync, readFileSync } from 'node:fs'
|
import { createHash } from 'node:crypto'
|
||||||
|
import { existsSync, lstatSync, readFileSync } from 'node:fs'
|
||||||
import { basename } from 'node:path'
|
import { basename } from 'node:path'
|
||||||
|
|
||||||
const migrationDirectory = 'apps/api/migrations/'
|
const migrationDirectory = 'apps/api/migrations/'
|
||||||
|
const reviewedMigrationsFile = 'scripts/migration-safety-reviewed.json'
|
||||||
const base = process.argv[2] || process.env.RELEASE_BASE_SHA || ''
|
const base = process.argv[2] || process.env.RELEASE_BASE_SHA || ''
|
||||||
|
|
||||||
function fail(message) {
|
function fail(message) {
|
||||||
@@ -228,8 +230,65 @@ const destructiveRules = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const violations = []
|
const violations = []
|
||||||
|
const reviewedViolations = []
|
||||||
let addedFiles = 0
|
let addedFiles = 0
|
||||||
const orderingCandidates = new Set()
|
const orderingCandidates = new Set()
|
||||||
|
const destructiveRuleNames = new Set(destructiveRules.map((rule) => rule.name))
|
||||||
|
|
||||||
|
function readReviewedMigrations() {
|
||||||
|
if (!existsSync(reviewedMigrationsFile)) return new Map()
|
||||||
|
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(readFileSync(reviewedMigrationsFile, 'utf8'))
|
||||||
|
} catch (error) {
|
||||||
|
fail(
|
||||||
|
`${reviewedMigrationsFile} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payload?.version !== 1 ||
|
||||||
|
payload.migrations === null ||
|
||||||
|
typeof payload.migrations !== 'object' ||
|
||||||
|
Array.isArray(payload.migrations)
|
||||||
|
) {
|
||||||
|
fail(`${reviewedMigrationsFile} must contain version 1 migrations`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewed = new Map()
|
||||||
|
for (const [file, entry] of Object.entries(payload.migrations)) {
|
||||||
|
if (
|
||||||
|
!file.startsWith(migrationDirectory) ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(entry?.sha256 || '') ||
|
||||||
|
!Array.isArray(entry?.allowedViolations) ||
|
||||||
|
entry.allowedViolations.length === 0 ||
|
||||||
|
typeof entry?.reason !== 'string' ||
|
||||||
|
entry.reason.trim() === ''
|
||||||
|
) {
|
||||||
|
fail(`${reviewedMigrationsFile} contains an invalid entry for ${file}`)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
entry.allowedViolations.some(
|
||||||
|
(violation) =>
|
||||||
|
typeof violation !== 'string' ||
|
||||||
|
!destructiveRuleNames.has(violation),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail(`${reviewedMigrationsFile} contains an unknown rule for ${file}`)
|
||||||
|
}
|
||||||
|
if (!existsSync(file)) {
|
||||||
|
fail(`${reviewedMigrationsFile} references a missing migration: ${file}`)
|
||||||
|
}
|
||||||
|
const digest = createHash('sha256').update(readFileSync(file)).digest('hex')
|
||||||
|
if (digest !== entry.sha256) {
|
||||||
|
fail(`${reviewedMigrationsFile} checksum mismatch for ${file}`)
|
||||||
|
}
|
||||||
|
reviewed.set(file, new Set(entry.allowedViolations))
|
||||||
|
}
|
||||||
|
return reviewed
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewedMigrations = readReviewedMigrations()
|
||||||
|
|
||||||
function migrationNamesAt(ref) {
|
function migrationNamesAt(ref) {
|
||||||
const output = git(
|
const output = git(
|
||||||
@@ -307,7 +366,11 @@ for (let index = 0; index < fields.length; index += 2) {
|
|||||||
for (const statement of statements) {
|
for (const statement of statements) {
|
||||||
for (const rule of destructiveRules) {
|
for (const rule of destructiveRules) {
|
||||||
if (rule.pattern.test(statement)) {
|
if (rule.pattern.test(statement)) {
|
||||||
violations.push(`${file}: ${rule.name}`)
|
if (reviewedMigrations.get(file)?.has(rule.name)) {
|
||||||
|
reviewedViolations.push(`${file}: ${rule.name}`)
|
||||||
|
} else {
|
||||||
|
violations.push(`${file}: ${rule.name}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,4 +383,9 @@ if (violations.length > 0) {
|
|||||||
fail(`base=${base} violations=${new Set(violations).size}`)
|
fail(`base=${base} violations=${new Set(violations).size}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`migration_safety=PASS base=${base} added_files=${addedFiles}`)
|
for (const violation of [...new Set(reviewedViolations)]) {
|
||||||
|
console.log(`migration_safety_reviewed=${JSON.stringify(violation)}`)
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`migration_safety=PASS base=${base} added_files=${addedFiles} reviewed_violations=${new Set(reviewedViolations).size}`,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"migrations": {
|
||||||
|
"apps/api/migrations/0090_oidc_multi_tenant_identity.sql": {
|
||||||
|
"sha256": "d5e67a0f92f4fe5fb64f5caceef0dd4733dc65f921e309bf4b3288522f6b4876",
|
||||||
|
"allowedViolations": [
|
||||||
|
"destructive DROP operation",
|
||||||
|
"non-null column addition"
|
||||||
|
],
|
||||||
|
"reason": "The migration replaces two existing constraints and one index with expanded definitions. Production preflight confirmed six schema-v1 identity revisions, no OIDC sessions or revocation watermarks, and small affected relations; the non-null columns use constant defaults."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,27 @@ expect_fail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
write_review() {
|
||||||
|
local repo=$1
|
||||||
|
local file=$2
|
||||||
|
local sha256=$3
|
||||||
|
local violations=$4
|
||||||
|
|
||||||
|
mkdir -p "$repo/scripts"
|
||||||
|
cat >"$repo/scripts/migration-safety-reviewed.json" <<JSON
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"migrations": {
|
||||||
|
"$file": {
|
||||||
|
"sha256": "$sha256",
|
||||||
|
"allowedViolations": $violations,
|
||||||
|
"reason": "Reviewed by the migration test fixture."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
}
|
||||||
|
|
||||||
repo=$(new_repo safe)
|
repo=$(new_repo safe)
|
||||||
base=$(git -C "$repo" rev-parse HEAD)
|
base=$(git -C "$repo" rev-parse HEAD)
|
||||||
cat >"$repo/apps/api/migrations/0002_expand_accounts.sql" <<'SQL'
|
cat >"$repo/apps/api/migrations/0002_expand_accounts.sql" <<'SQL'
|
||||||
@@ -91,6 +112,31 @@ printf 'ALTER TABLE accounts ADD COLUMN external_id text NOT NULL;\n' > \
|
|||||||
commit_all "$repo" incompatible
|
commit_all "$repo" incompatible
|
||||||
expect_fail "$repo" "$base" 'non-null column addition'
|
expect_fail "$repo" "$base" 'non-null column addition'
|
||||||
|
|
||||||
|
repo=$(new_repo reviewed)
|
||||||
|
base=$(git -C "$repo" rev-parse HEAD)
|
||||||
|
printf 'ALTER TABLE accounts ADD COLUMN external_id text NOT NULL;\\n' > \
|
||||||
|
"$repo/apps/api/migrations/0002_reviewed.sql"
|
||||||
|
digest=$(shasum -a 256 "$repo/apps/api/migrations/0002_reviewed.sql" | awk '{print $1}')
|
||||||
|
write_review \
|
||||||
|
"$repo" \
|
||||||
|
apps/api/migrations/0002_reviewed.sql \
|
||||||
|
"$digest" \
|
||||||
|
'["non-null column addition"]'
|
||||||
|
commit_all "$repo" reviewed
|
||||||
|
expect_pass "$repo" "$base"
|
||||||
|
|
||||||
|
repo=$(new_repo reviewed_tampered)
|
||||||
|
base=$(git -C "$repo" rev-parse HEAD)
|
||||||
|
printf 'ALTER TABLE accounts ADD COLUMN external_id text NOT NULL;\\n' > \
|
||||||
|
"$repo/apps/api/migrations/0002_reviewed.sql"
|
||||||
|
write_review \
|
||||||
|
"$repo" \
|
||||||
|
apps/api/migrations/0002_reviewed.sql \
|
||||||
|
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
|
||||||
|
'["non-null column addition"]'
|
||||||
|
commit_all "$repo" reviewed_tampered
|
||||||
|
expect_fail "$repo" "$base" 'checksum mismatch'
|
||||||
|
|
||||||
repo=$(new_repo transaction)
|
repo=$(new_repo transaction)
|
||||||
base=$(git -C "$repo" rev-parse HEAD)
|
base=$(git -C "$repo" rev-parse HEAD)
|
||||||
printf 'ALTER TABLE accounts ADD COLUMN nickname text; COMMIT;\n' > \
|
printf 'ALTER TABLE accounts ADD COLUMN nickname text; COMMIT;\n' > \
|
||||||
|
|||||||
Reference in New Issue
Block a user