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

@ -1153,15 +1153,36 @@ fn node_descriptors_report(
fn node_revoke_report(args: NodeRevokeArgs) -> Result<Value> {
if let Some(coordinator) = &args.scope.coordinator {
let tenant = args.scope.tenant.clone();
let project = args.scope.project.clone();
let user = args.scope.user.clone();
let node = args.node.clone();
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(json!({
"type": "revoke_node_credential",
"node": args.node,
"tenant": tenant,
"project": project,
"actor_user": user,
"node": node,
}))?;
return Ok(json!({
"command": "node revoke",
"coordinator": coordinator,
"requires_confirmation": !args.yes,
"tenant": tenant,
"project": project,
"user": user,
"node": node,
"credential_revoked": response.get("type").and_then(Value::as_str) == Some("node_credential_revoked"),
"descriptor_removed": response
.get("descriptor_removed")
.cloned()
.unwrap_or_else(|| json!(false)),
"queued_assignments_removed": response
.get("queued_assignments_removed")
.cloned()
.unwrap_or_else(|| json!(0)),
"node_credentials_separate_from_user_session": true,
"response": response,
"coordinator_session_requests": session.requests(),
}));
@ -4436,6 +4457,50 @@ mod tests {
assert_eq!(revoked["attribution"]["revoked_by_user"], "user");
}
#[test]
fn node_revoke_reports_scoped_credential_revocation() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert!(line.contains(r#""type":"revoke_node_credential""#));
assert!(line.contains(r#""tenant":"tenant""#));
assert!(line.contains(r#""project":"project""#));
assert!(line.contains(r#""actor_user":"user""#));
assert!(line.contains(r#""node":"node-a""#));
stream
.write_all(
br#"{"type":"node_credential_revoked","node":"node-a","tenant":"tenant","project":"project","actor":"user","descriptor_removed":true,"queued_assignments_removed":2}"#,
)
.unwrap();
stream.write_all(b"\n").unwrap();
});
let revoked = node_revoke_report(NodeRevokeArgs {
scope: CliScopeArgs {
coordinator: Some(addr),
tenant: "tenant".to_owned(),
project: "project".to_owned(),
user: "user".to_owned(),
json: false,
},
node: "node-a".to_owned(),
yes: true,
})
.unwrap();
server.join().unwrap();
assert_eq!(revoked["command"], "node revoke");
assert_eq!(revoked["node"], "node-a");
assert_eq!(revoked["credential_revoked"], true);
assert_eq!(revoked["descriptor_removed"], true);
assert_eq!(revoked["queued_assignments_removed"], 2);
assert_eq!(revoked["node_credentials_separate_from_user_session"], true);
}
#[test]
fn human_report_is_text_not_json() {
let report = json!({

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();

View file

@ -112,6 +112,12 @@ pub enum CoordinatorRequest {
project: String,
actor_user: String,
},
RevokeNodeCredential {
tenant: String,
project: String,
actor_user: String,
node: String,
},
ScheduleTask {
tenant: String,
project: String,
@ -471,6 +477,14 @@ pub enum CoordinatorResponse {
descriptors: Vec<NodeDescriptor>,
actor: UserId,
},
NodeCredentialRevoked {
node: NodeId,
tenant: TenantId,
project: ProjectId,
actor: UserId,
descriptor_removed: bool,
queued_assignments_removed: usize,
},
TaskPlacement {
placement: Placement,
},
@ -1017,6 +1031,45 @@ impl CoordinatorService {
.collect();
Ok(CoordinatorResponse::NodeDescriptors { descriptors, actor })
}
CoordinatorRequest::RevokeNodeCredential {
tenant,
project,
actor_user,
node,
} => {
let tenant = TenantId::new(tenant);
let project = ProjectId::new(project);
let actor = UserId::new(actor_user);
let node = NodeId::new(node);
let context = disasmer_core::AuthContext {
tenant: tenant.clone(),
project: project.clone(),
actor: Actor::User(actor.clone()),
};
self.coordinator.revoke_node_credential(&context, &node)?;
let descriptor_removed = self.node_descriptors.remove(&node).is_some();
let queued_assignments_removed = self
.task_assignments
.remove(&(tenant.clone(), project.clone(), node.clone()))
.map_or(0, |assignments| assignments.len());
self.active_tasks
.retain(|(task_tenant, task_project, _, task_node, _)| {
task_tenant != &tenant || task_project != &project || task_node != &node
});
self.task_cancellations
.retain(|(task_tenant, task_project, _, task_node, _)| {
task_tenant != &tenant || task_project != &project || task_node != &node
});
self.coordinator.persist(&mut self.store);
Ok(CoordinatorResponse::NodeCredentialRevoked {
node,
tenant,
project,
actor,
descriptor_removed,
queued_assignments_removed,
})
}
CoordinatorRequest::ScheduleTask {
tenant,
project,
@ -2682,6 +2735,78 @@ mod tests {
assert!(events.is_empty());
}
#[test]
fn service_revokes_node_credentials_and_live_descriptors() {
let mut service = CoordinatorService::new(7);
service
.handle_request(CoordinatorRequest::AttachNode {
tenant: "tenant".to_owned(),
project: "project".to_owned(),
node: "node".to_owned(),
public_key: "public-key".to_owned(),
})
.unwrap();
service
.handle_request(CoordinatorRequest::ReportNodeCapabilities {
tenant: "tenant".to_owned(),
project: "project".to_owned(),
node: "node".to_owned(),
capabilities: linux_capabilities(),
cached_environment_digests: vec![],
dependency_cache_digests: vec![],
source_snapshots: vec![],
artifact_locations: vec![],
direct_connectivity: true,
online: true,
})
.unwrap();
let CoordinatorResponse::NodeCredentialRevoked {
node,
tenant,
project,
actor,
descriptor_removed,
queued_assignments_removed,
} = service
.handle_request(CoordinatorRequest::RevokeNodeCredential {
tenant: "tenant".to_owned(),
project: "project".to_owned(),
actor_user: "user".to_owned(),
node: "node".to_owned(),
})
.unwrap()
else {
panic!("expected node credential revocation");
};
assert_eq!(node, NodeId::from("node"));
assert_eq!(tenant, TenantId::from("tenant"));
assert_eq!(project, ProjectId::from("project"));
assert_eq!(actor, UserId::from("user"));
assert!(descriptor_removed);
assert_eq!(queued_assignments_removed, 0);
let heartbeat = service
.handle_request(CoordinatorRequest::NodeHeartbeat {
node: "node".to_owned(),
})
.unwrap_err();
assert!(heartbeat.to_string().contains("not enrolled"));
let CoordinatorResponse::NodeDescriptors { descriptors, .. } = service
.handle_request(CoordinatorRequest::ListNodeDescriptors {
tenant: "tenant".to_owned(),
project: "project".to_owned(),
actor_user: "user".to_owned(),
})
.unwrap()
else {
panic!("expected node descriptors");
};
assert!(descriptors.is_empty());
}
#[test]
fn service_delivers_cancellation_to_connected_node_and_records_terminal_state() {
let mut service = CoordinatorService::new(7);