diff --git a/TASKS.md b/TASKS.md index c678c16..db20ad8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -156,5 +156,5 @@ Progress: - Phase 0 / task `0.12`: `apps/admin-api` и `apps/mcp-server` переведены на `community_default().with_limits(...).with_response_cache(...).build()` вместо прямой сборки runtime через `RuntimeExecutor::with_limits(...)` - Phase 0 / task `0.13`: введены `RegistryExtension`, `ExtensionMigration` и `apply_extension_migrations(...)` в `crank-registry` как отдельный public seam для additive private migrations - Phase 0 / task `0.14`: добавлены `AdminServiceBuilder`, seam slots (`identity_provider`, `policy_engine`, `audit_sink`, `token_issuer`, `capability_profile`) и community defaults для них; `apps/admin-api/src/main.rs` переведен на builder - - Phase 0 / task `0.15`: начат route delegation pass — `capabilities` route идет через `capability_profile`, а write handlers в `routes/access.rs` проверяют `policy_engine` и пишут generic audit events через `audit_sink`; `machine_auth` часть остается pending до actor-aware auth wiring + - Phase 0 / task `0.15`: route delegation переведен на public seams — `capabilities` route идет через `capability_profile`, write handlers в `routes/access.rs` проверяют `policy_engine` и пишут generic audit events через `audit_sink`, а `machine_auth` route использует `token_issuer` seam и сохраняет текущий Community `403` contract - backward-compatible alias `CommunityMachineCredentialVerifier` сохранен, поведение Community не изменено diff --git a/apps/admin-api/src/routes/machine_auth.rs b/apps/admin-api/src/routes/machine_auth.rs index ede6bea..bd7457a 100644 --- a/apps/admin-api/src/routes/machine_auth.rs +++ b/apps/admin-api/src/routes/machine_auth.rs @@ -1,5 +1,9 @@ use axum::{Json, extract::State}; -use crank_core::{IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest}; +use crank_core::{ + IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MachineAccessMode, MembershipRole, + TokenIssuerActor, TokenIssuerError, UserId, WorkspaceId, +}; +use serde_json::json; use crate::{error::ApiError, state::AppState}; @@ -7,7 +11,22 @@ pub async fn issue_agent_token( State(state): State, Json(payload): Json, ) -> Result, ApiError> { - let response = state.service.issue_agent_token(payload).await?; + let edition = state.service.capability_profile().capabilities().edition; + let grant_type = payload.grant_type.clone(); + let response = state + .service + .token_issuer() + .issue_short_lived(payload, &community_token_issuer_actor()) + .await + .map_err(|error| map_token_issuer_error( + error, + edition, + MachineAccessMode::ShortLivedToken, + json!({ + "grant_type": grant_type, + "upgrade_required": true, + }), + ))?; Ok(Json(serde_json::json!(response))) } @@ -15,6 +34,95 @@ pub async fn issue_one_time_agent_token( State(state): State, Json(payload): Json, ) -> Result, ApiError> { - let response = state.service.issue_one_time_agent_token(payload).await?; + let edition = state.service.capability_profile().capabilities().edition; + let operation_id = payload.operation_id.as_str().to_owned(); + let response = state + .service + .token_issuer() + .issue_one_time(payload, &community_token_issuer_actor()) + .await + .map_err(|error| map_token_issuer_error( + error, + edition, + MachineAccessMode::OneTimeToken, + json!({ + "operation_id": operation_id, + "upgrade_required": true, + }), + ))?; Ok(Json(serde_json::json!(response))) } + +fn community_token_issuer_actor() -> TokenIssuerActor { + TokenIssuerActor { + user_id: UserId::new("user_community_public"), + workspace_id: WorkspaceId::new("ws_community_public"), + role: MembershipRole::Viewer, + } +} + +fn map_token_issuer_error( + error: TokenIssuerError, + edition: crank_core::ProductEdition, + machine_access_mode: MachineAccessMode, + extra_context: serde_json::Value, +) -> ApiError { + match error { + TokenIssuerError::NotSupportedInEdition => ApiError::forbidden_with_context( + match machine_access_mode { + MachineAccessMode::ShortLivedToken => { + "short-lived machine access is not available in Community" + } + MachineAccessMode::OneTimeToken => { + "one-time machine access is not available in Community" + } + MachineAccessMode::StaticAgentKey => { + "static agent key machine access is not available for token issue" + } + }, + merge_machine_auth_context( + edition, + machine_access_mode, + extra_context, + ), + ), + TokenIssuerError::InvalidGrant(reason) => ApiError::validation_with_context( + "invalid machine token grant", + json!({ "reason": reason }), + ), + TokenIssuerError::AgentKeyUnknown => { + ApiError::validation("agent key is unknown") + } + TokenIssuerError::OperationNotStrict => { + ApiError::validation("operation is not strict") + } + TokenIssuerError::OperationNotPublishedForAgent => { + ApiError::validation("operation is not published for agent") + } + TokenIssuerError::RegistryFailure(details) => { + ApiError::internal(format!("token issuer registry failure: {details}")) + } + TokenIssuerError::ReplayGuardFailure(details) => { + ApiError::internal(format!("token issuer replay guard failure: {details}")) + } + } +} + +fn merge_machine_auth_context( + edition: crank_core::ProductEdition, + machine_access_mode: MachineAccessMode, + extra_context: serde_json::Value, +) -> serde_json::Value { + let mut context = json!({ + "edition": edition, + "machine_access_mode": machine_access_mode, + }); + + if let (Some(base), Some(extra)) = (context.as_object_mut(), extra_context.as_object()) { + for (key, value) in extra { + base.insert(key.clone(), value.clone()); + } + } + + context +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index a4878c9..7ee1266 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -1614,42 +1614,6 @@ impl AdminService { self.capability_profile().capabilities() } - pub async fn issue_agent_token( - &self, - payload: IssueAgentTokenRequest, - ) -> Result { - let edition = self.get_capabilities().await.edition; - let machine_access_mode = MachineAccessMode::ShortLivedToken; - - Err(ApiError::forbidden_with_context( - "short-lived machine access is not available in Community", - json!({ - "edition": edition, - "machine_access_mode": machine_access_mode, - "grant_type": payload.grant_type, - "upgrade_required": true, - }), - )) - } - - pub async fn issue_one_time_agent_token( - &self, - payload: IssueOneTimeAgentTokenRequest, - ) -> Result { - let edition = self.get_capabilities().await.edition; - let machine_access_mode = MachineAccessMode::OneTimeToken; - - Err(ApiError::forbidden_with_context( - "one-time machine access is not available in Community", - json!({ - "edition": edition, - "machine_access_mode": machine_access_mode, - "operation_id": payload.operation_id, - "upgrade_required": true, - }), - )) - } - pub async fn list_operations( &self, workspace_id: &WorkspaceId,