admin: delegate capabilities and access routes
This commit is contained in:
@@ -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.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.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.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 не изменено
|
- backward-compatible alias `CommunityMachineCredentialVerifier` сохранен, поведение Community не изменено
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use crank_core::{
|
||||||
|
AuditActor, AuditEvent, AuditEventId, AuditTarget, AuditTargetKind, PolicyAction,
|
||||||
|
PolicyDecision, PolicyScope, SessionActor,
|
||||||
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthenticatedSession,
|
auth::AuthenticatedSession,
|
||||||
@@ -47,15 +53,32 @@ pub async fn update_membership(
|
|||||||
Extension(session): Extension<AuthenticatedSession>,
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
Json(payload): Json<UpdateMembershipPayload>,
|
Json(payload): Json<UpdateMembershipPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> 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
|
let items = state
|
||||||
.service
|
.service
|
||||||
.update_membership_role(
|
.update_membership_role(
|
||||||
&path.workspace_id.as_str().into(),
|
&workspace_id,
|
||||||
&session.user.id,
|
&session.user.id,
|
||||||
&path.user_id.as_str().into(),
|
&path.user_id.as_str().into(),
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
.await?;
|
.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 })))
|
Ok(Json(json!({ "items": items })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +87,31 @@ pub async fn delete_membership(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(session): Extension<AuthenticatedSession>,
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> 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
|
state
|
||||||
.service
|
.service
|
||||||
.remove_membership(
|
.remove_membership(
|
||||||
&path.workspace_id.as_str().into(),
|
&workspace_id,
|
||||||
&session.user.id,
|
&session.user.id,
|
||||||
&path.user_id.as_str().into(),
|
&path.user_id.as_str().into(),
|
||||||
)
|
)
|
||||||
.await?;
|
.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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,26 +129,59 @@ pub async fn list_invitations(
|
|||||||
pub async fn create_invitation(
|
pub async fn create_invitation(
|
||||||
Path(path): Path<WorkspacePath>,
|
Path(path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
Json(payload): Json<InvitationPayload>,
|
Json(payload): Json<InvitationPayload>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> 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
|
let created = state
|
||||||
.service
|
.service
|
||||||
.create_invitation(&path.workspace_id.as_str().into(), payload)
|
.create_invitation(&workspace_id, payload)
|
||||||
.await?;
|
.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)))
|
Ok(Json(json!(created)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_invitation(
|
pub async fn delete_invitation(
|
||||||
Path(path): Path<WorkspaceInvitationPath>,
|
Path(path): Path<WorkspaceInvitationPath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> 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
|
state
|
||||||
.service
|
.service
|
||||||
.delete_invitation(
|
.delete_invitation(&workspace_id, &path.invitation_id.as_str().into())
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.invitation_id.as_str().into(),
|
|
||||||
)
|
|
||||||
.await?;
|
.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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +201,87 @@ pub async fn delete_workspace(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(session): Extension<AuthenticatedSession>,
|
Extension(session): Extension<AuthenticatedSession>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> 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
|
state
|
||||||
.service
|
.service
|
||||||
.delete_workspace(&path.workspace_id.as_str().into(), &session.user.id)
|
.delete_workspace(&workspace_id, &session.user.id)
|
||||||
.await?;
|
.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)
|
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}")))
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ use serde_json::json;
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub async fn get_capabilities(State(state): State<AppState>) -> Json<serde_json::Value> {
|
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()))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user