feat: connect workspace access lifecycle to admin api

This commit is contained in:
a.tolmachev
2026-03-31 09:12:42 +03:00
parent 3065b3100b
commit cb23f1eb96
12 changed files with 551 additions and 31 deletions
+174
View File
@@ -219,6 +219,11 @@ pub struct InvitationPayload {
pub expires_at: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UpdateMembershipPayload {
pub role: MembershipRole,
}
#[derive(Clone, Debug, Serialize)]
pub struct CreatedInvitationResponse {
pub invitation: InvitationRecord,
@@ -237,6 +242,17 @@ pub struct CreatedPlatformApiKeyResponse {
pub secret: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkspaceExportResponse {
pub workspace: WorkspaceRecord,
pub memberships: Vec<MembershipRecord>,
pub invitations: Vec<Value>,
pub operations: Vec<OperationSummaryView>,
pub agents: Vec<AgentSummaryView>,
pub platform_api_keys: Vec<PlatformApiKeyRecord>,
pub exported_at: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct LogsQuery {
pub level: Option<InvocationLevel>,
@@ -746,6 +762,107 @@ impl AdminService {
Ok(self.registry.list_memberships(workspace_id).await?)
}
pub async fn update_membership_role(
&self,
workspace_id: &WorkspaceId,
actor_user_id: &crank_core::UserId,
target_user_id: &crank_core::UserId,
payload: UpdateMembershipPayload,
) -> Result<Vec<MembershipRecord>, ApiError> {
let memberships = self.list_memberships(workspace_id).await?;
let actor_membership = memberships
.iter()
.find(|membership| &membership.user.id == actor_user_id)
.ok_or_else(|| ApiError::forbidden("workspace access denied"))?;
let target_membership = memberships
.iter()
.find(|membership| &membership.user.id == target_user_id)
.ok_or_else(|| {
ApiError::not_found(format!(
"membership for user {} in workspace {} was not found",
target_user_id.as_str(),
workspace_id.as_str()
))
})?;
if !matches!(
actor_membership.role,
MembershipRole::Owner | MembershipRole::Admin
) {
return Err(ApiError::forbidden(
"only owners and admins can manage workspace members",
));
}
if matches!(target_membership.role, MembershipRole::Owner)
&& !matches!(payload.role, MembershipRole::Owner)
{
let owner_count = memberships
.iter()
.filter(|membership| matches!(membership.role, MembershipRole::Owner))
.count();
if owner_count <= 1 {
return Err(ApiError::validation(
"workspace must keep at least one owner",
));
}
}
self.registry
.update_membership_role(workspace_id, target_user_id, payload.role)
.await?;
self.list_memberships(workspace_id).await
}
pub async fn remove_membership(
&self,
workspace_id: &WorkspaceId,
actor_user_id: &crank_core::UserId,
target_user_id: &crank_core::UserId,
) -> Result<(), ApiError> {
let memberships = self.list_memberships(workspace_id).await?;
let actor_membership = memberships
.iter()
.find(|membership| &membership.user.id == actor_user_id)
.ok_or_else(|| ApiError::forbidden("workspace access denied"))?;
let target_membership = memberships
.iter()
.find(|membership| &membership.user.id == target_user_id)
.ok_or_else(|| {
ApiError::not_found(format!(
"membership for user {} in workspace {} was not found",
target_user_id.as_str(),
workspace_id.as_str()
))
})?;
if !matches!(
actor_membership.role,
MembershipRole::Owner | MembershipRole::Admin
) {
return Err(ApiError::forbidden(
"only owners and admins can manage workspace members",
));
}
if matches!(target_membership.role, MembershipRole::Owner) {
let owner_count = memberships
.iter()
.filter(|membership| matches!(membership.role, MembershipRole::Owner))
.count();
if owner_count <= 1 {
return Err(ApiError::validation(
"workspace must keep at least one owner",
));
}
}
self.registry
.delete_membership(workspace_id, target_user_id)
.await?;
Ok(())
}
#[instrument(skip(self))]
pub async fn list_invitations(
&self,
@@ -804,6 +921,63 @@ impl AdminService {
Ok(())
}
pub async fn export_workspace(
&self,
workspace_id: &WorkspaceId,
) -> Result<WorkspaceExportResponse, ApiError> {
let workspace = self.get_workspace(workspace_id).await?;
let memberships = self.list_memberships(workspace_id).await?;
let invitations = self
.list_invitations(workspace_id)
.await?
.into_iter()
.map(|record| {
json!({
"id": record.invitation.id,
"email": record.invitation.email,
"role": record.invitation.role,
"status": record.invitation.status,
"expires_at": record.invitation.expires_at,
"created_at": record.invitation.created_at,
})
})
.collect();
let operations = self.list_operations(workspace_id).await?;
let agents = self.list_agents(workspace_id).await?;
let platform_api_keys = self.list_platform_api_keys(workspace_id).await?;
Ok(WorkspaceExportResponse {
workspace,
memberships,
invitations,
operations,
agents,
platform_api_keys,
exported_at: now_string()?,
})
}
pub async fn delete_workspace(
&self,
workspace_id: &WorkspaceId,
actor_user_id: &crank_core::UserId,
) -> Result<(), ApiError> {
let memberships = self.list_memberships(workspace_id).await?;
let actor_membership = memberships
.iter()
.find(|membership| &membership.user.id == actor_user_id)
.ok_or_else(|| ApiError::forbidden("workspace access denied"))?;
if !matches!(actor_membership.role, MembershipRole::Owner) {
return Err(ApiError::forbidden(
"only workspace owners can delete a workspace",
));
}
self.registry.delete_workspace(workspace_id).await?;
Ok(())
}
#[instrument(skip(self))]
pub async fn list_platform_api_keys(
&self,