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
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"kind": "disasmer-filtered-public-tree",
|
"kind": "disasmer-filtered-public-tree",
|
||||||
"source_commit": "6341cce4cf9f801a3bfe2248d6e7e6a119cea715",
|
"source_commit": "1bb23a07ddd082b491fd20b9be7bd7a6f9c46916",
|
||||||
"release_name": "dryrun-6341cce4cf9f",
|
"release_name": "dryrun-1bb23a07ddd0",
|
||||||
"filtered_out": [
|
"filtered_out": [
|
||||||
"private/**",
|
"private/**",
|
||||||
"experiments/**",
|
"experiments/**",
|
||||||
|
|
|
||||||
|
|
@ -1153,15 +1153,36 @@ fn node_descriptors_report(
|
||||||
|
|
||||||
fn node_revoke_report(args: NodeRevokeArgs) -> Result<Value> {
|
fn node_revoke_report(args: NodeRevokeArgs) -> Result<Value> {
|
||||||
if let Some(coordinator) = &args.scope.coordinator {
|
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 mut session = JsonLineSession::connect(coordinator)?;
|
||||||
let response = session.request(json!({
|
let response = session.request(json!({
|
||||||
"type": "revoke_node_credential",
|
"type": "revoke_node_credential",
|
||||||
"node": args.node,
|
"tenant": tenant,
|
||||||
|
"project": project,
|
||||||
|
"actor_user": user,
|
||||||
|
"node": node,
|
||||||
}))?;
|
}))?;
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
"command": "node revoke",
|
"command": "node revoke",
|
||||||
"coordinator": coordinator,
|
"coordinator": coordinator,
|
||||||
"requires_confirmation": !args.yes,
|
"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,
|
"response": response,
|
||||||
"coordinator_session_requests": session.requests(),
|
"coordinator_session_requests": session.requests(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -4436,6 +4457,50 @@ mod tests {
|
||||||
assert_eq!(revoked["attribution"]["revoked_by_user"], "user");
|
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]
|
#[test]
|
||||||
fn human_report_is_text_not_json() {
|
fn human_report_is_text_not_json() {
|
||||||
let report = json!({
|
let report = json!({
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,35 @@ impl Coordinator {
|
||||||
Ok(())
|
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> {
|
pub fn list_projects(&self, context: &AuthContext) -> Vec<ProjectRecord> {
|
||||||
self.durable
|
self.durable
|
||||||
.projects
|
.projects
|
||||||
|
|
@ -751,6 +780,71 @@ mod tests {
|
||||||
assert!(coordinator.node_identity(&NodeId::from("node")).is_some());
|
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]
|
#[test]
|
||||||
fn project_listing_is_filtered_by_tenant() {
|
fn project_listing_is_filtered_by_tenant() {
|
||||||
let store = InMemoryDurableStore::default();
|
let store = InMemoryDurableStore::default();
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,12 @@ pub enum CoordinatorRequest {
|
||||||
project: String,
|
project: String,
|
||||||
actor_user: String,
|
actor_user: String,
|
||||||
},
|
},
|
||||||
|
RevokeNodeCredential {
|
||||||
|
tenant: String,
|
||||||
|
project: String,
|
||||||
|
actor_user: String,
|
||||||
|
node: String,
|
||||||
|
},
|
||||||
ScheduleTask {
|
ScheduleTask {
|
||||||
tenant: String,
|
tenant: String,
|
||||||
project: String,
|
project: String,
|
||||||
|
|
@ -471,6 +477,14 @@ pub enum CoordinatorResponse {
|
||||||
descriptors: Vec<NodeDescriptor>,
|
descriptors: Vec<NodeDescriptor>,
|
||||||
actor: UserId,
|
actor: UserId,
|
||||||
},
|
},
|
||||||
|
NodeCredentialRevoked {
|
||||||
|
node: NodeId,
|
||||||
|
tenant: TenantId,
|
||||||
|
project: ProjectId,
|
||||||
|
actor: UserId,
|
||||||
|
descriptor_removed: bool,
|
||||||
|
queued_assignments_removed: usize,
|
||||||
|
},
|
||||||
TaskPlacement {
|
TaskPlacement {
|
||||||
placement: Placement,
|
placement: Placement,
|
||||||
},
|
},
|
||||||
|
|
@ -1017,6 +1031,45 @@ impl CoordinatorService {
|
||||||
.collect();
|
.collect();
|
||||||
Ok(CoordinatorResponse::NodeDescriptors { descriptors, actor })
|
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 {
|
CoordinatorRequest::ScheduleTask {
|
||||||
tenant,
|
tenant,
|
||||||
project,
|
project,
|
||||||
|
|
@ -2682,6 +2735,78 @@ mod tests {
|
||||||
assert!(events.is_empty());
|
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]
|
#[test]
|
||||||
fn service_delivers_cancellation_to_connected_node_and_records_terminal_state() {
|
fn service_delivers_cancellation_to_connected_node_and_records_terminal_state() {
|
||||||
let mut service = CoordinatorService::new(7);
|
let mut service = CoordinatorService::new(7);
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ for (const [name, pattern] of [
|
||||||
["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/],
|
["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/],
|
||||||
["CLI human output coverage", /fn human_report_is_text_not_json\(\)/],
|
["CLI human output coverage", /fn human_report_is_text_not_json\(\)/],
|
||||||
["CLI key lifecycle coverage", /fn key_lifecycle_reports_project_scoped_agent_credentials\(\)/],
|
["CLI key lifecycle coverage", /fn key_lifecycle_reports_project_scoped_agent_credentials\(\)/],
|
||||||
|
["CLI node revoke coverage", /fn node_revoke_reports_scoped_credential_revocation\(\)/],
|
||||||
["doctor unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/],
|
["doctor unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/],
|
||||||
["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/],
|
["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/],
|
||||||
["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/],
|
["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/],
|
||||||
|
|
@ -137,6 +138,11 @@ expect(
|
||||||
"coordinator agent key lifecycle coverage",
|
"coordinator agent key lifecycle coverage",
|
||||||
/fn service_manages_project_scoped_agent_public_keys\(\)/
|
/fn service_manages_project_scoped_agent_public_keys\(\)/
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
coordinator,
|
||||||
|
"coordinator node revoke coverage",
|
||||||
|
/fn service_revokes_node_credentials_and_live_descriptors\(\)/
|
||||||
|
);
|
||||||
|
|
||||||
for (const [name, pattern] of [
|
for (const [name, pattern] of [
|
||||||
["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/],
|
["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/],
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ for (const variant of [
|
||||||
"RevokeAgentPublicKey",
|
"RevokeAgentPublicKey",
|
||||||
"NodeHeartbeat",
|
"NodeHeartbeat",
|
||||||
"ReportNodeCapabilities",
|
"ReportNodeCapabilities",
|
||||||
|
"RevokeNodeCredential",
|
||||||
"RequestRendezvous",
|
"RequestRendezvous",
|
||||||
"RequestSourcePreparation",
|
"RequestSourcePreparation",
|
||||||
"CompleteSourcePreparation",
|
"CompleteSourcePreparation",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue