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
+94 -3
View File
@@ -7,8 +7,9 @@ use crate::{
auth::{require_session, require_workspace_session},
routes::{
access::{
create_invitation, create_platform_api_key, delete_invitation, delete_platform_api_key,
list_invitations, list_memberships, list_platform_api_keys, revoke_platform_api_key,
create_invitation, create_platform_api_key, delete_invitation, delete_membership,
delete_platform_api_key, delete_workspace, export_workspace, list_invitations,
list_memberships, list_platform_api_keys, revoke_platform_api_key, update_membership,
},
agents::{
create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent,
@@ -94,11 +95,16 @@ pub fn build_app(state: AppState) -> Router {
)
.route("/auth-profiles/{auth_profile_id}", get(get_auth_profile))
.route("/members", get(list_memberships))
.route(
"/members/{user_id}",
axum::routing::patch(update_membership).delete(delete_membership),
)
.route(
"/invitations",
get(list_invitations).post(create_invitation),
)
.route("/invitations/{invitation_id}", delete(delete_invitation))
.route("/export", get(export_workspace))
.route(
"/platform-api-keys",
get(list_platform_api_keys).post(create_platform_api_key),
@@ -127,7 +133,9 @@ pub fn build_app(state: AppState) -> Router {
let workspace_scoped_router = Router::new()
.route(
"/workspaces/{workspace_id}",
get(get_workspace).patch(update_workspace),
get(get_workspace)
.patch(update_workspace)
.delete(delete_workspace),
)
.nest("/workspaces/{workspace_id}", workspace_router)
.layer(middleware::from_fn_with_state(
@@ -765,6 +773,89 @@ mod tests {
assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_workspace_access_lifecycle() {
let registry = test_registry().await;
let storage_root = test_storage_root("workspace_access");
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
let client = authorized_client(&base_url).await;
let second_user_id = registry
.upsert_bootstrap_user("operator-2@crank.local", "Operator Two", "external-hash")
.await
.unwrap();
registry
.ensure_membership(
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
&second_user_id,
MembershipRole::Viewer,
)
.await
.unwrap();
let updated_members = assert_success_json(
client
.patch(format!("{base_url}/members/{}", second_user_id.as_str()))
.json(&json!({ "role": "admin" }))
.send()
.await
.unwrap(),
)
.await;
let updated_member = updated_members["items"]
.as_array()
.unwrap()
.iter()
.find(|item| item["user"]["id"] == second_user_id.as_str())
.unwrap();
assert_eq!(updated_member["role"], "admin");
let exported = assert_success_json(
client
.get(format!("{base_url}/export"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(
exported["workspace"]["workspace"]["id"],
DEFAULT_WORKSPACE_ID
);
assert_eq!(exported["memberships"].as_array().unwrap().len(), 2);
let delete_member_status = client
.delete(format!("{base_url}/members/{}", second_user_id.as_str()))
.send()
.await
.unwrap()
.status();
assert_eq!(delete_member_status, reqwest::StatusCode::NO_CONTENT);
let delete_workspace_status = client
.delete(base_url.as_ref())
.send()
.await
.unwrap()
.status();
assert_eq!(delete_workspace_status, reqwest::StatusCode::NO_CONTENT);
let root_url = base_url
.as_ref()
.split("/api/admin/workspaces/")
.next()
.unwrap();
let missing_workspace = client
.get(format!(
"{root_url}/api/admin/workspaces/{DEFAULT_WORKSPACE_ID}"
))
.send()
.await
.unwrap();
assert_eq!(missing_workspace.status(), reqwest::StatusCode::FORBIDDEN);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn updates_profile_and_changes_password() {
+6
View File
@@ -124,6 +124,12 @@ impl From<RegistryError> for ApiError {
RegistryError::UserNotFound { user_id } => {
Self::not_found(format!("user {user_id} was not found"))
}
RegistryError::MembershipNotFound {
workspace_id,
user_id,
} => Self::not_found(format!(
"membership for user {user_id} in workspace {workspace_id} was not found"
)),
RegistryError::AgentNotFound { agent_id } => {
Self::not_found(format!("agent {agent_id} was not found"))
}
+66 -2
View File
@@ -1,5 +1,5 @@
use axum::{
Json,
Extension, Json,
extract::{Path, State},
http::StatusCode,
};
@@ -7,8 +7,9 @@ use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
auth::AuthenticatedSession,
error::ApiError,
service::{InvitationPayload, PlatformApiKeyPayload},
service::{InvitationPayload, PlatformApiKeyPayload, UpdateMembershipPayload},
state::AppState,
};
@@ -23,6 +24,12 @@ pub struct WorkspaceInvitationPath {
pub invitation_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceMembershipPath {
pub workspace_id: String,
pub user_id: String,
}
#[derive(Deserialize)]
pub struct WorkspacePlatformApiKeyPath {
pub workspace_id: String,
@@ -40,6 +47,40 @@ pub async fn list_memberships(
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 items = state
.service
.update_membership_role(
&path.workspace_id.as_str().into(),
&session.user.id,
&path.user_id.as_str().into(),
payload,
)
.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> {
state
.service
.remove_membership(
&path.workspace_id.as_str().into(),
&session.user.id,
&path.user_id.as_str().into(),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_invitations(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
@@ -127,3 +168,26 @@ pub async fn delete_platform_api_key(
.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> {
state
.service
.delete_workspace(&path.workspace_id.as_str().into(), &session.user.id)
.await?;
Ok(StatusCode::NO_CONTENT)
}
+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,