Public dry run dryrun-1bb23a07ddd0

Source commit: 1bb23a07ddd082b491fd20b9be7bd7a6f9c46916

Public tree identity: sha256:098417c1f41d9acc1149ce03c1b86e63bb5a13a9932a15beaedef5c21078f140
This commit is contained in:
Michel Paulissen 2026-07-03 20:59:56 +02:00
parent 9f2d6ec010
commit a40778221a
6 changed files with 294 additions and 3 deletions

View file

@ -516,6 +516,35 @@ impl Coordinator {
Ok(())
}
pub fn revoke_node_credential(
&mut self,
context: &AuthContext,
node: &NodeId,
) -> Result<NodeIdentityRecord, CoordinatorError> {
let identity = self
.durable
.node_identities
.get(node)
.ok_or(CoordinatorError::UnknownNode)?
.clone();
if identity.tenant != context.tenant || identity.project != context.project {
return Err(CoordinatorError::Unauthorized(
"node credential is outside the signed-in tenant/project scope".to_owned(),
));
}
if !matches!(context.actor, Actor::User(_)) {
return Err(CoordinatorError::Unauthorized(
"node credential revocation requires a user identity".to_owned(),
));
}
self.durable.node_identities.remove(node);
self.durable.credentials.remove(&format!("node:{node}"));
for active in self.active_processes.values_mut() {
active.connected_nodes.remove(node);
}
Ok(identity)
}
pub fn list_projects(&self, context: &AuthContext) -> Vec<ProjectRecord> {
self.durable
.projects
@ -751,6 +780,71 @@ mod tests {
assert!(coordinator.node_identity(&NodeId::from("node")).is_some());
}
#[test]
fn node_credential_revocation_is_project_scoped_and_removes_identity() {
let store = InMemoryDurableStore::default();
let mut coordinator = Coordinator::boot(&store, 1);
coordinator.enroll_node(
TenantId::from("tenant"),
ProjectId::from("project"),
NodeId::from("node"),
"public-key",
"node:attach",
);
coordinator.durable.credentials.insert(
"node:node".to_owned(),
CredentialRecord {
subject: "node:node".to_owned(),
tenant: TenantId::from("tenant"),
project: Some(ProjectId::from("project")),
kind: CredentialKind::NodeCredential,
public_key_fingerprint: Some(Digest::sha256("public-key")),
},
);
coordinator.start_process(
TenantId::from("tenant"),
ProjectId::from("project"),
ProcessId::from("process"),
);
coordinator
.reconnect_node(
&NodeId::from("node"),
Some((&ProcessId::from("process"), 1)),
)
.unwrap();
let foreign = coordinator
.revoke_node_credential(
&AuthContext {
tenant: TenantId::from("other"),
project: ProjectId::from("project"),
actor: Actor::User(UserId::from("user")),
},
&NodeId::from("node"),
)
.unwrap_err();
assert!(matches!(foreign, CoordinatorError::Unauthorized(_)));
let revoked = coordinator
.revoke_node_credential(
&AuthContext {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
actor: Actor::User(UserId::from("user")),
},
&NodeId::from("node"),
)
.unwrap();
assert_eq!(revoked.id, NodeId::from("node"));
assert!(coordinator.node_identity(&NodeId::from("node")).is_none());
assert!(!coordinator.durable.credentials.contains_key("node:node"));
assert!(!coordinator
.active_process(&ProcessId::from("process"))
.unwrap()
.connected_nodes
.contains(&NodeId::from("node")));
}
#[test]
fn project_listing_is_filtered_by_tenant() {
let store = InMemoryDurableStore::default();