232 lines
7.6 KiB
Rust
232 lines
7.6 KiB
Rust
use sqlx::{PgConnection, Row, query};
|
|
|
|
use super::{
|
|
authority::MigrationError,
|
|
schema_guard::{
|
|
column_exists, constraint_expression, enum_constraint_matches, normalize_definition,
|
|
relation_exists, schema_error,
|
|
},
|
|
};
|
|
|
|
pub(super) async fn validate_v7_master_key_identity(
|
|
connection: &mut PgConnection,
|
|
) -> Result<(), MigrationError> {
|
|
for relation in ["master_key_identities", "master_key_rotations"] {
|
|
if !relation_exists(connection, relation).await? {
|
|
return Err(schema_error(7));
|
|
}
|
|
}
|
|
for (column, data_type, nullable) in [
|
|
("master_key_epoch", "bigint", false),
|
|
("target_ciphertext", "text", true),
|
|
("target_key_version", "text", true),
|
|
("target_master_key_epoch", "bigint", true),
|
|
] {
|
|
if !secret_version_column_matches(connection, column, data_type, nullable).await? {
|
|
return Err(schema_error(7));
|
|
}
|
|
}
|
|
validate_identity_constraints(connection).await?;
|
|
validate_rotation_constraints(connection).await?;
|
|
validate_secret_version_constraints(connection).await?;
|
|
validate_active_identity_index(connection).await
|
|
}
|
|
|
|
async fn secret_version_column_matches(
|
|
connection: &mut PgConnection,
|
|
column: &str,
|
|
data_type: &str,
|
|
nullable: bool,
|
|
) -> Result<bool, MigrationError> {
|
|
if !column_exists(connection, "secret_versions", column).await? {
|
|
return Ok(false);
|
|
}
|
|
let row = query(
|
|
"select data_type, is_nullable from information_schema.columns
|
|
where table_schema = current_schema()
|
|
and table_name = 'secret_versions'
|
|
and column_name = $1",
|
|
)
|
|
.bind(column)
|
|
.fetch_optional(connection)
|
|
.await
|
|
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
|
Ok(row.is_some_and(|row| {
|
|
row.try_get::<String, _>("data_type").ok().as_deref() == Some(data_type)
|
|
&& row.try_get::<String, _>("is_nullable").ok().as_deref()
|
|
== Some(if nullable { "YES" } else { "NO" })
|
|
}))
|
|
}
|
|
|
|
async fn validate_identity_constraints(
|
|
connection: &mut PgConnection,
|
|
) -> Result<(), MigrationError> {
|
|
let identity_epoch = constraint_expression(
|
|
connection,
|
|
"master_key_identities",
|
|
"master_key_identities_epoch_check",
|
|
)
|
|
.await?;
|
|
if normalize_definition(&identity_epoch) != "epoch>0" {
|
|
return Err(schema_error(7));
|
|
}
|
|
let identity_fingerprint = constraint_expression(
|
|
connection,
|
|
"master_key_identities",
|
|
"master_key_identities_fingerprint_check",
|
|
)
|
|
.await?;
|
|
if !regex_constraint_matches(&identity_fingerprint, "fingerprint", "^[0-9a-f]{64}$") {
|
|
return Err(schema_error(7));
|
|
}
|
|
let identity_cipher = constraint_expression(
|
|
connection,
|
|
"master_key_identities",
|
|
"master_key_identities_cipher_contract_check",
|
|
)
|
|
.await?;
|
|
if !enum_constraint_matches(
|
|
&identity_cipher,
|
|
"cipher_contract",
|
|
&["secret-envelope-v2/aes-256-gcm-hkdf-sha256"],
|
|
) {
|
|
return Err(schema_error(7));
|
|
}
|
|
let identity_status = constraint_expression(
|
|
connection,
|
|
"master_key_identities",
|
|
"master_key_identities_status_check",
|
|
)
|
|
.await?;
|
|
if enum_constraint_matches(
|
|
&identity_status,
|
|
"status",
|
|
&["active", "pending", "retired", "revoked"],
|
|
) {
|
|
Ok(())
|
|
} else {
|
|
Err(schema_error(7))
|
|
}
|
|
}
|
|
|
|
async fn validate_rotation_constraints(
|
|
connection: &mut PgConnection,
|
|
) -> Result<(), MigrationError> {
|
|
let rotation_state = constraint_expression(
|
|
connection,
|
|
"master_key_rotations",
|
|
"master_key_rotations_state_check",
|
|
)
|
|
.await?;
|
|
if enum_constraint_matches(
|
|
&rotation_state,
|
|
"state",
|
|
&[
|
|
"preflighted",
|
|
"running",
|
|
"verifying",
|
|
"verified",
|
|
"promoted",
|
|
"aborted",
|
|
"failed",
|
|
],
|
|
) {
|
|
Ok(())
|
|
} else {
|
|
Err(schema_error(7))
|
|
}
|
|
}
|
|
|
|
async fn validate_secret_version_constraints(
|
|
connection: &mut PgConnection,
|
|
) -> Result<(), MigrationError> {
|
|
let secret_epoch = constraint_expression(
|
|
connection,
|
|
"secret_versions",
|
|
"secret_versions_master_key_epoch_check",
|
|
)
|
|
.await?;
|
|
if normalize_definition(&secret_epoch) != "master_key_epoch>0" {
|
|
return Err(schema_error(7));
|
|
}
|
|
let secret_target_epoch = constraint_expression(
|
|
connection,
|
|
"secret_versions",
|
|
"secret_versions_target_epoch_check",
|
|
)
|
|
.await?;
|
|
if normalize_definition(&secret_target_epoch)
|
|
!= "target_master_key_epochisnullortarget_master_key_epoch>master_key_epoch"
|
|
{
|
|
return Err(schema_error(7));
|
|
}
|
|
let secret_all_or_none = constraint_expression(
|
|
connection,
|
|
"secret_versions",
|
|
"secret_versions_target_all_or_none_check",
|
|
)
|
|
.await?;
|
|
if target_ciphertext_all_or_none_matches(&secret_all_or_none) {
|
|
Ok(())
|
|
} else {
|
|
Err(schema_error(7))
|
|
}
|
|
}
|
|
|
|
async fn validate_active_identity_index(
|
|
connection: &mut PgConnection,
|
|
) -> Result<(), MigrationError> {
|
|
let active_index = query(
|
|
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
|
|
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
|
pg_get_expr(i.indpred, i.indrelid) as predicate
|
|
from pg_catalog.pg_index i
|
|
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
|
join pg_catalog.pg_class t on t.oid = i.indrelid
|
|
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
|
join pg_catalog.pg_am am on am.oid = idx.relam
|
|
where n.nspname = current_schema()
|
|
and idx.relname = 'master_key_identities_active_idx'",
|
|
)
|
|
.fetch_optional(connection)
|
|
.await
|
|
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
|
let valid = active_index.is_some_and(|row| {
|
|
row.try_get::<String, _>("table_name").ok().as_deref() == Some("master_key_identities")
|
|
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
|
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
|
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
|
&& row.try_get::<bool, _>("indisunique").ok() == Some(true)
|
|
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("status")
|
|
&& row
|
|
.try_get::<String, _>("predicate")
|
|
.ok()
|
|
.is_some_and(|value| normalize_definition(&value) == "status='active'::text")
|
|
});
|
|
if valid { Ok(()) } else { Err(schema_error(7)) }
|
|
}
|
|
|
|
fn regex_constraint_matches(expression: &str, column: &str, pattern: &str) -> bool {
|
|
let normalized = normalize_definition(expression);
|
|
if !normalized.contains(column) || normalized.contains("ortrue") {
|
|
return false;
|
|
}
|
|
let values = expression
|
|
.split('\'')
|
|
.enumerate()
|
|
.filter_map(|(index, value)| (index % 2 == 1).then_some(value))
|
|
.collect::<Vec<_>>();
|
|
values == [pattern]
|
|
}
|
|
|
|
fn target_ciphertext_all_or_none_matches(expression: &str) -> bool {
|
|
let normalized = normalize_definition(expression);
|
|
!normalized.contains("ortrue")
|
|
&& normalized.contains("target_ciphertextisnull")
|
|
&& normalized.contains("target_key_versionisnull")
|
|
&& normalized.contains("target_master_key_epochisnull")
|
|
&& normalized.contains("target_ciphertextisnotnull")
|
|
&& normalized.contains("target_key_versionisnotnull")
|
|
&& normalized.contains("target_master_key_epochisnotnull")
|
|
}
|