merge: compose current auth migration with Ticket case schema 91
This commit is contained in:
@@ -32,7 +32,8 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
tx.execute_batch(
|
||||
"CREATE TEMP TABLE repository_key_cutover (
|
||||
workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
||||
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
||||
created_at_seconds INTEGER NOT NULL, created_at_subsec_nanos INTEGER NOT NULL,
|
||||
PRIMARY KEY(workspace_id,kind,resource_id,legacy_counter));",
|
||||
)?;
|
||||
for (table, kind, id, fingerprint) in [
|
||||
@@ -68,15 +69,20 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
for (workspace, resource, legacy_counter, fingerprint, created) in rows {
|
||||
let mut statement = tx.prepare("SELECT operation_id FROM repository_secret_operations WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
|
||||
let matches = statement
|
||||
.query_map(params![workspace, kind, resource, legacy_counter, created], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?
|
||||
.query_map(
|
||||
params![workspace, kind, resource, legacy_counter, created],
|
||||
|r| r.get::<_, String>(0),
|
||||
)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
if matches.len() != 1 || matches[0].is_empty() {
|
||||
return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into()));
|
||||
}
|
||||
let created_time = frozen_repository_timestamp(
|
||||
&created,
|
||||
&format!("{table}.created_at (workspace {workspace}, resource {resource})"),
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7)",
|
||||
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
workspace,
|
||||
kind,
|
||||
@@ -84,11 +90,26 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
legacy_counter,
|
||||
matches[0],
|
||||
fingerprint,
|
||||
created
|
||||
created_time.timestamp(),
|
||||
created_time.timestamp_subsec_nanos()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Candidate copying uses an INNER JOIN: reject corrupt source rows before
|
||||
// they can disappear and evade the destination foreign-key check.
|
||||
let orphaned_candidates: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workdir_create_credential_candidates c
|
||||
LEFT JOIN workdir_create_operations o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
|
||||
WHERE o.operation_id IS NULL)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if orphaned_candidates {
|
||||
return Err(Error::Store(
|
||||
"Repository key cutover found credential candidates without a Workdir create operation".into(),
|
||||
));
|
||||
}
|
||||
// Current rows must agree with their pointed-to historical key, not merely
|
||||
// resolve to a nonempty receipt. FK checks alone cannot prove this identity.
|
||||
let inconsistent: bool = tx.query_row("SELECT EXISTS (
|
||||
@@ -148,8 +169,17 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, id, legacy_counter, purpose, algorithm, nonce, ciphertext, created, operation) in
|
||||
secrets
|
||||
for (
|
||||
workspace,
|
||||
id,
|
||||
legacy_counter,
|
||||
purpose,
|
||||
algorithm,
|
||||
nonce,
|
||||
ciphertext,
|
||||
created,
|
||||
operation,
|
||||
) in secrets
|
||||
{
|
||||
if algorithm != "aes-256-gcm-v1" {
|
||||
return Err(Error::Store(
|
||||
@@ -233,8 +263,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o
|
||||
WHERE o.state='succeeded'
|
||||
AND NOT EXISTS(SELECT 1 FROM workdir_create_credential_revision_retentions_v86 r WHERE r.workspace_id=o.workspace_id AND r.operation_id=o.operation_id)
|
||||
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));
|
||||
INSERT INTO repository_secret_legacy_receipts
|
||||
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));")?;
|
||||
prepare_repository_cutover_times(&tx)?;
|
||||
tx.execute_batch("INSERT INTO repository_secret_legacy_receipts
|
||||
SELECT e.workspace_id,e.operation_id,e.request_fingerprint,e.resource_kind,e.resource_id,e.result_revision,e.created_at,e.mutation_kind,
|
||||
CASE WHEN e.mutation_kind IN ('credential_created','host_trust_created') THEN 0
|
||||
WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') AND e.result_revision>1 THEN e.result_revision-1
|
||||
@@ -244,8 +275,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
WHERE a.workspace_id=o.workspace_id AND a.resource_id=o.resource_id AND a.revision=o.result_revision AND a.created_at=o.created_at
|
||||
AND ((o.resource_kind='credential' AND a.kind LIKE 'credential_%') OR (o.resource_kind='host_trust' AND a.kind LIKE 'host_trust_%'))
|
||||
HAVING count(*)=1) AS mutation_kind FROM repository_secret_operations_v86 o) e
|
||||
JOIN repository_cutover_times t ON t.kind='receipt' AND t.workspace_id=e.workspace_id AND t.operation_id=e.operation_id
|
||||
LEFT JOIN repository_key_cutover m ON m.workspace_id=e.workspace_id AND m.kind=e.resource_kind AND m.resource_id=e.resource_id
|
||||
AND m.created_at<=e.created_at
|
||||
AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
AND m.legacy_counter=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
|
||||
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END;
|
||||
INSERT INTO workdir_create_operations
|
||||
@@ -257,12 +289,14 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END
|
||||
FROM workdir_create_operations_v86 o
|
||||
LEFT JOIN workdir_terminal_archive_cutover a ON a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND c.created_at<=o.updated_at
|
||||
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND h.created_at<=o.updated_at;
|
||||
LEFT JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND (c.created_at_seconds,c.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND (h.created_at_seconds,h.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos);
|
||||
INSERT INTO workdir_create_credential_candidates
|
||||
SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint
|
||||
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id LEFT JOIN repository_key_cutover m
|
||||
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND m.created_at<=o.updated_at
|
||||
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
|
||||
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a WHERE a.workspace_id=c.workspace_id AND a.operation_id=c.operation_id);
|
||||
INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision;")?;
|
||||
let missing:i64=tx.query_row("SELECT count(*) FROM workdir_create_operations WHERE (credential_id IS NOT NULL AND credential_fingerprint IS NULL) OR (host_trust_id IS NOT NULL AND host_trust_fingerprint IS NULL)",[],|r|r.get(0))?;
|
||||
@@ -313,7 +347,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
] {
|
||||
tx.execute_batch(&format!("DROP TABLE {table}_v86;"))?;
|
||||
}
|
||||
tx.execute_batch("DROP TABLE repository_key_cutover;
|
||||
tx.execute_batch("DROP TABLE repository_key_cutover; DROP TABLE repository_cutover_times;
|
||||
CREATE INDEX idx_repository_secret_audit_workspace_created ON repository_secret_audit_events(workspace_id,created_at,event_id);
|
||||
CREATE INDEX idx_repository_ssh_credentials_workspace_status ON repository_ssh_credentials(workspace_id,status,credential_id);
|
||||
CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id,hostname,port);")?;
|
||||
@@ -360,6 +394,109 @@ BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||
result.and(restore)
|
||||
}
|
||||
|
||||
// Schema 86 used RFC3339 for Repository secrets and epoch-millisecond text for
|
||||
// registry operations. Compare typed instants, never their persisted spelling.
|
||||
fn frozen_repository_timestamp(
|
||||
value: &str,
|
||||
context: &str,
|
||||
) -> Result<chrono::DateTime<chrono::Utc>> {
|
||||
if let Ok(timestamp) = chrono::DateTime::parse_from_rfc3339(value) {
|
||||
return Ok(timestamp.with_timezone(&chrono::Utc));
|
||||
}
|
||||
if let Ok(millis) = value.parse::<i64>()
|
||||
&& let Some(timestamp) = chrono::DateTime::from_timestamp_millis(millis)
|
||||
{
|
||||
return Ok(timestamp);
|
||||
}
|
||||
Err(Error::Store(format!(
|
||||
"Repository key migration found invalid timestamp in {context}"
|
||||
)))
|
||||
}
|
||||
|
||||
fn prepare_repository_cutover_times(tx: &rusqlite::Transaction<'_>) -> Result<()> {
|
||||
// These temporary normalized values never rewrite audit history or the
|
||||
// original timestamps used to identify receipts and encrypted envelopes.
|
||||
// Seconds + nanoseconds also preserve sub-millisecond ordering and offsets.
|
||||
tx.execute_batch(
|
||||
"CREATE TEMP TABLE repository_cutover_times (
|
||||
kind TEXT NOT NULL, workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL,
|
||||
seconds INTEGER NOT NULL, subsec_nanos INTEGER NOT NULL,
|
||||
PRIMARY KEY(kind,workspace_id,operation_id));",
|
||||
)?;
|
||||
for (kind, context, query) in [
|
||||
(
|
||||
"receipt",
|
||||
"repository_secret_operations.created_at",
|
||||
"SELECT workspace_id,operation_id,created_at FROM repository_secret_operations_v86",
|
||||
),
|
||||
(
|
||||
"workdir",
|
||||
"workdir_create_operations.updated_at",
|
||||
"SELECT o.workspace_id,o.operation_id,o.updated_at FROM workdir_create_operations_v86 o
|
||||
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a
|
||||
WHERE a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id)
|
||||
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR
|
||||
EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c
|
||||
WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id))",
|
||||
),
|
||||
] {
|
||||
let rows = tx
|
||||
.prepare(query)?
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
for (workspace, operation, value) in rows {
|
||||
let timestamp = frozen_repository_timestamp(
|
||||
&value,
|
||||
&format!("{context} (workspace {workspace}, operation {operation})"),
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO repository_cutover_times VALUES(?1,?2,?3,?4,?5)",
|
||||
params![
|
||||
kind,
|
||||
workspace,
|
||||
operation,
|
||||
timestamp.timestamp(),
|
||||
timestamp.timestamp_subsec_nanos()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Refuse missing or newer/reused keys with a domain error before a NULL
|
||||
// fingerprint reaches a NOT NULL column. Archived successes are excluded:
|
||||
// they retain opaque history, not authority to retry with a current key.
|
||||
let missing: Option<(String, String)> = tx.query_row(
|
||||
"SELECT r.workspace_id,r.operation_id FROM (
|
||||
SELECT workspace_id,operation_id,'credential' AS kind,credential_id AS resource_id,credential_revision AS legacy_counter
|
||||
FROM workdir_create_operations_v86 WHERE credential_id IS NOT NULL
|
||||
UNION
|
||||
SELECT workspace_id,operation_id,'host_trust',host_trust_id,host_trust_revision
|
||||
FROM workdir_create_operations_v86 WHERE host_trust_id IS NOT NULL
|
||||
UNION
|
||||
SELECT workspace_id,operation_id,'credential',credential_id,credential_revision
|
||||
FROM workdir_create_credential_candidates_v86
|
||||
) r
|
||||
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=r.workspace_id AND t.operation_id=r.operation_id
|
||||
LEFT JOIN repository_key_cutover k ON k.workspace_id=r.workspace_id AND k.kind=r.kind
|
||||
AND k.resource_id=r.resource_id AND k.legacy_counter=r.legacy_counter
|
||||
AND (k.created_at_seconds,k.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
WHERE k.operation_id IS NULL LIMIT 1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
).optional()?;
|
||||
if let Some((workspace, operation)) = missing {
|
||||
return Err(Error::Store(format!(
|
||||
"Workdir SSH snapshot is missing key identity evidence at or before its timestamp (workspace {workspace}, operation {operation})"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives
|
||||
// are never read by credential lookup, lease construction, or a live CAS.
|
||||
fn frozen_repository_json_rows(
|
||||
|
||||
@@ -32,12 +32,12 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
|
||||
let end = start + frozen[start..].find(end).unwrap();
|
||||
conn.execute_batch(&frozen[start..end]).unwrap();
|
||||
}
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','created');
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_revision,config_projection_digest,working_directory_id,state,created_at,updated_at,credential_id,credential_revision)
|
||||
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','created','created','key',7);
|
||||
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','key',7);
|
||||
INSERT INTO workdir_create_credential_candidates VALUES('space','create-workdir',0,'primary','key',7);
|
||||
INSERT INTO workdir_create_credential_revision_retentions VALUES('space','create-workdir',0,'key',7);
|
||||
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
|
||||
@@ -59,11 +59,300 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
|
||||
&mut ciphertext,
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'created')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'2026-01-01T00:00:00.000Z')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
// Exercise the actual persisted formats, including failed creates whose SSH
|
||||
// snapshot remains retry authority. The encrypted fixture uses the real old AAD.
|
||||
fn legacy_ssh_snapshot_times(conn: &Connection, key_created: &str, workdir_updated: &str) {
|
||||
conn.execute(
|
||||
"UPDATE repository_secret_operations SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE repository_secret_audit_events SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credentials SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE server_secret_versions SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO repository_secret_operations VALUES('space','create-host','intent','host_trust','host',3,?1)", [key_created]).unwrap();
|
||||
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_created','host',3,'actor',?1)", [key_created]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','host-key','host-fingerprint',3,?1,?1)", [key_created]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'example.test',22,'ssh-ed25519','host-key','host-fingerprint',?1)", [key_created]).unwrap();
|
||||
conn.execute("UPDATE workdir_create_operations SET state='failed',failure='runtime_workdir_create_failed',host_trust_id='host',host_trust_revision=3,repository_access_mode='read_only',updated_at=?1", [workdir_updated]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_compares_mixed_timestamp_formats_as_instants() {
|
||||
for (key_created, workdir_updated) in [
|
||||
("2026-01-01T00:00:00.000Z", "1767225600000"),
|
||||
("2026-01-01T00:00:00.000Z", "1767225600001"),
|
||||
("2026-01-01T09:00:00.000+09:00", "2026-01-01T00:00:00Z"),
|
||||
(
|
||||
"2026-01-01T00:00:00.000000001Z",
|
||||
"2026-01-01T00:00:00.000000002Z",
|
||||
),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
legacy_ssh_snapshot_times(&conn, key_created, workdir_updated);
|
||||
migrate_repository_keys_v86_to_v87(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 87);
|
||||
let snapshot: (String, String, String, String) = conn.query_row(
|
||||
"SELECT state,credential_fingerprint,host_trust_fingerprint,updated_at FROM workdir_create_operations",
|
||||
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
snapshot,
|
||||
(
|
||||
"failed".into(),
|
||||
"fingerprint".into(),
|
||||
"host-fingerprint".into(),
|
||||
workdir_updated.into()
|
||||
)
|
||||
);
|
||||
let retained: (String, String, String) = conn.query_row(
|
||||
"SELECT c.credential_fingerprint,r.credential_fingerprint,r.credential_operation_id
|
||||
FROM workdir_create_credential_candidates c JOIN workdir_create_credential_retentions r
|
||||
ON r.workspace_id=c.workspace_id AND r.operation_id=c.operation_id AND r.ordinal=c.ordinal",
|
||||
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
retained,
|
||||
(
|
||||
"fingerprint".into(),
|
||||
"fingerprint".into(),
|
||||
"create-key".into()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT created_at FROM repository_ssh_credential_keys",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
key_created
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
.unwrap()
|
||||
.exists([])
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_timestamp_cutover_rolls_back(conn: &Connection, expected_context: &str) {
|
||||
let before =
|
||||
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap();
|
||||
let ciphertext: Vec<u8> = conn
|
||||
.query_row(
|
||||
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let error = migrate_repository_keys_v86_to_v87(conn)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(expected_context), "{error}");
|
||||
assert!(error.contains("timestamp"), "{error}");
|
||||
assert_eq!(current_schema_version(conn).unwrap(), 86);
|
||||
assert_eq!(
|
||||
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
|
||||
[],
|
||||
|r| r.get::<_, Vec<u8>>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
ciphertext
|
||||
);
|
||||
assert!(table_exists(conn, "repository_ssh_credential_revisions").unwrap());
|
||||
assert!(!table_exists(conn, "repository_ssh_credential_keys").unwrap());
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM workdir_create_credential_candidates",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM workdir_create_credential_revision_retentions",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_temp_master WHERE name IN ('repository_key_cutover','repository_cutover_times','workdir_terminal_archive_cutover')", [], |r| r.get::<_,i64>(0)).unwrap(), 0);
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_rejects_newer_reused_snapshot_keys_without_committing_authority() {
|
||||
for source in ["credential", "host_trust", "candidate"] {
|
||||
for later in ["2026-01-01T00:00:00.001Z", "2026-01-01T00:00:00.000000001Z"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
|
||||
if source == "host_trust" {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
|
||||
[later],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [later]).unwrap();
|
||||
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='host_trust_created'", [later]).unwrap();
|
||||
} else {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
|
||||
[later],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [later]).unwrap();
|
||||
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='credential_created'", [later]).unwrap();
|
||||
if source == "candidate" {
|
||||
conn.execute("UPDATE workdir_create_operations SET credential_id=NULL,credential_revision=NULL", []).unwrap();
|
||||
}
|
||||
}
|
||||
assert_timestamp_cutover_rolls_back(&conn, "Workdir SSH snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_invalid_authority_timestamps_roll_back_with_source_context() {
|
||||
for (source, invalid) in [
|
||||
("workdir_create_operations.updated_at", "not-a-date"),
|
||||
(
|
||||
"workdir_create_operations.updated_at",
|
||||
"9223372036854775807",
|
||||
),
|
||||
(
|
||||
"repository_ssh_credential_revisions.created_at",
|
||||
"not-a-date",
|
||||
),
|
||||
(
|
||||
"repository_ssh_host_trust_revisions.created_at",
|
||||
"not-a-date",
|
||||
),
|
||||
("repository_secret_operations.created_at", "not-a-date"),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
|
||||
match source {
|
||||
"workdir_create_operations.updated_at" => {
|
||||
conn.execute(
|
||||
"UPDATE workdir_create_operations SET updated_at=?1",
|
||||
[invalid],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"repository_ssh_credential_revisions.created_at" => {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
|
||||
[invalid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [invalid]).unwrap();
|
||||
}
|
||||
"repository_ssh_host_trust_revisions.created_at" => {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
|
||||
[invalid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [invalid]).unwrap();
|
||||
}
|
||||
"repository_secret_operations.created_at" => {
|
||||
conn.execute("INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','gone',1,?1)", [invalid]).unwrap();
|
||||
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','gone',1,'actor',?1)", [invalid]).unwrap();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_timestamp_cutover_rolls_back(&conn, source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_rejects_orphan_candidates_without_dropping_source_evidence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF;
|
||||
INSERT INTO workdir_create_credential_candidates VALUES('space','missing-parent',0,'primary','key',7);
|
||||
PRAGMA foreign_keys=ON;").unwrap();
|
||||
let before = frozen_repository_json_rows(
|
||||
&conn,
|
||||
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let error = migrate_repository_keys_v86_to_v87(&conn).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("credential candidates without a Workdir create operation"),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 86);
|
||||
assert_eq!(
|
||||
frozen_repository_json_rows(
|
||||
&conn,
|
||||
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
|
||||
&[]
|
||||
)
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(table_exists(&conn, "repository_ssh_credential_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "repository_ssh_credential_keys").unwrap());
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -150,12 +439,12 @@ fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
|
||||
.unwrap();
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE workdir_create_operations SET updated_at='later'",
|
||||
"UPDATE workdir_create_operations SET updated_at='2026-01-04T00:00:00.000Z'",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','created','created')", []).is_err());
|
||||
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')", []).is_err());
|
||||
assert!(
|
||||
!conn
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
@@ -274,14 +563,14 @@ fn repository_key_cutover_preserves_host_endpoints_deleted_audit_and_nondefault_
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'created');
|
||||
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'updated');
|
||||
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'created','updated');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','created');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','updated');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','updated');
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'deleted');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','deleted');
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'2026-01-03T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','2026-01-03T00:00:00.000Z');
|
||||
UPDATE workdir_create_operations SET host_trust_id='host',host_trust_revision=3;").unwrap();
|
||||
conn.execute_batch(&format!(
|
||||
"PRAGMA foreign_keys={foreign_keys}; PRAGMA legacy_alter_table={legacy_alter};"
|
||||
@@ -342,8 +631,8 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr
|
||||
let digest = "sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b";
|
||||
conn.execute("UPDATE workdir_create_operations SET state='succeeded',request_fingerprint=?1,selector='main',source_kind='local_path',source_uri='/tmp/repo',source_revision=9,source_fingerprint='source'",[digest]).unwrap();
|
||||
conn.execute_batch("DELETE FROM workdir_create_credential_revision_retentions;
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'deleted');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','deleted');
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'2026-01-03T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','2026-01-03T00:00:00.000Z');
|
||||
DELETE FROM repository_ssh_credentials;").unwrap();
|
||||
let original =
|
||||
frozen_repository_json_rows(&conn, "SELECT * FROM workdir_create_operations", &[])
|
||||
@@ -666,7 +955,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"credential",
|
||||
"key",
|
||||
1,
|
||||
"2026-01-01",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
"credential_created",
|
||||
),
|
||||
(
|
||||
@@ -675,7 +964,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"credential",
|
||||
"key",
|
||||
2,
|
||||
"2026-01-02",
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
"credential_rotated",
|
||||
),
|
||||
(
|
||||
@@ -684,7 +973,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"host_trust",
|
||||
"host",
|
||||
1,
|
||||
"2026-01-01",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
"host_trust_created",
|
||||
),
|
||||
(
|
||||
@@ -693,7 +982,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"host_trust",
|
||||
"host",
|
||||
2,
|
||||
"2026-01-02",
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
"host_trust_rotated",
|
||||
),
|
||||
] {
|
||||
@@ -708,11 +997,11 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01','2026-01-02')",[&next_fp]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",[&next_fp]).unwrap();
|
||||
let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
|
||||
for (legacy_counter, fingerprint, private, timestamp) in [
|
||||
(1u64, &old_fp, &old_private, "2026-01-01"),
|
||||
(2, &next_fp, &next_private, "2026-01-02"),
|
||||
(1u64, &old_fp, &old_private, "2026-01-01T00:00:00.000Z"),
|
||||
(2, &next_fp, &next_private, "2026-01-02T00:00:00.000Z"),
|
||||
] {
|
||||
conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![legacy_counter,fingerprint,timestamp]).unwrap();
|
||||
let nonce = [legacy_counter as u8; 12];
|
||||
@@ -727,10 +1016,10 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![legacy_counter,nonce.as_slice(),ciphertext,timestamp]).unwrap();
|
||||
}
|
||||
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01','2026-01-02')",params![public,old_fp]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",params![public,old_fp]).unwrap();
|
||||
for (legacy_counter, hostname, timestamp) in [
|
||||
(1, "old.example.test", "2026-01-01"),
|
||||
(2, "new.example.test", "2026-01-02"),
|
||||
(1, "old.example.test", "2026-01-01T00:00:00.000Z"),
|
||||
(2, "new.example.test", "2026-01-02T00:00:00.000Z"),
|
||||
] {
|
||||
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![legacy_counter,hostname,public,old_fp,timestamp]).unwrap();
|
||||
}
|
||||
@@ -884,8 +1173,8 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() {
|
||||
// The old deleted ID has been recreated using the same ordinal. Its earlier
|
||||
// delete receipt must remain history, not acquire proof from this new key.
|
||||
conn.execute_batch("DELETE FROM workdir_create_operations;
|
||||
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01');").unwrap();
|
||||
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01T00:00:00.000Z');").unwrap();
|
||||
migrate_repository_keys_v86_to_v87(&conn).unwrap();
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
@@ -966,8 +1255,8 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
|
||||
}
|
||||
"inconsistent_retention" => {
|
||||
// A different valid retained key passes the ordinal-only candidate FK.
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'later');
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','later');
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'2026-01-04T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','2026-01-04T00:00:00.000Z');
|
||||
UPDATE workdir_create_credential_revision_retentions SET credential_revision=8;").unwrap();
|
||||
}
|
||||
"missing_audit_receipt" => {
|
||||
@@ -978,12 +1267,12 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
|
||||
.unwrap();
|
||||
}
|
||||
"missing_host_receipt" => {
|
||||
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'created','created');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','created');").unwrap();
|
||||
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','2026-01-01T00:00:00.000Z');").unwrap();
|
||||
}
|
||||
"foreign_key_violation" => {
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF;
|
||||
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'created');
|
||||
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'2026-01-01T00:00:00.000Z');
|
||||
PRAGMA foreign_keys=ON;").unwrap();
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -817,17 +817,12 @@ mod current_authority {
|
||||
|
||||
fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let workspace_key =
|
||||
worker_runtime::auth::RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let runtime_key =
|
||||
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap();
|
||||
let workspace_fingerprint = normalize_runtime_public_key(&workspace_key.public_key)
|
||||
.unwrap()
|
||||
.1;
|
||||
store.with_conn(|conn| {
|
||||
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','created','updated');
|
||||
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');")?;
|
||||
conn.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519',?1,?2,'material','active','created','provisioned','updated')", params![workspace_key.public_key,workspace_fingerprint])?;
|
||||
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');
|
||||
INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519','public','sha256:workspace','material','active','created','provisioned','updated');")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let request = WorkspaceRuntimeBinding {
|
||||
@@ -838,11 +833,7 @@ mod current_authority {
|
||||
public_key: runtime_key.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: String::new(),
|
||||
state: WorkspaceRuntimeBindingState::Configured,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some("workspace-key".into()),
|
||||
workspace_public_key_fingerprint: Some(workspace_fingerprint),
|
||||
workspace_trust_id: Some("runtime-issued-trust".into()),
|
||||
created_at: "created".into(),
|
||||
updated_at: "updated".into(),
|
||||
revoked_at: None,
|
||||
@@ -853,36 +844,10 @@ mod current_authority {
|
||||
(store, binding)
|
||||
}
|
||||
|
||||
fn evidence(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeVerificationEvidence {
|
||||
WorkspaceRuntimeVerificationEvidence {
|
||||
workspace_id: binding.workspace_id.clone(),
|
||||
runtime_id: binding.runtime_id.clone(),
|
||||
binding_id: binding.binding_id.clone(),
|
||||
workspace_key_id: binding.workspace_key_id.clone().unwrap(),
|
||||
workspace_public_key_fingerprint: binding
|
||||
.workspace_public_key_fingerprint
|
||||
.clone()
|
||||
.unwrap(),
|
||||
workspace_trust_id: binding.workspace_trust_id.clone().unwrap(),
|
||||
runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(),
|
||||
challenge_id: "challenge".into(),
|
||||
state: "verified".into(),
|
||||
last_outcome: "verified".into(),
|
||||
verified_at: Some("verified-at".into()),
|
||||
checked_at: "checked-at".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_key_reactivation_fences_old_cas_and_verification_evidence() {
|
||||
fn same_key_reactivation_fences_old_config_and_admin_mutations() {
|
||||
let (store, binding) = binding_store();
|
||||
let old_proof = evidence(&binding);
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&old_proof)
|
||||
.unwrap();
|
||||
store
|
||||
.complete_workspace_runtime_verification(&old_proof)
|
||||
.unwrap();
|
||||
assert!(store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||
let (_, revoked) = store
|
||||
.revoke_workspace_runtime_binding_key(
|
||||
"space",
|
||||
@@ -893,16 +858,22 @@ mod current_authority {
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(binding.binding_id, revoked.binding_id);
|
||||
let mut replacement = binding.clone();
|
||||
replacement.state = WorkspaceRuntimeBindingState::Configured;
|
||||
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||
assert!(!store.workspace_runtime_binding_matches(&revoked).unwrap());
|
||||
let (_, reactivated) = store
|
||||
.put_workspace_runtime_binding_key(replacement, Some(&revoked.binding_id), "owner")
|
||||
.put_workspace_runtime_binding_key(binding.clone(), Some(&revoked.binding_id), "owner")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
binding.public_key_fingerprint,
|
||||
reactivated.public_key_fingerprint
|
||||
);
|
||||
assert_ne!(binding.binding_id, reactivated.binding_id);
|
||||
assert!(
|
||||
store
|
||||
.workspace_runtime_binding_matches(&reactivated)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||
assert!(matches!(
|
||||
store.put_workspace_runtime_binding_key(
|
||||
reactivated.clone(),
|
||||
@@ -921,42 +892,27 @@ mod current_authority {
|
||||
),
|
||||
Err(Error::RuntimeBindingIdConflict { .. })
|
||||
));
|
||||
assert!(
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&old_proof)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.complete_workspace_runtime_verification(&old_proof)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.workspace_runtime_verification_matches(
|
||||
&reactivated,
|
||||
&old_proof.workspace_public_key_fingerprint,
|
||||
&old_proof.workspace_trust_id
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_aba_and_workspace_key_change_cannot_reuse_verification_authority() {
|
||||
fn endpoint_aba_fences_old_config_without_copying_workspace_signing_identity() {
|
||||
let (store, binding) = binding_store();
|
||||
let proof = evidence(&binding);
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&proof)
|
||||
.unwrap();
|
||||
store
|
||||
.complete_workspace_runtime_verification(&proof)
|
||||
let renamed = store
|
||||
.update_workspace_runtime_binding_metadata(
|
||||
"space",
|
||||
"remote",
|
||||
"Renamed",
|
||||
&binding.base_url,
|
||||
"renamed-at",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(binding.binding_id, renamed.binding_id);
|
||||
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||
let moved = store
|
||||
.update_workspace_runtime_binding_metadata(
|
||||
"space",
|
||||
"remote",
|
||||
"Remote",
|
||||
"Renamed",
|
||||
"https://other.test",
|
||||
"changed",
|
||||
)
|
||||
@@ -965,35 +921,166 @@ mod current_authority {
|
||||
.update_workspace_runtime_binding_metadata(
|
||||
"space",
|
||||
"remote",
|
||||
"Remote",
|
||||
"Renamed",
|
||||
"https://remote.test",
|
||||
"changed-back",
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(moved.binding_id, binding.binding_id);
|
||||
assert_ne!(returned.binding_id, binding.binding_id);
|
||||
assert!(
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(&proof)
|
||||
.is_err()
|
||||
);
|
||||
let current = evidence(&returned);
|
||||
store
|
||||
.record_workspace_runtime_verification_attempt(¤t)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'",[])?; Ok(()) }).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.complete_workspace_runtime_verification(¤t)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!store.workspace_runtime_binding_matches(&renamed).unwrap());
|
||||
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'", [])?; Ok(()) }).unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace_runtime_binding("space", "remote")
|
||||
.unwrap(),
|
||||
Some(returned.clone())
|
||||
);
|
||||
assert!(store.workspace_runtime_binding_matches(&returned).unwrap());
|
||||
}
|
||||
|
||||
fn rows(conn: &Connection, sql: &str) -> Vec<Vec<rusqlite::types::Value>> {
|
||||
let mut stmt = conn.prepare(sql).unwrap();
|
||||
let count = stmt.column_count();
|
||||
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn schema89_bindings() -> Connection {
|
||||
let conn = legacy_binding_database();
|
||||
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO __yoi_schema_migrations VALUES(89,'descriptive legacy evidence columns')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute_batch("INSERT INTO workspace_runtime_bindings VALUES
|
||||
('space','configured-trust','Configured','https://configured.test','key-c','fp-c','binding-c','configured','workspace_identity','workspace-key','sha256:workspace','trust-c','created-c','updated-c',NULL),
|
||||
('space','verified','Verified','https://verified.test','key-v','fp-v','binding-v','verified','workspace_identity','workspace-key','sha256:workspace','trust-v','created-v','updated-v',NULL),
|
||||
('space','revoked-null-trust','Revoked','https://revoked.test','key-r','fp-r','binding-r','revoked','workspace_identity','workspace-key',NULL,NULL,'created-r','updated-r','revoked-r'),
|
||||
('space','legacy-verified','Legacy','https://legacy.test','key-l','fp-l','binding-l','verified','legacy_server_issuer',NULL,NULL,NULL,'created-l','updated-l',NULL),
|
||||
('space','legacy-revoked','Legacy revoked','https://legacy-r.test','key-lr','fp-lr','binding-lr','revoked','legacy_server_issuer',NULL,NULL,NULL,'created-lr','updated-lr','revoked-lr');
|
||||
INSERT INTO workspace_runtime_verifications VALUES
|
||||
('space','verified','binding-v','workspace-key','sha256:workspace','trust-v','fp-v','challenge-v','verified','verified','verified-at','checked-v'),
|
||||
('space','configured-trust','binding-c','workspace-key','sha256:workspace','trust-c','fp-c','challenge-c','pending','challenge_issued',NULL,'checked-c'),
|
||||
('space','revoked-null-trust','binding-r','workspace-key','sha256:workspace','old-trust','fp-r','challenge-r','failed','verification_failed',NULL,'checked-r');").unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema90_preserves_connections_and_archives_all_auth_metadata_without_reenrollment() {
|
||||
let conn = schema89_bindings();
|
||||
let connections = rows(
|
||||
&conn,
|
||||
"SELECT workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at,revoked_at FROM workspace_runtime_bindings ORDER BY runtime_id",
|
||||
);
|
||||
let metadata = rows(
|
||||
&conn,
|
||||
"SELECT workspace_id,runtime_id,binding_id,state,workspace_key_id,workspace_public_key_fingerprint,workspace_trust_id,updated_at FROM workspace_runtime_bindings ORDER BY runtime_id",
|
||||
);
|
||||
let proofs = rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_verifications ORDER BY runtime_id",
|
||||
);
|
||||
let identities = rows(&conn, "SELECT * FROM workspace_signing_identities");
|
||||
let audit = rows(&conn, "SELECT * FROM workspace_runtime_binding_audit");
|
||||
let removal = rows(&conn, "SELECT * FROM runtime_removal_operations");
|
||||
migrate_runtime_binding_connections_v89_to_v90(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 90);
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id"
|
||||
),
|
||||
connections
|
||||
);
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id"
|
||||
),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id"
|
||||
),
|
||||
proofs
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&conn, "SELECT * FROM workspace_signing_identities"),
|
||||
identities
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&conn, "SELECT * FROM workspace_runtime_binding_audit"),
|
||||
audit
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&conn, "SELECT * FROM runtime_removal_operations"),
|
||||
removal
|
||||
);
|
||||
verify_runtime_binding_connection_schema(&conn).unwrap();
|
||||
assert!(!table_exists(&conn, "workspace_runtime_verifications").unwrap());
|
||||
assert!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&conn, "SELECT * FROM pragma_foreign_key_check"),
|
||||
Vec::<Vec<rusqlite::types::Value>>::new()
|
||||
);
|
||||
conn.execute_batch("UPDATE workspace_runtime_bindings SET base_url='https://moved.test',binding_id='binding-new' WHERE runtime_id='verified';
|
||||
UPDATE workspace_runtime_bindings SET revoked_at='admin-revoked' WHERE runtime_id='configured-trust';
|
||||
DELETE FROM workspace_runtime_bindings WHERE runtime_id='legacy-revoked';").unwrap();
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id"
|
||||
),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id"
|
||||
),
|
||||
proofs
|
||||
);
|
||||
conn.execute(
|
||||
"UPDATE runtime_removal_operations SET state='cleanup_pending'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(conn.execute("UPDATE workspace_runtime_bindings SET display_name='Blocked' WHERE runtime_id='remote'", []).unwrap_err().to_string().contains("runtime_removal_in_progress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema90_failed_rebuild_rolls_back_connections_archives_and_schema_marker() {
|
||||
let conn = schema89_bindings();
|
||||
// An incompatible preexisting archive is not permission to discard historical evidence.
|
||||
conn.execute_batch("CREATE TABLE workspace_runtime_verification_archive(existing TEXT);")
|
||||
.unwrap();
|
||||
let before = rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id",
|
||||
);
|
||||
assert!(migrate_runtime_binding_connections_v89_to_v90(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 89);
|
||||
assert_eq!(
|
||||
rows(
|
||||
&conn,
|
||||
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id"
|
||||
),
|
||||
before
|
||||
);
|
||||
assert!(!table_exists(&conn, "workspace_runtime_binding_verification_archive").unwrap());
|
||||
assert!(table_exists(&conn, "workspace_runtime_verifications").unwrap());
|
||||
assert!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
.state,
|
||||
WorkspaceRuntimeBindingState::Configured
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ use crate::Error;
|
||||
use crate::resource_broker::BackendResourceBroker;
|
||||
#[cfg(test)]
|
||||
use crate::resource_broker::BackendResourceTarget;
|
||||
use crate::store::{
|
||||
ControlPlaneStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBindingState,
|
||||
};
|
||||
use crate::store::{ControlPlaneStore, WorkspaceRuntimeAuthenticationMode};
|
||||
use crate::workspace_signing_identity::WorkspaceSigningIdentityService;
|
||||
use chrono::Utc;
|
||||
use protocol::Segment;
|
||||
@@ -66,11 +64,7 @@ use worker_runtime::ssh_host_key_probe::{
|
||||
SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SshHostKeyProbeRequest,
|
||||
SshHostKeyProbeResponse,
|
||||
};
|
||||
use worker_runtime::workspace_issuer::{
|
||||
WorkspaceCapabilityClaims, WorkspaceRuntimeVerificationAcknowledgement,
|
||||
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
|
||||
WorkspaceRuntimeVerificationResponse, workspace_request_body_digest,
|
||||
};
|
||||
use worker_runtime::workspace_issuer::{WorkspaceCapabilityClaims, workspace_request_body_digest};
|
||||
|
||||
pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
|
||||
@@ -925,32 +919,6 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
})
|
||||
}
|
||||
|
||||
fn activate_workspace_authorization(&self, _binding: crate::store::WorkspaceRuntimeBinding) {}
|
||||
|
||||
fn send_workspace_verification_challenge(
|
||||
&self,
|
||||
_challenge: &WorkspaceRuntimeVerificationChallenge,
|
||||
_bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
|
||||
Err(RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::Unsupported,
|
||||
"runtime_workspace_verification_unsupported",
|
||||
"Workspace Runtime verification is unavailable for this Runtime provider",
|
||||
))
|
||||
}
|
||||
|
||||
fn send_workspace_verification_acknowledgement(
|
||||
&self,
|
||||
_acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
|
||||
_bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
|
||||
Err(RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::Unsupported,
|
||||
"runtime_workspace_verification_unsupported",
|
||||
"Workspace Runtime verification is unavailable for this Runtime provider",
|
||||
))
|
||||
}
|
||||
|
||||
fn list_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary>;
|
||||
|
||||
fn list_workers(&self, limit: usize) -> RuntimeList<InternalWorkerSummary>;
|
||||
@@ -2277,48 +2245,6 @@ impl RuntimeRegistry {
|
||||
runtime.ping()
|
||||
}
|
||||
|
||||
pub fn activate_workspace_authorization(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
binding: crate::store::WorkspaceRuntimeBinding,
|
||||
) -> Result<(), RuntimeRegistryError> {
|
||||
self.runtime(runtime_id)?
|
||||
.activate_workspace_authorization(binding);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_workspace_verification_challenge(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
challenge: &WorkspaceRuntimeVerificationChallenge,
|
||||
bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
|
||||
let runtime = self.runtime(runtime_id).map_err(|_| {
|
||||
RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::Configuration,
|
||||
"runtime_verification_registration_unavailable",
|
||||
"Registered Runtime binding is unavailable",
|
||||
)
|
||||
})?;
|
||||
runtime.send_workspace_verification_challenge(challenge, bearer_token)
|
||||
}
|
||||
|
||||
pub fn send_workspace_verification_acknowledgement(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
|
||||
bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
|
||||
let runtime = self.runtime(runtime_id).map_err(|_| {
|
||||
RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::Configuration,
|
||||
"runtime_verification_registration_unavailable",
|
||||
"Registered Runtime binding is unavailable",
|
||||
)
|
||||
})?;
|
||||
runtime.send_workspace_verification_acknowledgement(acknowledgement, bearer_token)
|
||||
}
|
||||
|
||||
fn runtimes_snapshot(&self) -> Vec<Arc<dyn WorkspaceWorkerRuntime>> {
|
||||
self.runtimes
|
||||
.read()
|
||||
@@ -3518,7 +3444,9 @@ pub struct WorkspaceRuntimeAuthorization {
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
signing_identities: WorkspaceSigningIdentityService,
|
||||
backend_url: String,
|
||||
binding: Arc<RwLock<Option<crate::store::WorkspaceRuntimeBinding>>>,
|
||||
// The connection selected for this client. Compare it with the current
|
||||
// administrative configuration before sending, never with a past auth result.
|
||||
binding: crate::store::WorkspaceRuntimeBinding,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WorkspaceRuntimeAuthorization {
|
||||
@@ -3535,19 +3463,13 @@ impl WorkspaceRuntimeAuthorization {
|
||||
store: Arc<dyn ControlPlaneStore>,
|
||||
signing_identities: WorkspaceSigningIdentityService,
|
||||
backend_url: impl Into<String>,
|
||||
binding: Option<crate::store::WorkspaceRuntimeBinding>,
|
||||
binding: crate::store::WorkspaceRuntimeBinding,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
signing_identities,
|
||||
backend_url: backend_url.into(),
|
||||
binding: Arc::new(RwLock::new(binding)),
|
||||
}
|
||||
}
|
||||
|
||||
fn activate(&self, binding: crate::store::WorkspaceRuntimeBinding) {
|
||||
if let Ok(mut current) = self.binding.write() {
|
||||
*current = Some(binding);
|
||||
binding,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3559,30 +3481,14 @@ impl WorkspaceRuntimeAuthorization {
|
||||
worker_id: Option<&str>,
|
||||
body: &[u8],
|
||||
) -> Result<String, RuntimeDiagnostic> {
|
||||
let binding = self
|
||||
.binding
|
||||
.read()
|
||||
.map_err(|_| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_unavailable",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime authorization is unavailable".to_string(),
|
||||
)
|
||||
})?
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_runtime_verification_required",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding is not verified".to_string(),
|
||||
)
|
||||
})?;
|
||||
if binding.state != WorkspaceRuntimeBindingState::Verified
|
||||
|| binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
|
||||
let binding = &self.binding;
|
||||
// A removed/replaced endpoint must not be used by an old client. This
|
||||
// compares connection configuration, not cached authentication success.
|
||||
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
|
||||
|| binding.revoked_at.is_some()
|
||||
|| !self
|
||||
.store
|
||||
.workspace_runtime_binding_matches(&binding)
|
||||
.workspace_runtime_binding_matches(binding)
|
||||
.map_err(|error| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_unavailable",
|
||||
@@ -3594,7 +3500,7 @@ impl WorkspaceRuntimeAuthorization {
|
||||
return Err(diagnostic(
|
||||
"workspace_runtime_authorization_stale",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding changed or was revoked".to_string(),
|
||||
"Workspace Runtime connection changed or was revoked".to_string(),
|
||||
));
|
||||
}
|
||||
let identity = self
|
||||
@@ -3607,63 +3513,26 @@ impl WorkspaceRuntimeAuthorization {
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
let workspace_key_id = binding.workspace_key_id.as_deref().ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_invalid",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding is missing its Workspace key".to_string(),
|
||||
)
|
||||
})?;
|
||||
let workspace_public_key_fingerprint = binding
|
||||
.workspace_public_key_fingerprint
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_invalid",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding is missing its Workspace public key fingerprint"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let workspace_trust_id = binding.workspace_trust_id.as_deref().ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_invalid",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace Runtime binding is missing its Workspace trust ID".to_string(),
|
||||
)
|
||||
})?;
|
||||
if identity.state != "active"
|
||||
|| identity.key_id != workspace_key_id
|
||||
|| identity.public_key_fingerprint.as_deref() != Some(workspace_public_key_fingerprint)
|
||||
|| !self
|
||||
.store
|
||||
.workspace_runtime_verification_matches(
|
||||
&binding,
|
||||
workspace_public_key_fingerprint,
|
||||
workspace_trust_id,
|
||||
)
|
||||
.map_err(|error| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_unavailable",
|
||||
HostDiagnosticSeverity::Error,
|
||||
error.to_string(),
|
||||
)
|
||||
})?
|
||||
{
|
||||
if identity.state != "active" {
|
||||
return Err(diagnostic(
|
||||
"workspace_runtime_authorization_stale",
|
||||
"workspace_signing_identity_unavailable",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace signing identity no longer matches the verified binding".to_string(),
|
||||
"Workspace signing identity is not active".to_string(),
|
||||
));
|
||||
}
|
||||
let fingerprint = identity.public_key_fingerprint.ok_or_else(|| {
|
||||
diagnostic(
|
||||
"workspace_signing_identity_unavailable",
|
||||
HostDiagnosticSeverity::Error,
|
||||
"Workspace signing identity has no public key fingerprint".to_string(),
|
||||
)
|
||||
})?;
|
||||
let now = Utc::now().timestamp();
|
||||
let claims = WorkspaceCapabilityClaims {
|
||||
issuer: self.backend_url.clone(),
|
||||
issuer_workspace_id: binding.workspace_id.clone(),
|
||||
issuer_key_id: identity.key_id,
|
||||
issuer_public_key_fingerprint: workspace_public_key_fingerprint.to_string(),
|
||||
trust_id: workspace_trust_id.to_string(),
|
||||
binding_id: binding.binding_id.clone(),
|
||||
issuer_public_key_fingerprint: fingerprint,
|
||||
runtime_id: binding.runtime_id.clone(),
|
||||
worker_id: worker_id.map(str::to_string),
|
||||
operation: operation.to_string(),
|
||||
@@ -3674,6 +3543,8 @@ impl WorkspaceRuntimeAuthorization {
|
||||
exp: now.saturating_add(60),
|
||||
jti: uuid::Uuid::now_v7().to_string(),
|
||||
};
|
||||
// The signer validates that these claims still name the selected current
|
||||
// identity; a concurrent rotation cannot silently sign mismatched claims.
|
||||
self.signing_identities
|
||||
.issue_workspace_capability(&binding.workspace_id, &claims)
|
||||
.map_err(|error| {
|
||||
@@ -4251,49 +4122,6 @@ impl RemoteWorkerRuntime {
|
||||
format!("{base}/v1/workers/{worker_id}/protocol/ws")
|
||||
}
|
||||
|
||||
fn post_bearer_json<T, U>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &T,
|
||||
bearer_token: &str,
|
||||
) -> Result<U, RuntimePingFailure>
|
||||
where
|
||||
T: Serialize + ?Sized,
|
||||
U: DeserializeOwned,
|
||||
{
|
||||
let response = self
|
||||
.http
|
||||
.post(self.endpoint(path))
|
||||
.bearer_auth(bearer_token)
|
||||
.json(body)
|
||||
.send()
|
||||
.map_err(|error| {
|
||||
RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::NetworkUnreachable,
|
||||
"runtime_workspace_verification_unreachable",
|
||||
format!("Runtime verification request failed: {error}"),
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::MalformedResponse,
|
||||
"runtime_workspace_verification_rejected",
|
||||
format!(
|
||||
"Runtime verification request returned HTTP {}",
|
||||
status.as_u16()
|
||||
),
|
||||
));
|
||||
}
|
||||
response.json::<U>().map_err(|error| {
|
||||
RuntimePingFailure::new(
|
||||
RuntimePingFailureKind::MalformedResponse,
|
||||
"runtime_workspace_verification_invalid_response",
|
||||
format!("Runtime verification response was invalid: {error}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
|
||||
where
|
||||
T: DeserializeOwned + Send + 'static,
|
||||
@@ -4702,36 +4530,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
})
|
||||
}
|
||||
|
||||
fn activate_workspace_authorization(&self, binding: crate::store::WorkspaceRuntimeBinding) {
|
||||
if let Some(authorization) = &self.workspace_authorization {
|
||||
authorization.activate(binding);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_workspace_verification_challenge(
|
||||
&self,
|
||||
challenge: &WorkspaceRuntimeVerificationChallenge,
|
||||
bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
|
||||
self.post_bearer_json(
|
||||
worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_CHALLENGE_PATH,
|
||||
challenge,
|
||||
bearer_token,
|
||||
)
|
||||
}
|
||||
|
||||
fn send_workspace_verification_acknowledgement(
|
||||
&self,
|
||||
acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
|
||||
bearer_token: &str,
|
||||
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
|
||||
self.post_bearer_json(
|
||||
worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_ACK_PATH,
|
||||
acknowledgement,
|
||||
bearer_token,
|
||||
)
|
||||
}
|
||||
|
||||
fn list_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary> {
|
||||
if limit == 0 {
|
||||
return RuntimeList::new(Vec::new(), Vec::new());
|
||||
@@ -6536,6 +6334,7 @@ pub fn placeholder_spawn_response(host_id: impl Into<String>) -> WorkerSpawnResu
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod restore_auth_tests;
|
||||
mod workspace_auth_tests;
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -9,36 +9,18 @@ use worker_runtime::{
|
||||
auth::RuntimeIdentityMaterial,
|
||||
http_server::{WorkspaceRuntimeHttpAuth, runtime_http_router_with_workspace_auth},
|
||||
workspace_issuer::{
|
||||
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
|
||||
RuntimeVerificationSigner, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
|
||||
WorkspaceIssuerTrustState, WorkspaceRuntimeVerificationAuthority,
|
||||
WorkspaceRuntimeVerificationRecord, issue_workspace_capability_token,
|
||||
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
|
||||
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, issue_workspace_capability_token,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_binding() {
|
||||
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
|
||||
let now = Utc::now().timestamp();
|
||||
let workspace_public_key_fingerprint =
|
||||
crate::workspace_signing_identity::public_key_fingerprint(&workspace_identity.public_key)
|
||||
.unwrap();
|
||||
let binding_id = "binding-restore-test".to_string();
|
||||
let verifications = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
|
||||
verifications
|
||||
.record(WorkspaceRuntimeVerificationRecord {
|
||||
workspace_id: "workspace-test".into(),
|
||||
runtime_id: "runtime-test".into(),
|
||||
binding_id: binding_id.clone(),
|
||||
workspace_trust_id: "trust-restore-test".to_string(),
|
||||
workspace_key_id: "workspace-key".into(),
|
||||
workspace_public_key_fingerprint: workspace_public_key_fingerprint.clone(),
|
||||
runtime_public_key_fingerprint: signer.public_key_fingerprint().to_owned(),
|
||||
verified_at: now,
|
||||
})
|
||||
.unwrap();
|
||||
let public_key =
|
||||
worker_runtime::auth::decode_public_key(&workspace_identity.public_key).unwrap();
|
||||
let verifier = WorkspaceCapabilityVerifier::new(
|
||||
@@ -69,8 +51,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
None,
|
||||
WorkspaceRuntimeHttpAuth {
|
||||
verifier,
|
||||
signer,
|
||||
verifications,
|
||||
runtime_id: "runtime-test".into(),
|
||||
},
|
||||
);
|
||||
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
|
||||
@@ -93,8 +74,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
"wrong-worker",
|
||||
"wrong-body",
|
||||
"wrong-key-fingerprint",
|
||||
"wrong-binding-id",
|
||||
"wrong-trust-id",
|
||||
] {
|
||||
let claims = WorkspaceCapabilityClaims {
|
||||
issuer: "https://backend.test".into(),
|
||||
@@ -111,16 +90,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
} else {
|
||||
workspace_public_key_fingerprint.clone()
|
||||
},
|
||||
binding_id: if case == "wrong-binding-id" {
|
||||
"binding-retired".to_string()
|
||||
} else {
|
||||
binding_id.clone()
|
||||
},
|
||||
trust_id: if case == "wrong-trust-id" {
|
||||
"trust-retired".to_string()
|
||||
} else {
|
||||
"trust-restore-test".to_string()
|
||||
},
|
||||
runtime_id: "runtime-test".into(),
|
||||
worker_id: if case == "wrong-worker" {
|
||||
Some(EmbeddedWorkerId::from_legacy_u64(2).to_string())
|
||||
@@ -176,27 +145,16 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
||||
} else {
|
||||
assert_eq!(
|
||||
status,
|
||||
if case == "wrong-binding-id" {
|
||||
StatusCode::FORBIDDEN
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED
|
||||
},
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{path} {case}: {}",
|
||||
error.error.message
|
||||
);
|
||||
assert_eq!(
|
||||
error.error.code,
|
||||
if case == "wrong-binding-id" {
|
||||
"workspace_runtime_verification_stale"
|
||||
} else {
|
||||
"unauthorized"
|
||||
}
|
||||
);
|
||||
assert_eq!(error.error.code, "unauthorized");
|
||||
let expected = match case {
|
||||
"wrong-operation" => Some("does not authorize this operation"),
|
||||
"wrong-worker" => Some("targets another Worker"),
|
||||
"wrong-body" => Some("does not bind this request body"),
|
||||
"wrong-key-fingerprint" | "wrong-binding-id" | "wrong-trust-id" => None,
|
||||
"wrong-key-fingerprint" => None,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if let Some(expected) = expected {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
use super::*;
|
||||
use crate::store::{
|
||||
AccountRecord, SqliteWorkspaceStore, WorkspaceRecord, WorkspaceRuntimeBinding,
|
||||
WorkspaceSigningIdentityRecord,
|
||||
};
|
||||
use crate::workspace_signing_identity::{
|
||||
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
|
||||
WorkspaceSigningPrivateMaterial, public_key_fingerprint,
|
||||
};
|
||||
use worker_runtime::auth::RuntimeIdentityMaterial;
|
||||
use worker_runtime::workspace_issuer::{
|
||||
InMemoryWorkspaceClaimReplayProtection, VerifiedWorkspaceCapability,
|
||||
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerificationError,
|
||||
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
|
||||
};
|
||||
|
||||
const WORKSPACE: &str = "workspace-auth-test";
|
||||
const RUNTIME: &str = "runtime-auth-test";
|
||||
const BACKEND: &str = "https://backend.test";
|
||||
const PATH: &str = "/v1/ping?probe=1";
|
||||
|
||||
struct Fixture {
|
||||
_temp: tempfile::TempDir,
|
||||
store: Arc<SqliteWorkspaceStore>,
|
||||
materials: Arc<InMemoryWorkspaceSigningMaterialStore>,
|
||||
service: WorkspaceSigningIdentityService,
|
||||
authorizer: WorkspaceRuntimeAuthorization,
|
||||
binding: WorkspaceRuntimeBinding,
|
||||
identity: WorkspaceSigningIdentityRecord,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap());
|
||||
store
|
||||
.upsert_account(&AccountRecord {
|
||||
account_id: "owner".into(),
|
||||
kind: "user".into(),
|
||||
handle: "owner".into(),
|
||||
display_name: "Owner".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
owner_account_id: "owner".into(),
|
||||
display_name: "Workspace".into(),
|
||||
state: "active".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
|
||||
let identity = service.provision_existing(WORKSPACE, "owner").unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate(RUNTIME).unwrap();
|
||||
store
|
||||
.upsert_workspace_runtime_binding(
|
||||
WorkspaceRuntimeBinding {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
runtime_id: RUNTIME.into(),
|
||||
display_name: "Runtime".into(),
|
||||
base_url: "https://runtime.test".into(),
|
||||
public_key_fingerprint: public_key_fingerprint(&runtime_identity.public_key)
|
||||
.unwrap(),
|
||||
public_key: runtime_identity.public_key,
|
||||
binding_id: String::new(),
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
revoked_at: None,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let binding = store
|
||||
.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let authorizer = WorkspaceRuntimeAuthorization::new(
|
||||
store.clone(),
|
||||
service.clone(),
|
||||
BACKEND,
|
||||
binding.clone(),
|
||||
);
|
||||
Self {
|
||||
_temp: temp,
|
||||
store,
|
||||
materials,
|
||||
service,
|
||||
authorizer,
|
||||
binding,
|
||||
identity,
|
||||
}
|
||||
}
|
||||
|
||||
fn issue(&self) -> Result<String, RuntimeDiagnostic> {
|
||||
self.authorizer
|
||||
.issue("GET", PATH, RUNTIME_PING_PERMISSION, None, b"")
|
||||
}
|
||||
|
||||
fn verifier(&self) -> WorkspaceCapabilityVerifier {
|
||||
WorkspaceCapabilityVerifier::new(
|
||||
vec![WorkspaceIssuerTrustRecord {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
backend_url: BACKEND.into(),
|
||||
key_id: self.identity.key_id.clone(),
|
||||
algorithm: "ed25519".into(),
|
||||
public_key: self.identity.public_key.clone().unwrap(),
|
||||
public_key_fingerprint: self.identity.public_key_fingerprint.clone().unwrap(),
|
||||
trust_id: uuid::Uuid::now_v7().to_string(),
|
||||
state: WorkspaceIssuerTrustState::Active,
|
||||
registered_at_unix: Utc::now().timestamp(),
|
||||
updated_at_unix: Utc::now().timestamp(),
|
||||
}],
|
||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// Fixture-only rotation: deliberately no production rotation API or live database.
|
||||
fn rotate_identity(&mut self) {
|
||||
let key_id = "WK-rotated";
|
||||
let material_ref = format!("workspace-signing/{WORKSPACE}/{key_id}");
|
||||
let material = WorkspaceSigningPrivateMaterial::generate(WORKSPACE, key_id).unwrap();
|
||||
let public_key = material.validate_and_public_key(WORKSPACE, key_id).unwrap();
|
||||
let fingerprint = public_key_fingerprint(&public_key).unwrap();
|
||||
self.materials
|
||||
.put_if_absent(&material_ref, &material)
|
||||
.unwrap();
|
||||
self.store.with_conn(|conn| {
|
||||
conn.execute("UPDATE workspace_signing_identities SET key_id = ?1, public_key = ?2, public_key_fingerprint = ?3, private_material_ref = ?4 WHERE workspace_id = ?5",
|
||||
rusqlite::params![key_id, public_key, fingerprint, material_ref, WORKSPACE])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
self.identity = self.service.get_validated(WORKSPACE).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(
|
||||
verifier: &WorkspaceCapabilityVerifier,
|
||||
token: &str,
|
||||
) -> Result<VerifiedWorkspaceCapability, WorkspaceCapabilityVerificationError> {
|
||||
verifier.verify(
|
||||
token,
|
||||
&WorkspaceCapabilityExpectation {
|
||||
workspace_id: WORKSPACE,
|
||||
runtime_id: RUNTIME,
|
||||
worker_id: None,
|
||||
operation: RUNTIME_PING_PERMISSION,
|
||||
method: "GET",
|
||||
path_and_query: PATH,
|
||||
body_digest: &workspace_request_body_digest(b""),
|
||||
now_unix: Utc::now().timestamp(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorizer_token_authenticates_actual_request_without_prior_verification_record() {
|
||||
let fixture = Fixture::new().await;
|
||||
let token = fixture.issue().unwrap();
|
||||
let verifier = fixture.verifier();
|
||||
let authenticated = verify(&verifier, &token).unwrap();
|
||||
assert_eq!(authenticated.workspace_id, WORKSPACE);
|
||||
assert_eq!(authenticated.issuer_key_id, fixture.identity.key_id);
|
||||
assert_eq!(
|
||||
verify(&verifier, &token).unwrap_err(),
|
||||
WorkspaceCapabilityVerificationError::Replay
|
||||
);
|
||||
let token = fixture.issue().unwrap();
|
||||
let wrong_request = WorkspaceCapabilityExpectation {
|
||||
workspace_id: WORKSPACE,
|
||||
runtime_id: RUNTIME,
|
||||
worker_id: None,
|
||||
operation: RUNTIME_PING_PERMISSION,
|
||||
method: "GET",
|
||||
path_and_query: "/v1/ping?probe=2",
|
||||
body_digest: &workspace_request_body_digest(b""),
|
||||
now_unix: Utc::now().timestamp(),
|
||||
};
|
||||
assert_eq!(
|
||||
verifier.verify(&token, &wrong_request).unwrap_err(),
|
||||
WorkspaceCapabilityVerificationError::WrongPathAndQuery
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_authorizer_selects_rotated_workspace_key_and_new_runtime_trust() {
|
||||
let mut fixture = Fixture::new().await;
|
||||
let old_verifier = fixture.verifier();
|
||||
let old_token = fixture.issue().unwrap();
|
||||
fixture.rotate_identity();
|
||||
let new_token = fixture.issue().unwrap();
|
||||
let new_verifier = fixture.verifier();
|
||||
assert_eq!(
|
||||
verify(&new_verifier, &new_token).unwrap().issuer_key_id,
|
||||
"WK-rotated"
|
||||
);
|
||||
assert_eq!(
|
||||
verify(&new_verifier, &old_token).unwrap_err(),
|
||||
WorkspaceCapabilityVerificationError::WrongIssuerKey
|
||||
);
|
||||
assert_eq!(
|
||||
verify(&old_verifier, &new_token).unwrap_err(),
|
||||
WorkspaceCapabilityVerificationError::WrongIssuerKey
|
||||
);
|
||||
// Changing Workspace authority did not replace or copy metadata into the connection.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.store
|
||||
.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
fixture.binding
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replaced_or_revoked_connection_rejects_stale_authorizer() {
|
||||
for revoked in [false, true] {
|
||||
let fixture = Fixture::new().await;
|
||||
fixture.issue().unwrap();
|
||||
if revoked {
|
||||
fixture
|
||||
.store
|
||||
.revoke_workspace_runtime_binding(WORKSPACE, RUNTIME, "2")
|
||||
.unwrap();
|
||||
} else {
|
||||
let mut replacement = fixture.binding.clone();
|
||||
replacement.base_url = "https://replacement-runtime.test".into();
|
||||
fixture
|
||||
.store
|
||||
.upsert_workspace_runtime_binding(replacement, true)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
fixture.issue().unwrap_err().code,
|
||||
"workspace_runtime_authorization_stale"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_workspace_signing_material_rejects_authorization_without_fallback() {
|
||||
let fixture = Fixture::new().await;
|
||||
fixture
|
||||
.materials
|
||||
.delete(&fixture.identity.private_material_ref)
|
||||
.unwrap();
|
||||
let error = fixture.issue().unwrap_err();
|
||||
assert_eq!(error.code, "workspace_runtime_authorization_unavailable");
|
||||
assert!(
|
||||
error.message.contains("private material is missing"),
|
||||
"{}",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_claims_must_match_current_workspace_identity_before_signing() {
|
||||
let mut fixture = Fixture::new().await;
|
||||
let now = Utc::now().timestamp();
|
||||
let mut claims = WorkspaceCapabilityClaims {
|
||||
issuer: BACKEND.into(),
|
||||
issuer_workspace_id: WORKSPACE.into(),
|
||||
issuer_key_id: fixture.identity.key_id.clone(),
|
||||
issuer_public_key_fingerprint: fixture.identity.public_key_fingerprint.clone().unwrap(),
|
||||
runtime_id: RUNTIME.into(),
|
||||
worker_id: None,
|
||||
operation: RUNTIME_PING_PERMISSION.into(),
|
||||
method: "GET".into(),
|
||||
path_and_query: PATH.into(),
|
||||
body_digest: workspace_request_body_digest(b""),
|
||||
iat: now,
|
||||
exp: now + 60,
|
||||
jti: uuid::Uuid::now_v7().to_string(),
|
||||
};
|
||||
let token = fixture
|
||||
.service
|
||||
.issue_workspace_capability(WORKSPACE, &claims)
|
||||
.unwrap();
|
||||
verify(&fixture.verifier(), &token).unwrap();
|
||||
for field in ["workspace", "key", "fingerprint"] {
|
||||
let mut mismatched = claims.clone();
|
||||
match field {
|
||||
"workspace" => mismatched.issuer_workspace_id = "another-workspace".into(),
|
||||
"key" => mismatched.issuer_key_id = "WK-another".into(),
|
||||
"fingerprint" => {
|
||||
mismatched.issuer_public_key_fingerprint = format!("sha256:{}", "0".repeat(64))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(
|
||||
matches!(fixture.service.issue_workspace_capability(WORKSPACE, &mismatched),
|
||||
Err(Error::WorkspaceSigningIdentity { ref code, .. }) if code == "workspace_capability_identity_mismatch"),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
fixture.rotate_identity();
|
||||
assert!(
|
||||
matches!(fixture.service.issue_workspace_capability(WORKSPACE, &claims),
|
||||
Err(Error::WorkspaceSigningIdentity { ref code, .. }) if code == "workspace_capability_identity_mismatch")
|
||||
);
|
||||
claims.issuer_key_id = fixture.identity.key_id.clone();
|
||||
claims.issuer_public_key_fingerprint = fixture.identity.public_key_fingerprint.clone().unwrap();
|
||||
let token = fixture
|
||||
.service
|
||||
.issue_workspace_capability(WORKSPACE, &claims)
|
||||
.unwrap();
|
||||
verify(&fixture.verifier(), &token).unwrap();
|
||||
}
|
||||
@@ -442,49 +442,43 @@ CREATE TABLE workspace_runtime_bindings (
|
||||
public_key TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
|
||||
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
|
||||
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
|
||||
workspace_key_id TEXT,
|
||||
workspace_public_key_fingerprint TEXT,
|
||||
workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
PRIMARY KEY (workspace_id, runtime_id),
|
||||
UNIQUE (workspace_id, public_key_fingerprint),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
|
||||
CHECK (
|
||||
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL)
|
||||
OR
|
||||
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL)))
|
||||
),
|
||||
CHECK (
|
||||
(state = 'revoked' AND revoked_at IS NOT NULL)
|
||||
OR
|
||||
(state != 'revoked' AND revoked_at IS NULL)
|
||||
)
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE TABLE workspace_runtime_verifications (
|
||||
-- Historical authentication metadata only, never current admission authority.
|
||||
CREATE TABLE workspace_runtime_binding_verification_archive (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL CHECK(length(binding_id) > 0),
|
||||
binding_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
workspace_key_id TEXT,
|
||||
workspace_public_key_fingerprint TEXT,
|
||||
workspace_trust_id TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, binding_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE workspace_runtime_verification_archive (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL,
|
||||
workspace_key_id TEXT NOT NULL,
|
||||
workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0),
|
||||
workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0),
|
||||
workspace_public_key_fingerprint TEXT NOT NULL,
|
||||
workspace_trust_id TEXT NOT NULL,
|
||||
runtime_public_key_fingerprint TEXT NOT NULL,
|
||||
challenge_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
|
||||
state TEXT NOT NULL,
|
||||
last_outcome TEXT NOT NULL,
|
||||
verified_at TEXT,
|
||||
checked_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, runtime_id),
|
||||
FOREIGN KEY(workspace_id, runtime_id)
|
||||
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
|
||||
CHECK((state = 'verified' AND verified_at IS NOT NULL)
|
||||
OR (state != 'verified' AND verified_at IS NULL))
|
||||
PRIMARY KEY (workspace_id, runtime_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX workspace_runtime_verifications_state_idx
|
||||
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);
|
||||
CREATE TABLE workspace_runtime_binding_audit (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
@@ -1699,7 +1693,7 @@ CREATE TABLE IF NOT EXISTS ticket_assignment_work_releases (
|
||||
FOREIGN KEY (workspace_id, assignment_id)
|
||||
REFERENCES ticket_worker_assignments(workspace_id, assignment_id) ON DELETE CASCADE
|
||||
);
|
||||
-- Ticket case assignment work constraints (schema 90).
|
||||
-- Ticket case assignment work constraints (schema 91).
|
||||
DROP VIEW IF EXISTS ticket_active_worker_assignments;
|
||||
CREATE VIEW ticket_active_worker_assignments AS
|
||||
SELECT current.* FROM ticket_current_worker_assignments AS current
|
||||
|
||||
@@ -10,7 +10,7 @@ use yoi_workspace_server::hosts::{
|
||||
};
|
||||
use yoi_workspace_server::store::{
|
||||
AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord,
|
||||
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState,
|
||||
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
|
||||
};
|
||||
use yoi_workspace_server::{
|
||||
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
||||
@@ -456,7 +456,7 @@ fn append_workspace_runtime_sources(
|
||||
.into_iter()
|
||||
.filter(|binding| {
|
||||
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
|
||||
&& binding.state == WorkspaceRuntimeBindingState::Verified
|
||||
&& binding.revoked_at.is_none()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
@@ -787,11 +787,7 @@ mod tests {
|
||||
public_key: "unused".to_owned(),
|
||||
public_key_fingerprint: "unused".to_owned(),
|
||||
binding_id: "binding-test".to_owned(),
|
||||
state: WorkspaceRuntimeBindingState::Verified,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||
workspace_key_id: None,
|
||||
workspace_public_key_fingerprint: None,
|
||||
workspace_trust_id: None,
|
||||
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
@@ -815,11 +811,7 @@ mod tests {
|
||||
public_key: "unused".to_owned(),
|
||||
public_key_fingerprint: "unused".to_owned(),
|
||||
binding_id: "binding-test".to_owned(),
|
||||
state: WorkspaceRuntimeBindingState::Verified,
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some("WK-test".to_owned()),
|
||||
workspace_public_key_fingerprint: Some("sha256:workspace-key".to_owned()),
|
||||
workspace_trust_id: Some("trust-test".to_owned()),
|
||||
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
|
||||
@@ -80,10 +80,10 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
seed_migration_workspace(&conn);
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','created');").unwrap();
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','2026-01-01T00:00:00.000Z');").unwrap();
|
||||
let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
|
||||
let mut ciphertext = b"private bytes".to_vec();
|
||||
key.seal_in_place_append_tag(
|
||||
@@ -92,7 +92,7 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
|
||||
&mut ciphertext,
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'created')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'2026-01-01T00:00:00.000Z')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
|
||||
drop(conn);
|
||||
let master_key = dir.path().join("repository-secrets.master-key");
|
||||
std::fs::write(&master_key, [42; 32]).unwrap();
|
||||
|
||||
@@ -53,7 +53,8 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
|
||||
"flow_source_contents",
|
||||
"repositories",
|
||||
"workspace_runtime_bindings",
|
||||
"workspace_runtime_verifications",
|
||||
"workspace_runtime_binding_verification_archive",
|
||||
"workspace_runtime_verification_archive",
|
||||
"workspace_runtime_binding_audit",
|
||||
"workspace_signing_identities",
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,11 +111,9 @@ impl RealDriveFixture {
|
||||
public_key: identity.public_key.clone(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: "binding-drive-test".to_string(),
|
||||
state: StoredRuntimeBindingState::Verified,
|
||||
|
||||
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||
workspace_key_id: None,
|
||||
workspace_public_key_fingerprint: None,
|
||||
workspace_trust_id: None,
|
||||
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
revoked_at: None,
|
||||
|
||||
@@ -160,12 +160,10 @@ impl LaunchFixture {
|
||||
if remote {
|
||||
fixture.runtime_id = "ticket-worker-real-http".to_string();
|
||||
use worker_runtime::workspace_issuer::{
|
||||
InMemoryWorkspaceClaimReplayProtection,
|
||||
InMemoryWorkspaceRuntimeVerificationAuthority, RuntimeVerificationSigner,
|
||||
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
|
||||
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
|
||||
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
|
||||
};
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate(&fixture.runtime_id).unwrap();
|
||||
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
|
||||
let workspace_identity = fixture
|
||||
.api
|
||||
.signing_identities
|
||||
@@ -204,13 +202,11 @@ impl LaunchFixture {
|
||||
display_name: "Ticket Worker real HTTP Runtime".into(),
|
||||
base_url: endpoint.clone(),
|
||||
public_key: runtime_identity.public_key.clone(),
|
||||
public_key_fingerprint: signer.public_key_fingerprint().to_string(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: "binding-launch-test".to_string(),
|
||||
state: StoredRuntimeBindingState::Configured,
|
||||
|
||||
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
workspace_key_id: Some(workspace_identity.key_id),
|
||||
workspace_public_key_fingerprint: workspace_identity.public_key_fingerprint.clone(),
|
||||
workspace_trust_id: Some("trust-launch-test".to_string()),
|
||||
|
||||
created_at: TEST_CREATED_AT.into(),
|
||||
updated_at: TEST_CREATED_AT.into(),
|
||||
revoked_at: None,
|
||||
@@ -228,6 +224,7 @@ impl LaunchFixture {
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let runtime_id = fixture.runtime_id.clone();
|
||||
fixture.runtime_server = Some(tokio::spawn(async move {
|
||||
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
|
||||
runtime,
|
||||
@@ -235,10 +232,7 @@ impl LaunchFixture {
|
||||
None,
|
||||
worker_runtime::http_server::WorkspaceRuntimeHttpAuth {
|
||||
verifier,
|
||||
signer,
|
||||
verifications: Arc::new(
|
||||
InMemoryWorkspaceRuntimeVerificationAuthority::default(),
|
||||
),
|
||||
runtime_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -258,27 +252,12 @@ impl LaunchFixture {
|
||||
cached_status: "active".into(),
|
||||
timeout: std::time::Duration::from_secs(5),
|
||||
};
|
||||
fixture.api.runtime.register_or_replace(
|
||||
RemoteWorkerRuntime::new(
|
||||
remote_config.clone(),
|
||||
TEST_WORKSPACE_ID.to_string(),
|
||||
backend_url.clone(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let verified = perform_workspace_runtime_verification(
|
||||
&fixture.api,
|
||||
fixture.api.runtime.clone(),
|
||||
&binding,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
remote_config.workspace_authorization =
|
||||
Some(crate::hosts::WorkspaceRuntimeAuthorization::new(
|
||||
fixture.api.store.clone(),
|
||||
fixture.api.signing_identities.clone(),
|
||||
backend_url.clone(),
|
||||
Some(verified),
|
||||
binding,
|
||||
));
|
||||
fixture.api.runtime.register_or_replace(
|
||||
RemoteWorkerRuntime::new(remote_config, TEST_WORKSPACE_ID.to_string(), backend_url)
|
||||
|
||||
@@ -694,7 +694,6 @@ async fn revoked_runtime_and_persisted_workspace_fence_reject_new_requests() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
binding.revoked_at = Some("revoked".into());
|
||||
binding.state = StoredRuntimeBindingState::Revoked;
|
||||
f.api
|
||||
.store
|
||||
.upsert_workspace_runtime_binding_record(binding, true)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,7 +102,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"workspace_resource_keys",
|
||||
"workspace_runtime_binding_audit",
|
||||
"workspace_runtime_bindings",
|
||||
"workspace_runtime_verifications",
|
||||
"workspace_runtime_binding_verification_archive",
|
||||
"workspace_runtime_verification_archive",
|
||||
"workspace_signing_identities",
|
||||
"workspace_signing_identity_audit",
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
|
||||
@@ -228,36 +228,7 @@ impl WorkspaceSigningIdentityService {
|
||||
}
|
||||
|
||||
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
|
||||
let identity = self.get_validated(workspace_id)?;
|
||||
if identity.state != "active" {
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_not_provisioned",
|
||||
"Workspace signing identity is not provisioned",
|
||||
));
|
||||
}
|
||||
let material = self
|
||||
.materials
|
||||
.load(&identity.private_material_ref)?
|
||||
.ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_missing",
|
||||
"Workspace signing private material is missing",
|
||||
)
|
||||
})?;
|
||||
let fingerprint = identity.public_key_fingerprint.as_deref().ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_material_mismatch",
|
||||
"Workspace signing public key fingerprint is missing",
|
||||
)
|
||||
})?;
|
||||
let signing_key = material.signing_key(workspace_id, &identity.key_id)?;
|
||||
let public_key = encode_public_key(signing_key.public_key().as_ref());
|
||||
if public_key_fingerprint(&public_key)? != fingerprint {
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_material_mismatch",
|
||||
"Workspace signing private material does not match public metadata",
|
||||
));
|
||||
}
|
||||
let (_, signing_key) = self.select_signing_key(workspace_id)?;
|
||||
Ok(signing_key.sign(payload).as_ref().to_vec())
|
||||
}
|
||||
|
||||
@@ -266,9 +237,20 @@ impl WorkspaceSigningIdentityService {
|
||||
workspace_id: &str,
|
||||
claims: &WorkspaceCapabilityClaims,
|
||||
) -> Result<String> {
|
||||
let (identity, signing_key) = self.select_signing_key(workspace_id)?;
|
||||
if claims.issuer_workspace_id != identity.workspace_id
|
||||
|| claims.issuer_key_id != identity.key_id
|
||||
|| Some(claims.issuer_public_key_fingerprint.as_str())
|
||||
!= identity.public_key_fingerprint.as_deref()
|
||||
{
|
||||
return Err(identity_error(
|
||||
"workspace_capability_identity_mismatch",
|
||||
"Workspace capability claims do not match the current signing identity",
|
||||
));
|
||||
}
|
||||
let input = workspace_capability_signing_input(claims).map_err(capability_error)?;
|
||||
let signature = self.sign(workspace_id, input.bytes())?;
|
||||
assemble_workspace_capability_token(input, &signature).map_err(capability_error)
|
||||
let signature = signing_key.sign(input.bytes());
|
||||
assemble_workspace_capability_token(input, signature.as_ref()).map_err(capability_error)
|
||||
}
|
||||
|
||||
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
|
||||
@@ -306,7 +288,40 @@ impl WorkspaceSigningIdentityService {
|
||||
})
|
||||
}
|
||||
|
||||
fn select_signing_key(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<(
|
||||
WorkspaceSigningIdentityRecord,
|
||||
ring::signature::Ed25519KeyPair,
|
||||
)> {
|
||||
let identity = self
|
||||
.store
|
||||
.get_workspace_signing_identity(workspace_id)?
|
||||
.ok_or_else(|| {
|
||||
identity_error(
|
||||
"workspace_signing_identity_metadata_missing",
|
||||
"Workspace signing identity metadata is missing",
|
||||
)
|
||||
})?;
|
||||
if identity.state != "active" {
|
||||
return Err(identity_error(
|
||||
"workspace_signing_identity_not_provisioned",
|
||||
"Workspace signing identity is not provisioned",
|
||||
));
|
||||
}
|
||||
let signing_key = self.validated_signing_key(&identity)?;
|
||||
Ok((identity, signing_key))
|
||||
}
|
||||
|
||||
fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> {
|
||||
self.validated_signing_key(identity).map(|_| ())
|
||||
}
|
||||
|
||||
fn validated_signing_key(
|
||||
&self,
|
||||
identity: &WorkspaceSigningIdentityRecord,
|
||||
) -> Result<ring::signature::Ed25519KeyPair> {
|
||||
let material = self
|
||||
.materials
|
||||
.load(&identity.private_material_ref)?
|
||||
@@ -316,8 +331,8 @@ impl WorkspaceSigningIdentityService {
|
||||
"Workspace signing private material is missing",
|
||||
)
|
||||
})?;
|
||||
let public_key =
|
||||
material.validate_and_public_key(&identity.workspace_id, &identity.key_id)?;
|
||||
let signing_key = material.signing_key(&identity.workspace_id, &identity.key_id)?;
|
||||
let public_key = encode_public_key(signing_key.public_key().as_ref());
|
||||
let fingerprint = public_key_fingerprint(&public_key)?;
|
||||
if identity.public_key.as_deref() != Some(public_key.as_str())
|
||||
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
|
||||
@@ -327,7 +342,7 @@ impl WorkspaceSigningIdentityService {
|
||||
"Workspace signing private material does not match public metadata",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
Ok(signing_key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,6 +858,14 @@ mod tests {
|
||||
loads: std::sync::atomic::AtomicUsize::new(0),
|
||||
}),
|
||||
);
|
||||
let selected_signature = swapping_service.sign("workspace-1", payload).unwrap();
|
||||
ring::signature::UnparsedPublicKey::new(
|
||||
&ring::signature::ED25519,
|
||||
worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.verify(payload, &selected_signature)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
swapping_service.sign("workspace-1", payload),
|
||||
Err(Error::WorkspaceSigningIdentity { ref code, .. })
|
||||
|
||||
Reference in New Issue
Block a user