288 lines
7.9 KiB
Rust
288 lines
7.9 KiB
Rust
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<WorkspacePath>,
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Value>, 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<WorkspaceMembershipPath>,
|
|
State(state): State<AppState>,
|
|
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(
|
|
&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<WorkspaceMembershipPath>,
|
|
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(
|
|
&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<WorkspacePath>,
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Value>, 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<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(&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(&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<WorkspacePath>,
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Value>, 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<WorkspacePath>,
|
|
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(&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}")))
|
|
}
|