diff --git a/TASKS.md b/TASKS.md index eb6550d..c678c16 100644 --- a/TASKS.md +++ b/TASKS.md @@ -156,4 +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 - backward-compatible alias `CommunityMachineCredentialVerifier` сохранен, поведение Community не изменено diff --git a/apps/admin-api/src/routes/access.rs b/apps/admin-api/src/routes/access.rs index 87ef0b5..51a74f1 100644 --- a/apps/admin-api/src/routes/access.rs +++ b/apps/admin-api/src/routes/access.rs @@ -3,8 +3,14 @@ use axum::{ extract::{Path, State}, http::StatusCode, }; +use crank_core::{ + AuditActor, AuditEvent, AuditEventId, AuditTarget, AuditTargetKind, PolicyAction, + PolicyDecision, PolicyScope, SessionActor, +}; use serde::Deserialize; use serde_json::{Value, json}; +use time::OffsetDateTime; +use uuid::Uuid; use crate::{ auth::AuthenticatedSession, @@ -47,15 +53,32 @@ pub async fn update_membership( Extension(session): Extension, Json(payload): Json, ) -> Result, ApiError> { + let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); + enforce_workspace_policy( + &state, + &session, + &workspace_id, + PolicyAction::WriteWorkspaceAccess, + )?; let items = state .service .update_membership_role( - &path.workspace_id.as_str().into(), + &workspace_id, &session.user.id, &path.user_id.as_str().into(), payload, ) .await?; + record_access_audit( + &state, + &session, + &workspace_id, + "membership.role_updated", + AuditTargetKind::Membership, + path.user_id.clone(), + json!({ "user_id": path.user_id }), + ) + .await?; Ok(Json(json!({ "items": items }))) } @@ -64,14 +87,31 @@ pub async fn delete_membership( State(state): State, Extension(session): Extension, ) -> Result { + let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); + enforce_workspace_policy( + &state, + &session, + &workspace_id, + PolicyAction::WriteWorkspaceAccess, + )?; state .service .remove_membership( - &path.workspace_id.as_str().into(), + &workspace_id, &session.user.id, &path.user_id.as_str().into(), ) .await?; + record_access_audit( + &state, + &session, + &workspace_id, + "membership.removed", + AuditTargetKind::Membership, + path.user_id.clone(), + json!({ "user_id": path.user_id }), + ) + .await?; Ok(StatusCode::NO_CONTENT) } @@ -89,26 +129,59 @@ pub async fn list_invitations( pub async fn create_invitation( Path(path): Path, State(state): State, + Extension(session): Extension, Json(payload): Json, ) -> Result, ApiError> { + let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); + enforce_workspace_policy( + &state, + &session, + &workspace_id, + PolicyAction::WriteWorkspaceAccess, + )?; let created = state .service - .create_invitation(&path.workspace_id.as_str().into(), payload) + .create_invitation(&workspace_id, payload) .await?; + record_access_audit( + &state, + &session, + &workspace_id, + "invitation.created", + AuditTargetKind::Invitation, + created.invitation.invitation.id.as_str().to_owned(), + json!({ "invitation_id": created.invitation.invitation.id.as_str() }), + ) + .await?; Ok(Json(json!(created))) } pub async fn delete_invitation( Path(path): Path, State(state): State, + Extension(session): Extension, ) -> Result { + let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); + enforce_workspace_policy( + &state, + &session, + &workspace_id, + PolicyAction::WriteWorkspaceAccess, + )?; state .service - .delete_invitation( - &path.workspace_id.as_str().into(), - &path.invitation_id.as_str().into(), - ) + .delete_invitation(&workspace_id, &path.invitation_id.as_str().into()) .await?; + record_access_audit( + &state, + &session, + &workspace_id, + "invitation.deleted", + AuditTargetKind::Invitation, + path.invitation_id.clone(), + json!({ "invitation_id": path.invitation_id }), + ) + .await?; Ok(StatusCode::NO_CONTENT) } @@ -128,9 +201,87 @@ pub async fn delete_workspace( State(state): State, Extension(session): Extension, ) -> Result { + let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); + enforce_workspace_policy( + &state, + &session, + &workspace_id, + PolicyAction::WriteWorkspace, + )?; state .service - .delete_workspace(&path.workspace_id.as_str().into(), &session.user.id) + .delete_workspace(&workspace_id, &session.user.id) .await?; + record_access_audit( + &state, + &session, + &workspace_id, + "workspace.deleted", + AuditTargetKind::Workspace, + workspace_id.as_str().to_owned(), + json!({ "workspace_id": workspace_id.as_str() }), + ) + .await?; Ok(StatusCode::NO_CONTENT) } + +fn enforce_workspace_policy( + state: &AppState, + session: &AuthenticatedSession, + workspace_id: &crank_core::WorkspaceId, + action: PolicyAction, +) -> Result<(), ApiError> { + let membership = session + .memberships + .iter() + .find(|membership| membership.workspace.id == *workspace_id) + .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; + let actor = SessionActor { + user_id: session.user.id.clone(), + workspace_id: workspace_id.clone(), + role: membership.role, + }; + + match state.service.policy_engine().check( + &actor, + action, + PolicyScope::Workspace(workspace_id.clone()), + ) { + PolicyDecision::Allow => Ok(()), + PolicyDecision::Deny { reason } => Err(ApiError::forbidden(reason)), + } +} + +async fn record_access_audit( + state: &AppState, + session: &AuthenticatedSession, + workspace_id: &crank_core::WorkspaceId, + action: &str, + target_kind: AuditTargetKind, + target_id: String, + payload: Value, +) -> Result<(), ApiError> { + state + .service + .audit_sink() + .record(AuditEvent { + id: AuditEventId::new(format!("audit_{}", Uuid::now_v7().simple())), + occurred_at: OffsetDateTime::now_utc(), + actor: AuditActor { + user_id: session.user.id.clone(), + email: session.user.email.clone(), + session_id: Some(session.session_id.clone()), + }, + action: action.to_owned(), + target: AuditTarget { + workspace_id: workspace_id.clone(), + kind: target_kind, + id: target_id, + }, + payload, + source_ip: None, + user_agent: None, + }) + .await + .map_err(|error| ApiError::internal(format!("failed to record audit event: {error}"))) +} diff --git a/apps/admin-api/src/routes/capabilities.rs b/apps/admin-api/src/routes/capabilities.rs index 656f75c..aa97bbb 100644 --- a/apps/admin-api/src/routes/capabilities.rs +++ b/apps/admin-api/src/routes/capabilities.rs @@ -4,5 +4,5 @@ use serde_json::json; use crate::state::AppState; pub async fn get_capabilities(State(state): State) -> Json { - Json(json!(state.service.get_capabilities().await)) + Json(json!(state.service.capability_profile().capabilities())) }