use axum::{ Extension, Json, 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, error::ApiError, service::{InvitationPayload, UpdateMembershipPayload}, state::AppState, }; #[derive(Deserialize)] pub struct WorkspacePath { pub workspace_id: String, } #[derive(Deserialize)] pub struct WorkspaceInvitationPath { pub workspace_id: String, pub invitation_id: String, } #[derive(Deserialize)] pub struct WorkspaceMembershipPath { pub workspace_id: String, pub user_id: String, } pub async fn list_memberships( Path(path): Path, State(state): State, ) -> Result, ApiError> { let items = state .service .list_memberships(&path.workspace_id.as_str().into()) .await?; Ok(Json(json!({ "items": items }))) } pub async fn update_membership( 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 items = state .service .update_membership_role( &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 }))) } pub async fn delete_membership( 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 .remove_membership( &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) } pub async fn list_invitations( Path(path): Path, State(state): State, ) -> Result, ApiError> { let items = state .service .list_invitations(&path.workspace_id.as_str().into()) .await?; Ok(Json(json!({ "items": items }))) } 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(&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(&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) } pub async fn export_workspace( Path(path): Path, State(state): State, ) -> Result, ApiError> { let exported = state .service .export_workspace(&path.workspace_id.as_str().into()) .await?; Ok(Json(json!(exported))) } pub async fn delete_workspace( 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::WriteWorkspace, )?; state .service .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}"))) }