feat: complete Epic 1 production foundation
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
use crank_core::{Secret, SecretId, SecretKind, SecretStatus, WorkspaceId};
|
||||
use crank_registry::{
|
||||
CreateSecretRequest, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate, RegistryError,
|
||||
RotateSecretRequest,
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::common::TestDatabase;
|
||||
|
||||
fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registers_and_verifies_active_master_key_identity() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
let registered = registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let verified = registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(registered.epoch, 1);
|
||||
assert_eq!(registered.status, "active");
|
||||
assert_eq!(registered, verified);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_different_active_master_key_identity() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RegistryError::MasterKeyIdentityMismatch { epoch: 1 }
|
||||
));
|
||||
assert!(!error.to_string().contains("fedcba"));
|
||||
assert!(!error.to_string().contains("012345"));
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn master_key_rotation_is_resumable_and_promotes_atomically() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
let later = timestamp("2026-08-21T00:01:00Z");
|
||||
let current_fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: current_fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
insert_secret_version(®istry, "secret_a", "cipher-a", &now).await;
|
||||
insert_secret_version(®istry, "secret_b", "cipher-b", &now).await;
|
||||
|
||||
let snapshot = registry
|
||||
.list_secret_versions_for_master_key_epoch(1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.len(), 2);
|
||||
|
||||
let preflight_status = registry.master_key_rotation_status().await.unwrap();
|
||||
assert_eq!(preflight_status.active_identity.unwrap().epoch, 1);
|
||||
assert!(preflight_status.rotations.is_empty());
|
||||
assert!(
|
||||
registry
|
||||
.list_secret_versions_for_master_key_epoch(1)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|version| version.target_ciphertext.is_none())
|
||||
);
|
||||
|
||||
let rotation = registry
|
||||
.begin_master_key_rotation(1, 2, target_fingerprint, Some("offline-backup-ref"), &now)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rotation.state, "running");
|
||||
assert_eq!(rotation.total_secret_versions, 2);
|
||||
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&SecretId::new("secret_a"),
|
||||
1,
|
||||
1,
|
||||
"target-cipher-a",
|
||||
"v2",
|
||||
2,
|
||||
&later,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let resumed = registry
|
||||
.begin_master_key_rotation(1, 2, target_fingerprint, Some("offline-backup-ref"), &later)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resumed.processed_secret_versions, 1);
|
||||
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&SecretId::new("secret_b"),
|
||||
1,
|
||||
1,
|
||||
"target-cipher-b",
|
||||
"v2",
|
||||
2,
|
||||
&later,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let ready = registry
|
||||
.finish_master_key_rotation_batches(&rotation.id, &later)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ready.state, "verifying");
|
||||
registry
|
||||
.verify_master_key_rotation(&rotation.id, 2, &later)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.promote_master_key_rotation(
|
||||
&rotation.id,
|
||||
MasterKeyIdentityCandidate {
|
||||
epoch: 2,
|
||||
fingerprint: target_fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &later,
|
||||
},
|
||||
&later,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = registry.master_key_rotation_status().await.unwrap();
|
||||
let active = status.active_identity.unwrap();
|
||||
assert_eq!(active.epoch, 2);
|
||||
assert_eq!(active.fingerprint, target_fingerprint);
|
||||
assert_eq!(status.rotations[0].state, "promoted");
|
||||
assert!(
|
||||
registry
|
||||
.list_secret_versions_for_master_key_epoch(1)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let promoted = registry
|
||||
.list_secret_versions_for_master_key_epoch(2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(promoted.len(), 2);
|
||||
assert!(
|
||||
promoted
|
||||
.iter()
|
||||
.all(|version| version.target_ciphertext.is_none())
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn abort_preserves_current_epoch_and_clears_target_ciphertext() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
insert_secret_version(®istry, "secret_abort", "cipher-a", &now).await;
|
||||
let rotation = registry
|
||||
.begin_master_key_rotation(
|
||||
1,
|
||||
2,
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
||||
None,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&SecretId::new("secret_abort"),
|
||||
1,
|
||||
1,
|
||||
"target-cipher-a",
|
||||
"v2",
|
||||
2,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
registry
|
||||
.abort_master_key_rotation(&rotation.id, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = registry.master_key_rotation_status().await.unwrap();
|
||||
assert_eq!(status.active_identity.unwrap().epoch, 1);
|
||||
assert_eq!(status.rotations[0].state, "aborted");
|
||||
let versions = registry
|
||||
.list_secret_versions_for_master_key_epoch(1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(versions.len(), 1);
|
||||
assert!(versions[0].target_ciphertext.is_none());
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aborted_rotation_can_be_rerun_for_same_source_epoch() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
insert_secret_version(®istry, "secret_retry", "cipher-a", &now).await;
|
||||
let rotation = registry
|
||||
.begin_master_key_rotation(1, 2, target_fingerprint, None, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&SecretId::new("secret_retry"),
|
||||
1,
|
||||
1,
|
||||
"target-cipher-a",
|
||||
"v2",
|
||||
2,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.abort_master_key_rotation(&rotation.id, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rerun = registry
|
||||
.begin_master_key_rotation(1, 2, target_fingerprint, Some("second-attempt"), &now)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rerun.id, rotation.id);
|
||||
assert_eq!(rerun.state, "running");
|
||||
assert_eq!(rerun.processed_secret_versions, 0);
|
||||
assert_eq!(rerun.backup_ref.as_deref(), Some("second-attempt"));
|
||||
assert!(
|
||||
registry
|
||||
.list_secret_versions_for_master_key_epoch(1)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|version| version.target_ciphertext.is_none())
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_rotation_rejects_new_secret_writes() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
insert_secret_version(®istry, "existing_secret", "cipher-a", &now).await;
|
||||
registry
|
||||
.begin_master_key_rotation(
|
||||
1,
|
||||
2,
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
||||
None,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let create_error = registry
|
||||
.create_secret(CreateSecretRequest {
|
||||
secret: &test_secret("new_secret", &now),
|
||||
ciphertext: "cipher-new",
|
||||
key_version: "v2",
|
||||
master_key_epoch: 1,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
create_error,
|
||||
RegistryError::MasterKeyRotationInProgress
|
||||
));
|
||||
|
||||
let rotate_error = registry
|
||||
.rotate_secret(RotateSecretRequest {
|
||||
workspace_id: &WorkspaceId::new("ws_default"),
|
||||
secret_id: &SecretId::new("existing_secret"),
|
||||
ciphertext: "cipher-rotated",
|
||||
key_version: "v2",
|
||||
master_key_epoch: 1,
|
||||
created_at: &now,
|
||||
updated_at: &now,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
rotate_error,
|
||||
RegistryError::MasterKeyRotationInProgress
|
||||
));
|
||||
|
||||
let delete_error = registry
|
||||
.delete_secret(
|
||||
&WorkspaceId::new("ws_default"),
|
||||
&SecretId::new("existing_secret"),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
delete_error,
|
||||
RegistryError::MasterKeyRotationInProgress
|
||||
));
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_source_epoch_writer_is_rejected_after_promotion() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let now = timestamp("2026-08-21T00:00:00Z");
|
||||
let source_fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let target_fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
registry
|
||||
.verify_or_register_master_key_identity(MasterKeyIdentityCandidate {
|
||||
epoch: 1,
|
||||
fingerprint: source_fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
insert_secret_version(®istry, "promoted_secret", "cipher-a", &now).await;
|
||||
let rotation = registry
|
||||
.begin_master_key_rotation(1, 2, target_fingerprint, None, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&SecretId::new("promoted_secret"),
|
||||
1,
|
||||
1,
|
||||
"target-cipher-a",
|
||||
"v2",
|
||||
2,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.finish_master_key_rotation_batches(&rotation.id, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.verify_master_key_rotation(&rotation.id, 1, &now)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.promote_master_key_rotation(
|
||||
&rotation.id,
|
||||
MasterKeyIdentityCandidate {
|
||||
epoch: 2,
|
||||
fingerprint: target_fingerprint,
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
},
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let create_error = registry
|
||||
.create_secret(CreateSecretRequest {
|
||||
secret: &test_secret("stale_new_secret", &now),
|
||||
ciphertext: "cipher-new",
|
||||
key_version: "v2",
|
||||
master_key_epoch: 1,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
create_error,
|
||||
RegistryError::MasterKeyIdentityMismatch { epoch: 2 }
|
||||
));
|
||||
|
||||
let rotate_error = registry
|
||||
.rotate_secret(RotateSecretRequest {
|
||||
workspace_id: &WorkspaceId::new("ws_default"),
|
||||
secret_id: &SecretId::new("promoted_secret"),
|
||||
ciphertext: "cipher-rotated",
|
||||
key_version: "v2",
|
||||
master_key_epoch: 1,
|
||||
created_at: &now,
|
||||
updated_at: &now,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
rotate_error,
|
||||
RegistryError::MasterKeyIdentityMismatch { epoch: 2 }
|
||||
));
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
async fn insert_secret_version(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
id: &str,
|
||||
ciphertext: &str,
|
||||
now: &OffsetDateTime,
|
||||
) {
|
||||
registry
|
||||
.create_secret(CreateSecretRequest {
|
||||
secret: &test_secret(id, now),
|
||||
ciphertext,
|
||||
key_version: "v2",
|
||||
master_key_epoch: 1,
|
||||
created_by: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn test_secret(id: &str, now: &OffsetDateTime) -> Secret {
|
||||
Secret {
|
||||
id: SecretId::new(id),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
name: id.to_owned(),
|
||||
kind: SecretKind::Token,
|
||||
status: SecretStatus::Active,
|
||||
current_version: 1,
|
||||
created_at: *now,
|
||||
updated_at: *now,
|
||||
last_used_at: None,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user