diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 3aa0153..5915bd8 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "00a47f2c6f213d43fcef11fdf7926320241da30d", - "release_name": "dryrun-00a47f2c6f21", + "source_commit": "c7543c680764ad3efa8d96e8c427fddcba2d3a4a", + "release_name": "dryrun-c7543c680764", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index eaccb7b..eb802f9 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1441,13 +1441,52 @@ fn exec_dap(args: DapArgs) -> Result<()> { } fn debug_attach_report(args: DebugAttachArgs) -> Result { + let dap = dap_binary_path()?.display().to_string(); + debug_attach_report_with_dap(args, dap) +} + +fn debug_attach_report_with_dap(args: DebugAttachArgs, dap: String) -> 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 process = args.process.clone(); + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "debug_attach", + "tenant": tenant, + "project": project, + "actor_user": user, + "process": process, + }))?; + let authorization = response.get("authorization").cloned().unwrap_or_else( + || json!({"allowed": false, "reason": "missing authorization response"}), + ); + return Ok(json!({ + "command": "debug attach", + "process": process, + "coordinator": coordinator, + "tenant": tenant, + "project": project, + "user": user, + "dap": dap, + "authorized": authorization + .get("allowed") + .cloned() + .unwrap_or_else(|| json!(false)), + "authorization": authorization, + "private_website_required": false, + "coordinator_session_requests": session.requests(), + })); + } Ok(json!({ "command": "debug attach", "process": args.process, "coordinator": args.scope.coordinator, "tenant": args.scope.tenant, "project": args.scope.project, - "dap": dap_binary_path()?.display().to_string(), + "dap": dap, + "authorized": "unknown_without_coordinator", "private_website_required": false, })) } @@ -4588,6 +4627,54 @@ mod tests { assert_eq!(suspended["private_website_required"], false); } + #[test] + fn debug_attach_reports_public_authorization() { + 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":"debug_attach""#)); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""actor_user":"user""#)); + assert!(line.contains(r#""process":"vp""#)); + stream + .write_all( + br#"{"type":"debug_attach","process":"vp","actor":"user","authorization":{"allowed":true,"reason":"debug attach authorized for project"}}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let report = debug_attach_report_with_dap( + DebugAttachArgs { + scope: CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + process: "vp".to_owned(), + }, + "/tmp/disasmer-debug-dap-test".to_owned(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["command"], "debug attach"); + assert_eq!(report["process"], "vp"); + assert_eq!(report["authorized"], true); + assert_eq!( + report["authorization"]["reason"], + "debug attach authorized for project" + ); + assert_eq!(report["private_website_required"], false); + } + #[test] fn human_report_is_text_not_json() { let report = json!({ diff --git a/crates/disasmer-coordinator/src/service.rs b/crates/disasmer-coordinator/src/service.rs index f4e177d..bda6934 100644 --- a/crates/disasmer-coordinator/src/service.rs +++ b/crates/disasmer-coordinator/src/service.rs @@ -4,7 +4,7 @@ use std::net::{SocketAddr, TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; use disasmer_core::{ - Actor, AgentId, ArtifactId, ArtifactRegistry, Capability, CapabilityReportError, + Actor, AgentId, ArtifactId, ArtifactRegistry, Authorization, Capability, CapabilityReportError, CredentialKind, DataPlaneObject, DataPlaneScope, DefaultScheduler, Digest, DirectBulkTransferPlan, DownloadLink, DownloadPolicy, EnvironmentRequirements, LimitError, LimitKind, NativeQuicTransport, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId, @@ -221,6 +221,12 @@ pub enum CoordinatorRequest { node: String, task: String, }, + DebugAttach { + tenant: String, + project: String, + actor_user: String, + process: String, + }, PollDebugCommand { tenant: String, project: String, @@ -557,6 +563,11 @@ pub enum CoordinatorResponse { task: TaskId, command: Option, }, + DebugAttach { + process: ProcessId, + actor: UserId, + authorization: Authorization, + }, TaskLogRecorded { process: ProcessId, task: TaskId, @@ -1560,6 +1571,30 @@ impl CoordinatorService { cancel_requested, }) } + CoordinatorRequest::DebugAttach { + tenant, + project, + actor_user, + process, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let process = ProcessId::new(process); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: project.clone(), + actor: Actor::User(actor.clone()), + }; + self.coordinator + .upsert_user(tenant, actor.clone(), CredentialKind::BrowserSession); + let authorization = self.coordinator.authorize_debug_attach(&context, &process); + Ok(CoordinatorResponse::DebugAttach { + process, + actor, + authorization, + }) + } CoordinatorRequest::PollDebugCommand { tenant, project, @@ -3057,6 +3092,74 @@ mod tests { ); } + #[test] + fn service_authorizes_debug_attach_through_public_api() { + let mut service = CoordinatorService::new(7); + service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant".to_owned(), + actor_user: "user".to_owned(), + project: "project".to_owned(), + name: "Demo".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + + let CoordinatorResponse::DebugAttach { + process, + actor, + authorization, + } = service + .handle_request(CoordinatorRequest::DebugAttach { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + }) + .unwrap() + else { + panic!("expected debug attach authorization"); + }; + assert_eq!(process, ProcessId::from("process")); + assert_eq!(actor, UserId::from("user")); + assert!(authorization.allowed); + + let CoordinatorResponse::DebugAttach { authorization, .. } = service + .handle_request(CoordinatorRequest::DebugAttach { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "other-user".to_owned(), + process: "process".to_owned(), + }) + .unwrap() + else { + panic!("expected denied debug attach authorization"); + }; + assert!(!authorization.allowed); + assert!(authorization.reason.contains("explicit project permission")); + + let CoordinatorResponse::DebugAttach { authorization, .. } = service + .handle_request(CoordinatorRequest::DebugAttach { + tenant: "other-tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + }) + .unwrap() + else { + panic!("expected cross-tenant debug attach denial"); + }; + assert!(!authorization.allowed); + assert!(authorization.reason.contains("tenant or project")); + } + #[test] fn service_cancels_whole_process_and_blocks_new_task_launches() { let mut service = CoordinatorService::new(7); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 24b8e6f..c582a9c 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -108,6 +108,7 @@ for (const [name, pattern] of [ ["CLI key lifecycle coverage", /fn key_lifecycle_reports_project_scoped_agent_credentials\(\)/], ["CLI node revoke coverage", /fn node_revoke_reports_scoped_credential_revocation\(\)/], ["CLI admin public API coverage", /fn admin_status_and_suspend_use_public_coordinator_api\(\)/], + ["CLI debug attach coverage", /fn debug_attach_reports_public_authorization\(\)/], ["doctor unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/], ["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/], ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], @@ -149,6 +150,11 @@ expect( "coordinator public admin suspension coverage", /fn service_reports_and_enforces_public_admin_tenant_suspension\(\)/ ); +expect( + coordinator, + "coordinator debug attach coverage", + /fn service_authorizes_debug_attach_through_public_api\(\)/ +); for (const [name, pattern] of [ ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], diff --git a/scripts/user-session-token-boundary-smoke.js b/scripts/user-session-token-boundary-smoke.js index cfab810..b93f66c 100755 --- a/scripts/user-session-token-boundary-smoke.js +++ b/scripts/user-session-token-boundary-smoke.js @@ -63,6 +63,7 @@ for (const variant of [ "CancelTask", "CancelProcess", "PollTaskControl", + "DebugAttach", "TaskCompleted", ]) { assertNoUserSessionCredential(