Public dry run dryrun-1bb23a07ddd0
Source commit: 1bb23a07ddd082b491fd20b9be7bd7a6f9c46916 Public tree identity: sha256:098417c1f41d9acc1149ce03c1b86e63bb5a13a9932a15beaedef5c21078f140
This commit is contained in:
parent
9f2d6ec010
commit
a40778221a
6 changed files with 294 additions and 3 deletions
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue