diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 906e5f1..783ca77 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "57e56a24e60a3990d91c199548b96b6c1f0fe962", - "release_name": "dryrun-57e56a24e60a", + "source_commit": "3313653a3d00fcd3fa5613e12cde2f76ae9a8ad0", + "release_name": "dryrun-3313653a3d00", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index a70de24..d02c072 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1335,32 +1335,98 @@ fn build_report(args: BuildArgs, cwd: PathBuf) -> Result { fn node_enroll_report(args: NodeEnrollArgs) -> Result { 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 requested_grant = args.grant.clone(); + let ttl_seconds = args.ttl_seconds; let mut session = JsonLineSession::connect(coordinator)?; let response = session.request(json!({ "type": "create_node_enrollment_grant", - "tenant": args.scope.tenant, - "project": args.scope.project, - "actor_user": args.scope.user, - "grant": args.grant, + "tenant": tenant, + "project": project, + "actor_user": user, + "grant": requested_grant, "now_epoch_seconds": 0, - "ttl_seconds": args.ttl_seconds, + "ttl_seconds": ttl_seconds, }))?; + let enrollment_grant = node_enrollment_grant_summary( + &response, + &tenant, + &project, + &user, + &requested_grant, + ttl_seconds, + ); return Ok(json!({ "command": "node enroll", + "status": "created", "coordinator": coordinator, + "tenant": tenant, + "project": project, + "user": user, + "private_website_required": false, + "enrollment_grant": enrollment_grant, "response": response, "coordinator_session_requests": session.requests(), })); } + let enrollment_grant = json!({ + "grant": args.grant, + "tenant": args.scope.tenant, + "project": args.scope.project, + "user": args.scope.user, + "scope": "node:attach", + "ttl_seconds": args.ttl_seconds, + "short_lived": true, + "exchange_for_persistent_node_identity": true, + "node_credentials_separate_from_user_session": true, + }); Ok(json!({ "command": "node enroll", "status": "planned_without_coordinator", - "grant": args.grant, - "ttl_seconds": args.ttl_seconds, - "scope": "node:attach", + "private_website_required": false, + "enrollment_grant": enrollment_grant, })) } +fn node_enrollment_grant_summary( + response: &Value, + tenant: &str, + project: &str, + user: &str, + requested_grant: &str, + ttl_seconds: u64, +) -> Value { + json!({ + "grant": response + .get("grant") + .cloned() + .unwrap_or_else(|| json!(requested_grant)), + "tenant": response + .get("tenant") + .cloned() + .unwrap_or_else(|| json!(tenant)), + "project": response + .get("project") + .cloned() + .unwrap_or_else(|| json!(project)), + "user": user, + "scope": response + .get("scope") + .cloned() + .unwrap_or_else(|| json!("node:attach")), + "ttl_seconds": ttl_seconds, + "expires_at_epoch_seconds": response + .get("expires_at_epoch_seconds") + .cloned() + .unwrap_or(Value::Null), + "short_lived": true, + "exchange_for_persistent_node_identity": true, + "node_credentials_separate_from_user_session": true, + }) +} + fn node_list_report(args: NodeListArgs) -> Result { node_descriptors_report("node list", args.scope, None) } @@ -7534,6 +7600,65 @@ mod tests { assert_eq!(report["diagnostics"][0]["code"], "missing_environment"); } + #[test] + fn node_enroll_reports_short_lived_public_api_grant() { + 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":"create_node_enrollment_grant""#)); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""actor_user":"user""#)); + assert!(line.contains(r#""grant":"grant-live""#)); + assert!(line.contains(r#""ttl_seconds":300"#)); + stream + .write_all( + br#"{"type":"node_enrollment_grant_created","tenant":"tenant","project":"project","grant":"grant-live","scope":"node:attach","expires_at_epoch_seconds":300}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let report = node_enroll_report(NodeEnrollArgs { + scope: CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + grant: "grant-live".to_owned(), + ttl_seconds: 300, + }) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["command"], "node enroll"); + assert_eq!(report["status"], "created"); + assert_eq!(report["private_website_required"], false); + assert_eq!(report["tenant"], "tenant"); + assert_eq!(report["project"], "project"); + assert_eq!(report["user"], "user"); + assert_eq!(report["enrollment_grant"]["grant"], "grant-live"); + assert_eq!(report["enrollment_grant"]["scope"], "node:attach"); + assert_eq!(report["enrollment_grant"]["ttl_seconds"], 300); + assert_eq!(report["enrollment_grant"]["expires_at_epoch_seconds"], 300); + assert_eq!(report["enrollment_grant"]["short_lived"], true); + assert_eq!( + report["enrollment_grant"]["exchange_for_persistent_node_identity"], + true + ); + assert_eq!( + report["enrollment_grant"]["node_credentials_separate_from_user_session"], + true + ); + assert_eq!(report["coordinator_session_requests"], 1); + } + #[test] fn node_enroll_and_process_commands_have_safe_plan_without_coordinator() { let scope = CliScopeArgs { @@ -7550,7 +7675,15 @@ mod tests { }) .unwrap(); assert_eq!(enroll["status"], "planned_without_coordinator"); - assert_eq!(enroll["scope"], "node:attach"); + assert_eq!(enroll["private_website_required"], false); + assert_eq!(enroll["enrollment_grant"]["grant"], "grant"); + assert_eq!(enroll["enrollment_grant"]["scope"], "node:attach"); + assert_eq!(enroll["enrollment_grant"]["ttl_seconds"], 60); + assert_eq!(enroll["enrollment_grant"]["short_lived"], true); + assert_eq!( + enroll["enrollment_grant"]["exchange_for_persistent_node_identity"], + true + ); let cancel = process_cancel_report(ProcessCancelArgs { scope, diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index bb4bfc3..9f4acfa 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -199,6 +199,7 @@ for (const [name, pattern] of [ ["CLI top-level logout alias coverage", /fn top_level_logout_alias_removes_only_cli_session_state\(\)/], ["CLI run rejection category coverage", /fn run_rejection_reports_machine_readable_error_category\(\)/], ["node attach grant disclosure coverage", /fn node_attach_discloses_dangerous_capability_grants\(\)/], + ["node enroll public API grant coverage", /fn node_enroll_reports_short_lived_public_api_grant\(\)/], ["quota local status coverage", /fn quota_status_uses_project_config_and_generic_public_limits\(\)/], ["quota coordinator usage coverage", /fn quota_status_queries_public_coordinator_usage\(\)/], ["task event summary coverage", /fn process_task_log_and_artifact_reports_summarize_task_events\(\)/], @@ -305,6 +306,11 @@ expect( "CLI exposes node attach grant disclosures", /struct CapabilityGrantDisclosure[\s\S]*coordinator_policy_limited[\s\S]*fn capability_grant_disclosures/ ); +expect( + cli, + "CLI exposes normalized node enrollment grants", + /fn node_enroll_report[\s\S]*create_node_enrollment_grant[\s\S]*enrollment_grant[\s\S]*private_website_required[\s\S]*fn node_enrollment_grant_summary[\s\S]*short_lived[\s\S]*node_credentials_separate_from_user_session/ +); expect( cli, "CLI exposes task placement reasons",