mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(db): add lossless identity migration state
Preserve published binding history while projecting authoritative lifecycle state into additive tables. Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
@@ -561,7 +561,7 @@ mod tests {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations.len(), 28);
|
||||
assert_eq!(migrations.len(), 29);
|
||||
assert_eq!(migrations[0].version, 1);
|
||||
assert_eq!(&*migrations[0].description, "initial schema");
|
||||
assert!(migrations[0]
|
||||
@@ -953,6 +953,107 @@ mod tests {
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("CREATE TABLE identity_revoked_keys"));
|
||||
|
||||
// O3 is a brownfield-safe additive projection over the frozen 0027/0028
|
||||
// identity tables. It must not rewrite or discard legacy authority.
|
||||
assert_eq!(migrations[28].version, 29);
|
||||
let o3 = migrations[28].sql.as_str();
|
||||
for required in [
|
||||
"ADD COLUMN binding_id",
|
||||
"ADD COLUMN binding_version",
|
||||
"ADD COLUMN binding_state",
|
||||
"ADD COLUMN binding_provenance",
|
||||
"CREATE TABLE identity_retired_pairs",
|
||||
"CREATE TABLE identity_pending_replacements",
|
||||
"CREATE TABLE identity_binding_history",
|
||||
"CREATE TABLE identity_lifecycle_operations",
|
||||
"CREATE TABLE identity_migration_denials",
|
||||
] {
|
||||
assert!(
|
||||
o3.contains(required),
|
||||
"migration 0029 is missing {required}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn o3_executable_sql(sql: &str) -> String {
|
||||
let mut output = String::with_capacity(sql.len());
|
||||
let mut chars = sql.chars().peekable();
|
||||
let mut in_line_comment = false;
|
||||
let mut in_string = false;
|
||||
while let Some(ch) = chars.next() {
|
||||
if in_line_comment {
|
||||
if ch == '\n' {
|
||||
in_line_comment = false;
|
||||
output.push(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !in_string && ch == '-' && chars.peek() == Some(&'-') {
|
||||
chars.next();
|
||||
in_line_comment = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '\'' {
|
||||
if in_string && chars.peek() == Some(&'\'') {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
in_string = !in_string;
|
||||
output.push(' ');
|
||||
continue;
|
||||
}
|
||||
output.push(if in_string { ' ' } else { ch });
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_0029_is_strictly_additive() {
|
||||
let migration = MIGRATOR
|
||||
.iter()
|
||||
.find(|migration| migration.version == 29)
|
||||
.expect("migration 0029");
|
||||
let executable = o3_executable_sql(migration.sql.as_str());
|
||||
let normalized = normalize_sql(&executable);
|
||||
|
||||
for forbidden in [" rename ", " drop ", " truncate ", " delete "] {
|
||||
assert!(
|
||||
!format!(" {normalized} ").contains(forbidden),
|
||||
"migration 0029 contains forbidden legacy mutation: {forbidden}"
|
||||
);
|
||||
}
|
||||
assert!(!normalized.contains("update identity_revoked_keys"));
|
||||
assert!(!normalized.contains("update identity_principals"));
|
||||
assert!(!normalized.contains("insert into identity_revoked_keys"));
|
||||
|
||||
let allowed_binding_columns = [
|
||||
"binding_id",
|
||||
"binding_version",
|
||||
"binding_state",
|
||||
"binding_provenance",
|
||||
"replacement_binding_id",
|
||||
];
|
||||
for statement in split_sql_statements(&executable) {
|
||||
let statement = normalize_sql(&statement);
|
||||
if !statement.starts_with("update identity_bindings ") {
|
||||
continue;
|
||||
}
|
||||
let assignments = statement
|
||||
.split_once(" set ")
|
||||
.map(|(_, tail)| tail.split_once(" where ").map_or(tail, |(set, _)| set))
|
||||
.expect("identity binding metadata update has SET clause");
|
||||
for assignment in split_top_level_csv(assignments) {
|
||||
let column = assignment
|
||||
.split_once('=')
|
||||
.map(|(column, _)| column.trim())
|
||||
.expect("metadata assignment");
|
||||
assert!(
|
||||
allowed_binding_columns.contains(&column),
|
||||
"migration 0029 rewrites legacy identity_bindings.{column}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1128,6 +1229,73 @@ mod tests {
|
||||
.expect("read applied migrations")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct LegacyIdentitySnapshot {
|
||||
bindings: Vec<String>,
|
||||
principals: Vec<String>,
|
||||
revoked_keys: Vec<String>,
|
||||
catalog: Vec<String>,
|
||||
}
|
||||
|
||||
async fn legacy_identity_snapshot(pool: &PgPool) -> LegacyIdentitySnapshot {
|
||||
async fn json_rows(pool: &PgPool, query: &'static str) -> Vec<String> {
|
||||
let mut rows = sqlx::query_scalar::<_, serde_json::Value>(query)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("snapshot identity rows")
|
||||
.into_iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
rows.sort();
|
||||
rows
|
||||
}
|
||||
|
||||
let bindings = json_rows(
|
||||
pool,
|
||||
"SELECT to_jsonb(binding) - ARRAY[\
|
||||
'binding_id', 'binding_version', 'binding_state',\
|
||||
'binding_provenance', 'replacement_binding_id', 'created_by',\
|
||||
'created_policy_version']::text[] \
|
||||
FROM identity_bindings binding",
|
||||
)
|
||||
.await;
|
||||
let principals = json_rows(
|
||||
pool,
|
||||
"SELECT to_jsonb(principal) FROM identity_principals principal",
|
||||
)
|
||||
.await;
|
||||
let revoked_keys = json_rows(
|
||||
pool,
|
||||
"SELECT to_jsonb(revoked) FROM identity_revoked_keys revoked",
|
||||
)
|
||||
.await;
|
||||
let mut catalog = sqlx::query_scalar::<_, String>(
|
||||
r#"
|
||||
SELECT definition FROM (
|
||||
SELECT 'constraint:' || conrelid::regclass::text || ':' || conname || ':' || pg_get_constraintdef(oid) AS definition
|
||||
FROM pg_constraint
|
||||
WHERE conrelid IN ('identity_bindings'::regclass, 'identity_principals'::regclass, 'identity_revoked_keys'::regclass)
|
||||
UNION ALL
|
||||
SELECT 'index:' || tablename || ':' || indexname || ':' || indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname='public'
|
||||
AND tablename IN ('identity_bindings', 'identity_principals', 'identity_revoked_keys')
|
||||
) definitions
|
||||
ORDER BY definition
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("snapshot identity catalog");
|
||||
catalog.sort();
|
||||
LegacyIdentitySnapshot {
|
||||
bindings,
|
||||
principals,
|
||||
revoked_keys,
|
||||
catalog,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn pre_0007_ambiguous_nip_rs_data_blocks_without_mutation_and_allows_retry() {
|
||||
@@ -1258,6 +1426,299 @@ mod tests {
|
||||
assert_eq!(after, vec![(1, Some(true)), (30_350, None)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a dedicated disposable Postgres database"]
|
||||
async fn identity_0029_additive_upgrade_preserves_legacy_state_and_handles_history() {
|
||||
let pool = connect_test_pool().await;
|
||||
reset_public_schema(&pool).await;
|
||||
MIGRATOR
|
||||
.run_to(28, &pool)
|
||||
.await
|
||||
.expect("apply migrations through legacy identity lifecycle");
|
||||
|
||||
let domain_a = uuid::Uuid::new_v4();
|
||||
let domain_b = uuid::Uuid::new_v4();
|
||||
for (domain, label) in [(domain_a, "a"), (domain_b, "b")] {
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(domain)
|
||||
.bind(format!("identity-0029-{label}-{}.example", domain.simple()))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert fixture community");
|
||||
}
|
||||
|
||||
let active_key = vec![1_u8; 32];
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_bindings \
|
||||
(community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at) \
|
||||
VALUES ($1, ' Issuer ', ' Subject ', $2, 'jwt_npub', \
|
||||
TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z')",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.bind(&active_key)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert literal active principal");
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_bindings \
|
||||
(community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at) \
|
||||
VALUES ($1, ' Issuer ', ' Subject ', $2, 'jwt_npub', \
|
||||
TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z', TIMESTAMPTZ '2025-01-01 00:00:00Z')",
|
||||
)
|
||||
.bind(domain_b)
|
||||
.bind(&active_key)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert cross-domain control");
|
||||
|
||||
let chain_keys = [vec![10_u8; 32], vec![11_u8; 32], vec![12_u8; 32]];
|
||||
for (index, key) in chain_keys.iter().enumerate() {
|
||||
if let Some(successor) = chain_keys.get(index + 1) {
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_bindings \
|
||||
(community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at, \
|
||||
revoked_at, revoked_reason, revocation_scope, rotation_completed_at, \
|
||||
rotated_to_pubkey, rotation_reason) \
|
||||
VALUES ($1, 'https://idp.example', 'chain', $2, 'db_binding', \
|
||||
TIMESTAMPTZ '2025-02-01 00:00:00Z', TIMESTAMPTZ '2025-02-01 00:00:00Z', \
|
||||
TIMESTAMPTZ '2025-02-01 00:00:00Z', TIMESTAMPTZ '2025-02-01 00:00:00Z', \
|
||||
'legacy rotation', 'rotation', TIMESTAMPTZ '2025-02-01 00:00:00Z', \
|
||||
$3, 'legacy rotation')",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.bind(key)
|
||||
.bind(successor)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("insert retired chain node {index}: {error}"));
|
||||
} else {
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_bindings \
|
||||
(community_id, issuer, uid, pubkey, source, created_at, updated_at, last_seen_at) \
|
||||
VALUES ($1, 'https://idp.example', 'chain', $2, 'db_binding', \
|
||||
TIMESTAMPTZ '2025-02-01 00:00:00Z', TIMESTAMPTZ '2025-02-01 00:00:00Z', \
|
||||
TIMESTAMPTZ '2025-02-01 00:00:00Z')",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.bind(key)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("insert active chain node {index}: {error}"));
|
||||
}
|
||||
}
|
||||
for old in chain_keys.iter().take(chain_keys.len() - 1) {
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_revoked_keys (community_id, pubkey, revoked_at, reason) \
|
||||
VALUES ($1, $2, TIMESTAMPTZ '2025-02-01 00:00:00Z', 'legacy rotation')",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.bind(old)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert legacy rotation tombstone");
|
||||
}
|
||||
|
||||
let revoked_key = vec![20_u8; 32];
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_bindings \
|
||||
(community_id, issuer, uid, pubkey, source, revoked_at, revoked_reason, revocation_scope) \
|
||||
VALUES ($1, 'https://idp.example', 'pending', $2, 'db_binding', NOW(), 'explicit revoke', 'key')",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.bind(&revoked_key)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert pending revoked binding");
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_revoked_keys (community_id, pubkey, revoked_at, reason) \
|
||||
VALUES ($1, $2, TIMESTAMPTZ '2025-03-01 00:00:00Z', 'explicit revoke')",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.bind(&revoked_key)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert explicit key selector");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_principals \
|
||||
(community_id, issuer, uid, disabled_at, disabled_reason) \
|
||||
VALUES ($1, 'https://idp.example', 'never-enrolled', NOW(), 'disabled before enrollment')",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert disabled never-enrolled principal");
|
||||
|
||||
let missing_old = vec![30_u8; 32];
|
||||
let missing_target = vec![31_u8; 32];
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_bindings \
|
||||
(community_id, issuer, uid, pubkey, source, revoked_at, revoked_reason, revocation_scope, \
|
||||
rotation_completed_at, rotated_to_pubkey, rotation_reason) \
|
||||
VALUES ($1, 'https://idp.example', 'ambiguous', $2, 'jwt_npub', NOW(), \
|
||||
'missing successor', 'rotation', NOW(), $3, 'missing successor')",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.bind(&missing_old)
|
||||
.bind(&missing_target)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert missing-lineage fixture");
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_revoked_keys (community_id, pubkey, reason) VALUES ($1, $2, 'ambiguous legacy selector')",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.bind(&missing_old)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert ambiguous selector");
|
||||
|
||||
let before = legacy_identity_snapshot(&pool).await;
|
||||
run_migrations(&pool)
|
||||
.await
|
||||
.expect("additive identity migration succeeds on populated history");
|
||||
let after = legacy_identity_snapshot(&pool).await;
|
||||
assert_eq!(after.bindings, before.bindings, "legacy bindings changed");
|
||||
assert_eq!(
|
||||
after.principals, before.principals,
|
||||
"legacy principals changed"
|
||||
);
|
||||
assert_eq!(after.revoked_keys, before.revoked_keys, "legacy Y changed");
|
||||
assert!(
|
||||
before
|
||||
.catalog
|
||||
.iter()
|
||||
.all(|definition| after.catalog.contains(definition)),
|
||||
"legacy identity constraints/indexes must remain unchanged"
|
||||
);
|
||||
|
||||
let chain: Vec<(Vec<u8>, i64, Option<Vec<u8>>)> = sqlx::query_as(
|
||||
"SELECT binding.pubkey, binding.binding_version, replacement.pubkey \
|
||||
FROM identity_bindings binding \
|
||||
LEFT JOIN identity_bindings replacement \
|
||||
ON replacement.community_id=binding.community_id \
|
||||
AND replacement.binding_id=binding.replacement_binding_id \
|
||||
WHERE binding.community_id=$1 AND binding.issuer='https://idp.example' AND binding.uid='chain' \
|
||||
ORDER BY binding.binding_version",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("read migrated chain");
|
||||
assert_eq!(chain.len(), 3);
|
||||
assert_eq!(
|
||||
chain[0],
|
||||
(chain_keys[0].clone(), 1, Some(chain_keys[1].clone()))
|
||||
);
|
||||
assert_eq!(
|
||||
chain[1],
|
||||
(chain_keys[1].clone(), 2, Some(chain_keys[2].clone()))
|
||||
);
|
||||
assert_eq!(chain[2], (chain_keys[2].clone(), 3, None));
|
||||
|
||||
let pending: (Vec<u8>, i64, i64) = sqlx::query_as(
|
||||
"SELECT retired_pubkey, retired_binding_version, selector_version \
|
||||
FROM identity_pending_replacements \
|
||||
WHERE community_id=$1 AND issuer='https://idp.example' AND subject='pending' AND cleared_at IS NULL",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read pending selector");
|
||||
assert_eq!(pending, (revoked_key, 1, 1));
|
||||
|
||||
let quarantined: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM identity_migration_denials \
|
||||
WHERE community_id=$1 AND issuer='https://idp.example' AND subject='ambiguous')",
|
||||
)
|
||||
.bind(domain_a)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read migration quarantine");
|
||||
assert!(quarantined);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a dedicated disposable Postgres database"]
|
||||
async fn identity_0029_failure_rolls_back_every_additive_projection() {
|
||||
let pool = connect_test_pool().await;
|
||||
reset_public_schema(&pool).await;
|
||||
MIGRATOR
|
||||
.run_to(28, &pool)
|
||||
.await
|
||||
.expect("apply migrations through legacy identity lifecycle");
|
||||
let domain = uuid::Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)")
|
||||
.bind(domain)
|
||||
.bind(format!(
|
||||
"identity-0029-rollback-{}.example",
|
||||
domain.simple()
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert rollback community");
|
||||
sqlx::query(
|
||||
"INSERT INTO identity_bindings (community_id, issuer, uid, pubkey, source) \
|
||||
VALUES ($1,'https://idp.example','rollback',$2,'db_binding')",
|
||||
)
|
||||
.bind(domain)
|
||||
.bind(vec![99_u8; 32])
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert rollback fixture");
|
||||
sqlx::query(
|
||||
"CREATE FUNCTION reject_o3_projection() RETURNS trigger LANGUAGE plpgsql AS $$ \
|
||||
BEGIN RAISE EXCEPTION 'injected O3 projection failure'; END $$",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("create failure function");
|
||||
sqlx::query(
|
||||
"CREATE TRIGGER reject_o3_projection BEFORE UPDATE ON identity_bindings \
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_o3_projection()",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("create failure trigger");
|
||||
|
||||
assert!(MIGRATOR.run_to(29, &pool).await.is_err());
|
||||
let projected_tables: (Option<String>, Option<String>, Option<String>) = sqlx::query_as(
|
||||
"SELECT to_regclass('identity_retired_pairs')::text, \
|
||||
to_regclass('identity_pending_replacements')::text, \
|
||||
to_regclass('identity_binding_history')::text",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("inspect rolled back tables");
|
||||
assert_eq!(projected_tables, (None, None, None));
|
||||
let projected_columns: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM information_schema.columns \
|
||||
WHERE table_schema='public' AND table_name='identity_bindings' \
|
||||
AND column_name IN ('binding_id','binding_version','binding_state','binding_provenance')",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("inspect rolled back columns");
|
||||
assert_eq!(projected_columns, 0);
|
||||
let latest: i64 =
|
||||
sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations WHERE success")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read latest migration");
|
||||
assert_eq!(latest, 28);
|
||||
sqlx::query("DROP TRIGGER reject_o3_projection ON identity_bindings")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("drop failure trigger");
|
||||
sqlx::query("DROP FUNCTION reject_o3_projection()")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("drop failure function");
|
||||
run_migrations(&pool)
|
||||
.await
|
||||
.expect("retry additive projection after rollback");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() {
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
-- Additive O3 identity-binding projection.
|
||||
--
|
||||
-- Migrations 0027/0028 are a frozen compatibility boundary. This migration
|
||||
-- never renames or removes their columns, constraints, indexes, rows, or
|
||||
-- lifecycle selectors. In particular, every legacy identity_revoked_keys row
|
||||
-- remains authoritative because rotation-created and explicitly strengthened
|
||||
-- tombstones cannot be distinguished after the fact.
|
||||
|
||||
ALTER TABLE identity_bindings
|
||||
ADD COLUMN binding_id UUID,
|
||||
ADD COLUMN binding_version BIGINT,
|
||||
ADD COLUMN binding_state TEXT,
|
||||
ADD COLUMN binding_provenance TEXT,
|
||||
ADD COLUMN replacement_binding_id UUID,
|
||||
ADD COLUMN created_by BYTEA,
|
||||
ADD COLUMN created_policy_version TEXT;
|
||||
|
||||
-- Every row receives a stable persisted identifier. Unique legacy exact pairs
|
||||
-- use a reproducible length-prefixed hash. Byte-identical duplicate rows lack
|
||||
-- a legacy row identifier, so they retain random persisted IDs and their exact
|
||||
-- principal is quarantined below.
|
||||
UPDATE identity_bindings
|
||||
SET binding_id = gen_random_uuid();
|
||||
|
||||
WITH unique_pairs AS (
|
||||
SELECT
|
||||
community_id,
|
||||
issuer,
|
||||
uid,
|
||||
pubkey,
|
||||
(
|
||||
substr(fingerprint, 1, 8) || '-' ||
|
||||
substr(fingerprint, 9, 4) || '-' ||
|
||||
substr(fingerprint, 13, 4) || '-' ||
|
||||
substr(fingerprint, 17, 4) || '-' ||
|
||||
substr(fingerprint, 21, 12)
|
||||
)::UUID AS stable_id
|
||||
FROM (
|
||||
SELECT
|
||||
community_id,
|
||||
issuer,
|
||||
uid,
|
||||
pubkey,
|
||||
encode(
|
||||
digest(
|
||||
E'\\x01'::BYTEA ||
|
||||
uuid_send(community_id) ||
|
||||
int8send(octet_length(convert_to(issuer, 'UTF8'))::BIGINT) ||
|
||||
convert_to(issuer, 'UTF8') ||
|
||||
int8send(octet_length(convert_to(uid, 'UTF8'))::BIGINT) ||
|
||||
convert_to(uid, 'UTF8') ||
|
||||
int8send(octet_length(pubkey)::BIGINT) ||
|
||||
pubkey,
|
||||
'sha256'
|
||||
),
|
||||
'hex'
|
||||
) AS fingerprint
|
||||
FROM identity_bindings
|
||||
GROUP BY community_id, issuer, uid, pubkey
|
||||
HAVING COUNT(*) = 1
|
||||
) fingerprints
|
||||
)
|
||||
UPDATE identity_bindings binding
|
||||
SET binding_id = unique_pairs.stable_id
|
||||
FROM unique_pairs
|
||||
WHERE binding.community_id = unique_pairs.community_id
|
||||
AND binding.issuer = unique_pairs.issuer
|
||||
AND binding.uid = unique_pairs.uid
|
||||
AND binding.pubkey = unique_pairs.pubkey;
|
||||
|
||||
UPDATE identity_bindings
|
||||
SET binding_state = CASE
|
||||
WHEN rotation_completed_at IS NOT NULL THEN 'rotated'
|
||||
WHEN revoked_at IS NOT NULL THEN 'revoked'
|
||||
ELSE 'active'
|
||||
END,
|
||||
binding_provenance = CASE source
|
||||
WHEN 'jwt_npub' THEN 'attested_key'
|
||||
ELSE 'tofu'
|
||||
END;
|
||||
|
||||
ALTER TABLE identity_bindings
|
||||
ALTER COLUMN binding_id SET NOT NULL,
|
||||
ALTER COLUMN binding_id SET DEFAULT gen_random_uuid(),
|
||||
ALTER COLUMN binding_state SET NOT NULL,
|
||||
ALTER COLUMN binding_state SET DEFAULT 'active',
|
||||
ALTER COLUMN binding_provenance SET NOT NULL,
|
||||
ALTER COLUMN binding_provenance SET DEFAULT 'tofu',
|
||||
ADD CONSTRAINT identity_bindings_o3_id_unique
|
||||
UNIQUE (community_id, binding_id),
|
||||
ADD CONSTRAINT chk_identity_bindings_o3_id_not_nil
|
||||
CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::UUID),
|
||||
ADD CONSTRAINT chk_identity_bindings_o3_state
|
||||
CHECK (binding_state IN ('active', 'revoked', 'rotated')),
|
||||
ADD CONSTRAINT chk_identity_bindings_o3_provenance
|
||||
CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')),
|
||||
ADD CONSTRAINT chk_identity_bindings_o3_created_by_len
|
||||
CHECK (created_by IS NULL OR length(created_by) = 32),
|
||||
ADD CONSTRAINT chk_identity_bindings_o3_policy_version
|
||||
CHECK (created_policy_version IS NULL OR length(created_policy_version) > 0);
|
||||
|
||||
-- Invalid legacy graphs remain stored verbatim but are not usable as binding
|
||||
-- authority. O3 exposes no operation that clears these migration denials.
|
||||
CREATE TABLE identity_migration_denials (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, issuer, subject),
|
||||
CHECK (length(issuer) > 0),
|
||||
CHECK (length(subject) > 0),
|
||||
CHECK (length(reason) > 0)
|
||||
);
|
||||
|
||||
INSERT INTO identity_migration_denials (community_id, issuer, subject, reason)
|
||||
SELECT community_id, issuer, uid, 'duplicate legacy exact-pair rows'
|
||||
FROM identity_bindings
|
||||
GROUP BY community_id, issuer, uid, pubkey
|
||||
HAVING COUNT(*) > 1
|
||||
ON CONFLICT (community_id, issuer, subject) DO NOTHING;
|
||||
|
||||
-- A valid legacy principal is one complete, non-branching, acyclic chain. Edge
|
||||
-- candidates include inactive replacements and ignore timestamps because NOW()
|
||||
-- is transaction-stable in the legacy rotation helper.
|
||||
WITH RECURSIVE
|
||||
candidate_edges AS (
|
||||
SELECT
|
||||
predecessor.community_id,
|
||||
predecessor.issuer,
|
||||
predecessor.uid,
|
||||
predecessor.binding_id AS predecessor_id,
|
||||
successor.binding_id AS successor_id,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY predecessor.community_id, predecessor.binding_id
|
||||
) AS outgoing_candidates
|
||||
FROM identity_bindings predecessor
|
||||
JOIN identity_bindings successor
|
||||
ON successor.community_id = predecessor.community_id
|
||||
AND successor.issuer = predecessor.issuer
|
||||
AND successor.uid = predecessor.uid
|
||||
AND successor.pubkey = predecessor.rotated_to_pubkey
|
||||
AND successor.binding_id <> predecessor.binding_id
|
||||
WHERE predecessor.rotation_completed_at IS NOT NULL
|
||||
),
|
||||
resolved_edges AS (
|
||||
SELECT community_id, issuer, uid, predecessor_id, successor_id
|
||||
FROM candidate_edges
|
||||
WHERE outgoing_candidates = 1
|
||||
),
|
||||
principals AS (
|
||||
SELECT community_id, issuer, uid, COUNT(*)::BIGINT AS node_count
|
||||
FROM identity_bindings
|
||||
GROUP BY community_id, issuer, uid
|
||||
),
|
||||
roots AS (
|
||||
SELECT node.community_id, node.issuer, node.uid, node.binding_id
|
||||
FROM identity_bindings node
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM resolved_edges edge
|
||||
WHERE edge.community_id = node.community_id
|
||||
AND edge.successor_id = node.binding_id
|
||||
)
|
||||
),
|
||||
reachable AS (
|
||||
SELECT root.community_id, root.issuer, root.uid, root.binding_id
|
||||
FROM roots root
|
||||
UNION
|
||||
SELECT edge.community_id, edge.issuer, edge.uid, edge.successor_id
|
||||
FROM reachable current_node
|
||||
JOIN resolved_edges edge
|
||||
ON edge.community_id = current_node.community_id
|
||||
AND edge.predecessor_id = current_node.binding_id
|
||||
),
|
||||
graph_stats AS (
|
||||
SELECT
|
||||
principal.community_id,
|
||||
principal.issuer,
|
||||
principal.uid,
|
||||
principal.node_count,
|
||||
COUNT(DISTINCT edge.predecessor_id)::BIGINT AS edge_count,
|
||||
COUNT(DISTINCT root.binding_id)::BIGINT AS root_count,
|
||||
COUNT(DISTINCT reached.binding_id)::BIGINT AS reached_count,
|
||||
COALESCE(MAX(incoming.incoming_count), 0)::BIGINT AS max_incoming
|
||||
FROM principals principal
|
||||
LEFT JOIN resolved_edges edge
|
||||
ON edge.community_id = principal.community_id
|
||||
AND edge.issuer = principal.issuer
|
||||
AND edge.uid = principal.uid
|
||||
LEFT JOIN roots root
|
||||
ON root.community_id = principal.community_id
|
||||
AND root.issuer = principal.issuer
|
||||
AND root.uid = principal.uid
|
||||
LEFT JOIN reachable reached
|
||||
ON reached.community_id = principal.community_id
|
||||
AND reached.issuer = principal.issuer
|
||||
AND reached.uid = principal.uid
|
||||
LEFT JOIN (
|
||||
SELECT community_id, issuer, uid, successor_id, COUNT(*)::BIGINT AS incoming_count
|
||||
FROM resolved_edges
|
||||
GROUP BY community_id, issuer, uid, successor_id
|
||||
) incoming
|
||||
ON incoming.community_id = principal.community_id
|
||||
AND incoming.issuer = principal.issuer
|
||||
AND incoming.uid = principal.uid
|
||||
GROUP BY principal.community_id, principal.issuer, principal.uid, principal.node_count
|
||||
),
|
||||
unresolved_rotations AS (
|
||||
SELECT predecessor.community_id, predecessor.issuer, predecessor.uid
|
||||
FROM identity_bindings predecessor
|
||||
LEFT JOIN candidate_edges edge
|
||||
ON edge.community_id = predecessor.community_id
|
||||
AND edge.predecessor_id = predecessor.binding_id
|
||||
WHERE predecessor.rotation_completed_at IS NOT NULL
|
||||
GROUP BY predecessor.community_id, predecessor.issuer, predecessor.uid, predecessor.binding_id
|
||||
HAVING COUNT(edge.successor_id) <> 1
|
||||
OR COALESCE(MAX(edge.outgoing_candidates), 0) <> 1
|
||||
),
|
||||
invalid_principals AS (
|
||||
SELECT community_id, issuer, uid FROM unresolved_rotations
|
||||
UNION
|
||||
SELECT community_id, issuer, uid
|
||||
FROM graph_stats
|
||||
WHERE edge_count <> node_count - 1
|
||||
OR root_count <> 1
|
||||
OR reached_count <> node_count
|
||||
OR max_incoming > 1
|
||||
)
|
||||
INSERT INTO identity_migration_denials (community_id, issuer, subject, reason)
|
||||
SELECT community_id, issuer, uid, 'ambiguous legacy replacement lineage'
|
||||
FROM invalid_principals
|
||||
ON CONFLICT (community_id, issuer, subject) DO NOTHING;
|
||||
|
||||
-- Topological versions are deterministic for valid histories. Quarantined
|
||||
-- rows receive stable positive versions solely for attribution, never auth.
|
||||
WITH RECURSIVE
|
||||
candidate_edges AS (
|
||||
SELECT
|
||||
predecessor.community_id,
|
||||
predecessor.issuer,
|
||||
predecessor.uid,
|
||||
predecessor.binding_id AS predecessor_id,
|
||||
successor.binding_id AS successor_id,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY predecessor.community_id, predecessor.binding_id
|
||||
) AS outgoing_candidates
|
||||
FROM identity_bindings predecessor
|
||||
JOIN identity_bindings successor
|
||||
ON successor.community_id = predecessor.community_id
|
||||
AND successor.issuer = predecessor.issuer
|
||||
AND successor.uid = predecessor.uid
|
||||
AND successor.pubkey = predecessor.rotated_to_pubkey
|
||||
AND successor.binding_id <> predecessor.binding_id
|
||||
WHERE predecessor.rotation_completed_at IS NOT NULL
|
||||
),
|
||||
resolved_edges AS (
|
||||
SELECT community_id, issuer, uid, predecessor_id, successor_id
|
||||
FROM candidate_edges
|
||||
WHERE outgoing_candidates = 1
|
||||
),
|
||||
roots AS (
|
||||
SELECT node.community_id, node.issuer, node.uid, node.binding_id
|
||||
FROM identity_bindings node
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM resolved_edges edge
|
||||
WHERE edge.community_id = node.community_id
|
||||
AND edge.successor_id = node.binding_id
|
||||
)
|
||||
),
|
||||
walk AS (
|
||||
SELECT root.community_id, root.issuer, root.uid, root.binding_id, 1::BIGINT AS binding_version
|
||||
FROM roots root
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM identity_migration_denials denial
|
||||
WHERE denial.community_id = root.community_id
|
||||
AND denial.issuer = root.issuer
|
||||
AND denial.subject = root.uid
|
||||
)
|
||||
UNION ALL
|
||||
SELECT edge.community_id, edge.issuer, edge.uid, edge.successor_id, walk.binding_version + 1
|
||||
FROM walk
|
||||
JOIN resolved_edges edge
|
||||
ON edge.community_id = walk.community_id
|
||||
AND edge.predecessor_id = walk.binding_id
|
||||
),
|
||||
quarantined AS (
|
||||
SELECT
|
||||
binding.community_id,
|
||||
binding.binding_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY binding.community_id, binding.issuer, binding.uid
|
||||
ORDER BY binding.created_at, binding.updated_at,
|
||||
encode(binding.pubkey, 'hex'), binding.binding_id
|
||||
)::BIGINT AS binding_version
|
||||
FROM identity_bindings binding
|
||||
JOIN identity_migration_denials denial
|
||||
ON denial.community_id = binding.community_id
|
||||
AND denial.issuer = binding.issuer
|
||||
AND denial.subject = binding.uid
|
||||
),
|
||||
versions AS (
|
||||
SELECT community_id, binding_id, binding_version FROM walk
|
||||
UNION ALL
|
||||
SELECT community_id, binding_id, binding_version FROM quarantined
|
||||
)
|
||||
UPDATE identity_bindings binding
|
||||
SET binding_version = versions.binding_version
|
||||
FROM versions
|
||||
WHERE binding.community_id = versions.community_id
|
||||
AND binding.binding_id = versions.binding_id;
|
||||
|
||||
ALTER TABLE identity_bindings
|
||||
ALTER COLUMN binding_version SET NOT NULL,
|
||||
ALTER COLUMN binding_version SET DEFAULT 1,
|
||||
ADD CONSTRAINT chk_identity_bindings_o3_version_positive
|
||||
CHECK (binding_version > 0),
|
||||
ADD CONSTRAINT identity_bindings_o3_principal_version_unique
|
||||
UNIQUE (community_id, issuer, uid, binding_version);
|
||||
|
||||
CREATE TABLE identity_binding_lineage (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
predecessor_binding_id UUID NOT NULL,
|
||||
successor_binding_id UUID NOT NULL,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, predecessor_binding_id),
|
||||
UNIQUE (community_id, successor_binding_id),
|
||||
FOREIGN KEY (community_id, predecessor_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
FOREIGN KEY (community_id, successor_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
CHECK (predecessor_binding_id <> successor_binding_id)
|
||||
);
|
||||
|
||||
WITH candidate_edges AS (
|
||||
SELECT
|
||||
predecessor.community_id,
|
||||
predecessor.issuer,
|
||||
predecessor.uid,
|
||||
predecessor.binding_id AS predecessor_id,
|
||||
successor.binding_id AS successor_id,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY predecessor.community_id, predecessor.binding_id
|
||||
) AS outgoing_candidates
|
||||
FROM identity_bindings predecessor
|
||||
JOIN identity_bindings successor
|
||||
ON successor.community_id = predecessor.community_id
|
||||
AND successor.issuer = predecessor.issuer
|
||||
AND successor.uid = predecessor.uid
|
||||
AND successor.pubkey = predecessor.rotated_to_pubkey
|
||||
AND successor.binding_id <> predecessor.binding_id
|
||||
WHERE predecessor.rotation_completed_at IS NOT NULL
|
||||
)
|
||||
INSERT INTO identity_binding_lineage
|
||||
(community_id, predecessor_binding_id, successor_binding_id)
|
||||
SELECT edge.community_id, edge.predecessor_id, edge.successor_id
|
||||
FROM candidate_edges edge
|
||||
WHERE edge.outgoing_candidates = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM identity_migration_denials denial
|
||||
WHERE denial.community_id = edge.community_id
|
||||
AND denial.issuer = edge.issuer
|
||||
AND denial.subject = edge.uid
|
||||
);
|
||||
|
||||
UPDATE identity_bindings predecessor
|
||||
SET replacement_binding_id = lineage.successor_binding_id
|
||||
FROM identity_binding_lineage lineage
|
||||
WHERE lineage.community_id = predecessor.community_id
|
||||
AND lineage.predecessor_binding_id = predecessor.binding_id;
|
||||
|
||||
ALTER TABLE identity_bindings
|
||||
ADD CONSTRAINT identity_bindings_o3_replacement_fk
|
||||
FOREIGN KEY (community_id, replacement_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE TABLE identity_retired_pairs (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
pubkey BYTEA NOT NULL,
|
||||
retired_binding_id UUID,
|
||||
retired_binding_version BIGINT,
|
||||
retired_at TIMESTAMPTZ NOT NULL,
|
||||
retired_by BYTEA,
|
||||
reason TEXT NOT NULL,
|
||||
PRIMARY KEY (community_id, issuer, subject, pubkey),
|
||||
UNIQUE (
|
||||
community_id, issuer, subject, pubkey,
|
||||
retired_binding_id, retired_binding_version
|
||||
),
|
||||
FOREIGN KEY (community_id, retired_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
CHECK (length(issuer) > 0),
|
||||
CHECK (length(subject) > 0),
|
||||
CHECK (length(pubkey) = 32),
|
||||
CHECK (retired_binding_version IS NULL OR retired_binding_version > 0),
|
||||
CHECK (
|
||||
(retired_binding_id IS NULL AND retired_binding_version IS NULL)
|
||||
OR
|
||||
(retired_binding_id IS NOT NULL AND retired_binding_version IS NOT NULL)
|
||||
),
|
||||
CHECK (retired_by IS NULL OR length(retired_by) = 32),
|
||||
CHECK (length(reason) > 0)
|
||||
);
|
||||
|
||||
INSERT INTO identity_retired_pairs
|
||||
(community_id, issuer, subject, pubkey, retired_binding_id,
|
||||
retired_binding_version, retired_at, retired_by, reason)
|
||||
SELECT
|
||||
community_id,
|
||||
issuer,
|
||||
uid,
|
||||
pubkey,
|
||||
CASE WHEN COUNT(*) = 1 THEN (array_agg(binding_id))[1] END,
|
||||
CASE WHEN COUNT(*) = 1 THEN (array_agg(binding_version))[1] END,
|
||||
MIN(COALESCE(revoked_at, rotation_completed_at, updated_at)),
|
||||
CASE WHEN COUNT(*) = 1 THEN (array_agg(COALESCE(rotation_by, revoked_by)))[1] END,
|
||||
MIN(COALESCE(NULLIF(rotation_reason, ''), NULLIF(revoked_reason, ''), 'legacy pair retirement'))
|
||||
FROM identity_bindings
|
||||
WHERE revoked_at IS NOT NULL
|
||||
GROUP BY community_id, issuer, uid, pubkey;
|
||||
|
||||
-- Q is append-only history. cleared_at marks selector absence while retaining
|
||||
-- the selector version so recreation of the same tuple cannot cause ABA.
|
||||
CREATE TABLE identity_pending_replacements (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
selector_version BIGINT NOT NULL,
|
||||
retired_pubkey BYTEA NOT NULL,
|
||||
retired_binding_id UUID NOT NULL,
|
||||
retired_binding_version BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_operation_id UUID,
|
||||
cleared_at TIMESTAMPTZ,
|
||||
cleared_operation_id UUID,
|
||||
PRIMARY KEY (community_id, issuer, subject, selector_version),
|
||||
FOREIGN KEY (
|
||||
community_id, issuer, subject, retired_pubkey,
|
||||
retired_binding_id, retired_binding_version
|
||||
) REFERENCES identity_retired_pairs (
|
||||
community_id, issuer, subject, pubkey,
|
||||
retired_binding_id, retired_binding_version
|
||||
),
|
||||
CHECK (length(issuer) > 0),
|
||||
CHECK (length(subject) > 0),
|
||||
CHECK (selector_version > 0),
|
||||
CHECK (length(retired_pubkey) = 32),
|
||||
CHECK (retired_binding_version > 0),
|
||||
CHECK (
|
||||
(cleared_at IS NULL AND cleared_operation_id IS NULL)
|
||||
OR
|
||||
(cleared_at IS NOT NULL AND cleared_operation_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_identity_pending_replacements_active
|
||||
ON identity_pending_replacements (community_id, issuer, subject)
|
||||
WHERE cleared_at IS NULL;
|
||||
|
||||
INSERT INTO identity_pending_replacements
|
||||
(community_id, issuer, subject, selector_version, retired_pubkey,
|
||||
retired_binding_id, retired_binding_version)
|
||||
SELECT
|
||||
terminal.community_id,
|
||||
terminal.issuer,
|
||||
terminal.uid,
|
||||
1,
|
||||
terminal.pubkey,
|
||||
terminal.binding_id,
|
||||
terminal.binding_version
|
||||
FROM identity_bindings terminal
|
||||
WHERE terminal.revoked_at IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM identity_bindings active
|
||||
WHERE active.community_id = terminal.community_id
|
||||
AND active.issuer = terminal.issuer
|
||||
AND active.uid = terminal.uid
|
||||
AND active.revoked_at IS NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM identity_binding_lineage lineage
|
||||
WHERE lineage.community_id = terminal.community_id
|
||||
AND lineage.predecessor_binding_id = terminal.binding_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM identity_migration_denials denial
|
||||
WHERE denial.community_id = terminal.community_id
|
||||
AND denial.issuer = terminal.issuer
|
||||
AND denial.subject = terminal.uid
|
||||
);
|
||||
|
||||
CREATE TABLE identity_binding_history (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
history_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
binding_id UUID NOT NULL,
|
||||
binding_version BIGINT NOT NULL,
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
pubkey BYTEA NOT NULL,
|
||||
binding_state TEXT NOT NULL,
|
||||
binding_provenance TEXT NOT NULL,
|
||||
transition_kind TEXT NOT NULL,
|
||||
replacement_binding_id UUID,
|
||||
operation_id UUID,
|
||||
actor BYTEA,
|
||||
reason TEXT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, history_id),
|
||||
UNIQUE (community_id, binding_id, binding_version, transition_kind),
|
||||
FOREIGN KEY (community_id, binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
FOREIGN KEY (community_id, replacement_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id)
|
||||
DEFERRABLE INITIALLY DEFERRED,
|
||||
CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::UUID),
|
||||
CHECK (binding_version > 0),
|
||||
CHECK (length(issuer) > 0),
|
||||
CHECK (length(subject) > 0),
|
||||
CHECK (length(pubkey) = 32),
|
||||
CHECK (binding_state IN ('active', 'revoked', 'rotated')),
|
||||
CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')),
|
||||
CHECK (transition_kind IN (
|
||||
'legacy_import', 'enroll', 'provision', 'provenance_strengthened',
|
||||
'retire_pair', 'disable_identity', 'revoke_key', 'rotate',
|
||||
'recover', 'enable_identity'
|
||||
)),
|
||||
CHECK (actor IS NULL OR length(actor) = 32),
|
||||
CHECK (length(reason) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_identity_binding_history_principal
|
||||
ON identity_binding_history (community_id, issuer, subject, recorded_at);
|
||||
|
||||
INSERT INTO identity_binding_history
|
||||
(community_id, binding_id, binding_version, issuer, subject, pubkey,
|
||||
binding_state, binding_provenance, transition_kind,
|
||||
replacement_binding_id, actor, reason, recorded_at)
|
||||
SELECT
|
||||
community_id,
|
||||
binding_id,
|
||||
binding_version,
|
||||
issuer,
|
||||
uid,
|
||||
pubkey,
|
||||
binding_state,
|
||||
binding_provenance,
|
||||
'legacy_import',
|
||||
replacement_binding_id,
|
||||
COALESCE(rotation_by, revoked_by),
|
||||
COALESCE(NULLIF(rotation_reason, ''), NULLIF(revoked_reason, ''), 'legacy import'),
|
||||
updated_at
|
||||
FROM identity_bindings;
|
||||
|
||||
-- Idempotency and local state history only. Authorization and complete
|
||||
-- operator audit authority remain outside O3.
|
||||
CREATE TABLE identity_lifecycle_operations (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
operation_id UUID NOT NULL,
|
||||
operation_kind TEXT NOT NULL,
|
||||
request_fingerprint BYTEA NOT NULL,
|
||||
issuer TEXT,
|
||||
subject TEXT,
|
||||
pubkey BYTEA,
|
||||
replacement_pubkey BYTEA,
|
||||
binding_id UUID,
|
||||
replacement_binding_id UUID,
|
||||
binding_version BIGINT,
|
||||
selector_version BIGINT,
|
||||
actor BYTEA,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, operation_id),
|
||||
FOREIGN KEY (community_id, binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
FOREIGN KEY (community_id, replacement_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::UUID),
|
||||
CHECK (operation_kind IN (
|
||||
'provision', 'retire_pair', 'disable_identity', 'revoke_key',
|
||||
'rotate', 'recover', 'enable_identity'
|
||||
)),
|
||||
CHECK (length(request_fingerprint) = 32),
|
||||
CHECK (issuer IS NULL OR length(issuer) > 0),
|
||||
CHECK (subject IS NULL OR length(subject) > 0),
|
||||
CHECK (pubkey IS NULL OR length(pubkey) = 32),
|
||||
CHECK (replacement_pubkey IS NULL OR length(replacement_pubkey) = 32),
|
||||
CHECK (binding_version IS NULL OR binding_version > 0),
|
||||
CHECK (selector_version IS NULL OR selector_version > 0),
|
||||
CHECK (actor IS NULL OR length(actor) = 32),
|
||||
CHECK (length(reason) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_identity_lifecycle_operations_principal
|
||||
ON identity_lifecycle_operations (community_id, issuer, subject, created_at);
|
||||
|
||||
CREATE INDEX idx_identity_lifecycle_operations_key
|
||||
ON identity_lifecycle_operations (community_id, pubkey, created_at);
|
||||
+203
-1
@@ -210,6 +210,13 @@ CREATE TABLE identity_bindings (
|
||||
rotated_to_pubkey BYTEA,
|
||||
rotation_by BYTEA,
|
||||
rotation_reason TEXT,
|
||||
binding_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
binding_version BIGINT NOT NULL DEFAULT 1,
|
||||
binding_state TEXT NOT NULL DEFAULT 'active',
|
||||
binding_provenance TEXT NOT NULL DEFAULT 'tofu',
|
||||
replacement_binding_id UUID,
|
||||
created_by BYTEA,
|
||||
created_policy_version TEXT,
|
||||
CONSTRAINT chk_identity_bindings_issuer_not_empty CHECK (length(issuer) > 0),
|
||||
CONSTRAINT chk_identity_bindings_uid_not_empty CHECK (length(uid) > 0),
|
||||
CONSTRAINT chk_identity_bindings_pubkey_len CHECK (length(pubkey) = 32),
|
||||
@@ -226,7 +233,25 @@ CREATE TABLE identity_bindings (
|
||||
AND (rotation_by IS NULL OR length(rotation_by) = 32)
|
||||
AND rotation_reason IS NOT NULL
|
||||
AND length(rotation_reason) > 0)
|
||||
)
|
||||
),
|
||||
CONSTRAINT identity_bindings_o3_id_unique UNIQUE (community_id, binding_id),
|
||||
CONSTRAINT identity_bindings_o3_principal_version_unique
|
||||
UNIQUE (community_id, issuer, uid, binding_version),
|
||||
CONSTRAINT identity_bindings_o3_replacement_fk
|
||||
FOREIGN KEY (community_id, replacement_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id)
|
||||
DEFERRABLE INITIALLY DEFERRED,
|
||||
CONSTRAINT chk_identity_bindings_o3_id_not_nil
|
||||
CHECK (binding_id <> '00000000-0000-0000-0000-000000000000'::UUID),
|
||||
CONSTRAINT chk_identity_bindings_o3_version_positive CHECK (binding_version > 0),
|
||||
CONSTRAINT chk_identity_bindings_o3_state
|
||||
CHECK (binding_state IN ('active', 'revoked', 'rotated')),
|
||||
CONSTRAINT chk_identity_bindings_o3_provenance
|
||||
CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')),
|
||||
CONSTRAINT chk_identity_bindings_o3_created_by_len
|
||||
CHECK (created_by IS NULL OR length(created_by) = 32),
|
||||
CONSTRAINT chk_identity_bindings_o3_policy_version
|
||||
CHECK (created_policy_version IS NULL OR length(created_policy_version) > 0)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_identity_bindings_active_principal
|
||||
@@ -267,6 +292,183 @@ CREATE TABLE identity_revoked_keys (
|
||||
CHECK (length(reason) > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE identity_migration_denials (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, issuer, subject),
|
||||
CHECK (length(issuer) > 0),
|
||||
CHECK (length(subject) > 0),
|
||||
CHECK (length(reason) > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE identity_binding_lineage (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
predecessor_binding_id UUID NOT NULL,
|
||||
successor_binding_id UUID NOT NULL,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, predecessor_binding_id),
|
||||
UNIQUE (community_id, successor_binding_id),
|
||||
FOREIGN KEY (community_id, predecessor_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
FOREIGN KEY (community_id, successor_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
CHECK (predecessor_binding_id <> successor_binding_id)
|
||||
);
|
||||
|
||||
CREATE TABLE identity_retired_pairs (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
pubkey BYTEA NOT NULL,
|
||||
retired_binding_id UUID,
|
||||
retired_binding_version BIGINT,
|
||||
retired_at TIMESTAMPTZ NOT NULL,
|
||||
retired_by BYTEA,
|
||||
reason TEXT NOT NULL,
|
||||
PRIMARY KEY (community_id, issuer, subject, pubkey),
|
||||
UNIQUE (
|
||||
community_id, issuer, subject, pubkey,
|
||||
retired_binding_id, retired_binding_version
|
||||
),
|
||||
FOREIGN KEY (community_id, retired_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
CHECK (length(issuer) > 0),
|
||||
CHECK (length(subject) > 0),
|
||||
CHECK (length(pubkey) = 32),
|
||||
CHECK (retired_binding_version IS NULL OR retired_binding_version > 0),
|
||||
CHECK (
|
||||
(retired_binding_id IS NULL AND retired_binding_version IS NULL)
|
||||
OR
|
||||
(retired_binding_id IS NOT NULL AND retired_binding_version IS NOT NULL)
|
||||
),
|
||||
CHECK (retired_by IS NULL OR length(retired_by) = 32),
|
||||
CHECK (length(reason) > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE identity_pending_replacements (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
selector_version BIGINT NOT NULL,
|
||||
retired_pubkey BYTEA NOT NULL,
|
||||
retired_binding_id UUID NOT NULL,
|
||||
retired_binding_version BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_operation_id UUID,
|
||||
cleared_at TIMESTAMPTZ,
|
||||
cleared_operation_id UUID,
|
||||
PRIMARY KEY (community_id, issuer, subject, selector_version),
|
||||
FOREIGN KEY (
|
||||
community_id, issuer, subject, retired_pubkey,
|
||||
retired_binding_id, retired_binding_version
|
||||
) REFERENCES identity_retired_pairs (
|
||||
community_id, issuer, subject, pubkey,
|
||||
retired_binding_id, retired_binding_version
|
||||
),
|
||||
CHECK (length(issuer) > 0),
|
||||
CHECK (length(subject) > 0),
|
||||
CHECK (selector_version > 0),
|
||||
CHECK (length(retired_pubkey) = 32),
|
||||
CHECK (retired_binding_version > 0),
|
||||
CHECK (
|
||||
(cleared_at IS NULL AND cleared_operation_id IS NULL)
|
||||
OR
|
||||
(cleared_at IS NOT NULL AND cleared_operation_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_identity_pending_replacements_active
|
||||
ON identity_pending_replacements (community_id, issuer, subject)
|
||||
WHERE cleared_at IS NULL;
|
||||
|
||||
CREATE TABLE identity_binding_history (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
history_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
binding_id UUID NOT NULL,
|
||||
binding_version BIGINT NOT NULL,
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
pubkey BYTEA NOT NULL,
|
||||
binding_state TEXT NOT NULL,
|
||||
binding_provenance TEXT NOT NULL,
|
||||
transition_kind TEXT NOT NULL,
|
||||
replacement_binding_id UUID,
|
||||
operation_id UUID,
|
||||
actor BYTEA,
|
||||
reason TEXT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, history_id),
|
||||
UNIQUE (community_id, binding_id, binding_version, transition_kind),
|
||||
FOREIGN KEY (community_id, binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
FOREIGN KEY (community_id, replacement_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id)
|
||||
DEFERRABLE INITIALLY DEFERRED,
|
||||
CHECK (history_id <> '00000000-0000-0000-0000-000000000000'::UUID),
|
||||
CHECK (binding_version > 0),
|
||||
CHECK (length(issuer) > 0),
|
||||
CHECK (length(subject) > 0),
|
||||
CHECK (length(pubkey) = 32),
|
||||
CHECK (binding_state IN ('active', 'revoked', 'rotated')),
|
||||
CHECK (binding_provenance IN ('attested_key', 'provisioned', 'tofu')),
|
||||
CHECK (transition_kind IN (
|
||||
'legacy_import', 'enroll', 'provision', 'provenance_strengthened',
|
||||
'retire_pair', 'disable_identity', 'revoke_key', 'rotate',
|
||||
'recover', 'enable_identity'
|
||||
)),
|
||||
CHECK (actor IS NULL OR length(actor) = 32),
|
||||
CHECK (length(reason) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_identity_binding_history_principal
|
||||
ON identity_binding_history (community_id, issuer, subject, recorded_at);
|
||||
|
||||
CREATE TABLE identity_lifecycle_operations (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
operation_id UUID NOT NULL,
|
||||
operation_kind TEXT NOT NULL,
|
||||
request_fingerprint BYTEA NOT NULL,
|
||||
issuer TEXT,
|
||||
subject TEXT,
|
||||
pubkey BYTEA,
|
||||
replacement_pubkey BYTEA,
|
||||
binding_id UUID,
|
||||
replacement_binding_id UUID,
|
||||
binding_version BIGINT,
|
||||
selector_version BIGINT,
|
||||
actor BYTEA,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, operation_id),
|
||||
FOREIGN KEY (community_id, binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
FOREIGN KEY (community_id, replacement_binding_id)
|
||||
REFERENCES identity_bindings (community_id, binding_id),
|
||||
CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::UUID),
|
||||
CHECK (operation_kind IN (
|
||||
'provision', 'retire_pair', 'disable_identity', 'revoke_key',
|
||||
'rotate', 'recover', 'enable_identity'
|
||||
)),
|
||||
CHECK (length(request_fingerprint) = 32),
|
||||
CHECK (issuer IS NULL OR length(issuer) > 0),
|
||||
CHECK (subject IS NULL OR length(subject) > 0),
|
||||
CHECK (pubkey IS NULL OR length(pubkey) = 32),
|
||||
CHECK (replacement_pubkey IS NULL OR length(replacement_pubkey) = 32),
|
||||
CHECK (binding_version IS NULL OR binding_version > 0),
|
||||
CHECK (selector_version IS NULL OR selector_version > 0),
|
||||
CHECK (actor IS NULL OR length(actor) = 32),
|
||||
CHECK (length(reason) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_identity_lifecycle_operations_principal
|
||||
ON identity_lifecycle_operations (community_id, issuer, subject, created_at);
|
||||
|
||||
CREATE INDEX idx_identity_lifecycle_operations_key
|
||||
ON identity_lifecycle_operations (community_id, pubkey, created_at);
|
||||
|
||||
-- ── Events (partitioned by month on created_at) ──────────────────────────────
|
||||
-- Conformance: "Channel-less global events and DMs". `community_id` leads the
|
||||
-- PK and every hot-path index. Partition stays BY RANGE (created_at) — the
|
||||
|
||||
Reference in New Issue
Block a user