admin: delegate capabilities and access routes

This commit is contained in:
github-ops
2026-05-14 19:06:38 +00:00
parent 82deaeec63
commit 75f76a28bf
3 changed files with 161 additions and 9 deletions
+1
View File
@@ -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 не изменено
+159 -8
View File
@@ -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<AuthenticatedSession>,
Json(payload): Json<UpdateMembershipPayload>,
) -> Result<Json<Value>, 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<AppState>,
Extension(session): Extension<AuthenticatedSession>,
) -> Result<StatusCode, ApiError> {
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<WorkspacePath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<InvitationPayload>,
) -> Result<Json<Value>, 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<WorkspaceInvitationPath>,
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
Extension(session): Extension<AuthenticatedSession>,
) -> Result<StatusCode, ApiError> {
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}")))
}
+1 -1
View File
@@ -4,5 +4,5 @@ use serde_json::json;
use crate::state::AppState;
pub async fn get_capabilities(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!(state.service.get_capabilities().await))
Json(json!(state.service.capability_profile().capabilities()))
}