Limit community to single-user workspace
Deploy / deploy (push) Successful in 2m33s
CI / Rust Checks (push) Successful in 5m35s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 4m19s

This commit is contained in:
github-ops
2026-06-19 17:41:50 +00:00
parent ba29ac7b94
commit 58cfd86d90
23 changed files with 134 additions and 1696 deletions
+64 -125
View File
@@ -8,20 +8,14 @@ use crate::{
rate_limit::apply_api_rate_limit,
request_context::apply_request_context,
routes::{
access::{
create_invitation, delete_invitation, delete_membership, delete_workspace,
export_workspace, list_invitations, list_memberships, update_membership,
},
access::export_workspace,
agents::{
archive_agent, create_agent, create_agent_platform_api_key, delete_agent,
delete_agent_platform_api_key, get_agent, get_agent_version,
list_agent_platform_api_keys, list_agents, publish_agent,
revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent,
},
auth::{
change_password, get_profile, get_session, login, logout, update_current_workspace,
update_profile,
},
auth::{change_password, get_profile, get_session, login, logout, update_profile},
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
capabilities::get_capabilities,
machine_auth::{issue_agent_token, issue_one_time_agent_token},
@@ -35,7 +29,7 @@ use crate::{
secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret},
streaming::list_protocol_capabilities,
upstreams::{create_upstream, list_upstreams, update_upstream},
workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace},
workspaces::{get_workspace, list_workspaces, update_workspace},
},
state::AppState,
};
@@ -118,16 +112,6 @@ pub fn build_app(state: AppState) -> Router {
get(get_secret).delete(delete_secret),
)
.route("/secrets/{secret_id}/rotate", post(rotate_secret))
.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("/logs", get(list_logs))
.route("/logs/{log_id}", get(get_log))
@@ -138,7 +122,7 @@ pub fn build_app(state: AppState) -> Router {
let workspace_root_router = Router::new()
.route("/capabilities", get(get_capabilities))
.route("/workspaces", get(list_workspaces).post(create_workspace))
.route("/workspaces", get(list_workspaces))
.layer(middleware::from_fn_with_state(
state.clone(),
require_session,
@@ -147,9 +131,7 @@ pub fn build_app(state: AppState) -> Router {
let workspace_scoped_router = Router::new()
.route(
"/workspaces/{workspace_id}",
get(get_workspace)
.patch(update_workspace)
.delete(delete_workspace),
get(get_workspace).patch(update_workspace),
)
.nest("/workspaces/{workspace_id}", workspace_router)
.layer(middleware::from_fn_with_state(
@@ -163,7 +145,6 @@ pub fn build_app(state: AppState) -> Router {
.route("/logout", post(logout))
.route("/session", get(get_session))
.route("/profile", get(get_profile).patch(update_profile))
.route("/current-workspace", post(update_current_workspace))
.route("/password", post(change_password))
.layer(middleware::from_fn_with_state(
state.clone(),
@@ -1002,21 +983,19 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_platform_access_resources() {
async fn rejects_workspace_access_management_in_community() {
let registry = test_registry().await;
let storage_root = test_storage_root("platform_access");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let members = client
let members_status = client
.get(format!("{base_url}/members"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let created_invitation = client
.status();
let create_invitation_status = client
.post(format!("{base_url}/invitations"))
.json(&json!({
"email": "operator@example.com",
@@ -1025,44 +1004,10 @@ mod tests {
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let invitation_id = created_invitation["invitation"]["invitation"]["id"]
.as_str()
.unwrap()
.to_owned();
let invitations = client
.get(format!("{base_url}/invitations"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let delete_invitation_status = client
.delete(format!("{base_url}/invitations/{invitation_id}"))
.send()
.await
.unwrap()
.status();
assert_eq!(members["items"][0]["role"], "owner");
assert_eq!(
created_invitation["invitation"]["invitation"]["status"],
"pending"
);
assert!(
created_invitation["invite_token"]
.as_str()
.unwrap()
.starts_with("invite_")
);
assert_eq!(
invitations["items"][0]["invitation"]["email"],
"operator@example.com"
);
assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT);
assert_eq!(members_status, reqwest::StatusCode::NOT_FOUND);
assert_eq!(create_invitation_status, reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test(flavor = "multi_thread")]
@@ -1178,7 +1123,7 @@ mod tests {
);
assert_eq!(response["limits"]["max_workspaces"], 1);
assert_eq!(response["limits"]["max_users_per_workspace"], 1);
assert_eq!(response["limits"]["max_agents_per_workspace"], 1);
assert_eq!(response["limits"]["max_agents_per_workspace"], Value::Null);
}
#[tokio::test(flavor = "multi_thread")]
@@ -1568,7 +1513,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_workspace_access_lifecycle() {
async fn exports_single_workspace_but_rejects_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;
@@ -1587,22 +1532,14 @@ mod tests {
.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()
let update_member_status = client
.patch(format!("{base_url}/members/{}", second_user_id.as_str()))
.json(&json!({ "role": "admin" }))
.send()
.await
.unwrap()
.iter()
.find(|item| item["user"]["id"] == second_user_id.as_str())
.unwrap();
assert_eq!(updated_member["role"], "admin");
.status();
assert_eq!(update_member_status, reqwest::StatusCode::NOT_FOUND);
let exported = assert_success_json(
client
@@ -1616,7 +1553,8 @@ mod tests {
exported["workspace"]["workspace"]["id"],
DEFAULT_WORKSPACE_ID
);
assert_eq!(exported["memberships"].as_array().unwrap().len(), 2);
assert!(exported.get("memberships").is_none());
assert!(exported.get("invitations").is_none());
let delete_member_status = client
.delete(format!("{base_url}/members/{}", second_user_id.as_str()))
@@ -1624,7 +1562,7 @@ mod tests {
.await
.unwrap()
.status();
assert_eq!(delete_member_status, reqwest::StatusCode::NO_CONTENT);
assert_eq!(delete_member_status, reqwest::StatusCode::NOT_FOUND);
let delete_workspace_status = client
.delete(base_url.as_ref())
@@ -1632,21 +1570,27 @@ mod tests {
.await
.unwrap()
.status();
assert_eq!(delete_workspace_status, reqwest::StatusCode::NO_CONTENT);
assert_eq!(
delete_workspace_status,
reqwest::StatusCode::METHOD_NOT_ALLOWED
);
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);
let still_available = assert_success_json(
client
.get(format!(
"{root_url}/api/admin/workspaces/{DEFAULT_WORKSPACE_ID}"
))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(still_available["workspace"]["id"], DEFAULT_WORKSPACE_ID);
}
#[tokio::test]
@@ -1679,11 +1623,7 @@ mod tests {
.iter()
.any(|item| item.workspace.slug == "default")
);
assert!(
workspaces
.iter()
.any(|item| item.workspace.slug == "growth-lab")
);
assert_eq!(workspaces.len(), 1);
let default_workspace_id = WorkspaceId::new(DEFAULT_WORKSPACE_ID);
let operations = service
@@ -1788,7 +1728,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn switches_current_workspace_in_session() {
async fn rejects_multi_workspace_session_switching_in_community() {
let registry = test_registry().await;
let storage_root = test_storage_root("session_workspace");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
@@ -1800,31 +1740,30 @@ mod tests {
.to_owned();
let client = authorized_client(&base_url).await;
let created_workspace = assert_success_json(
client
.post(format!("{root_url}/api/admin/workspaces"))
.json(&json!({
"slug": "growth-lab",
"display_name": "Growth Lab",
"settings": {}
}))
.send()
.await
.unwrap(),
)
.await;
let workspace_id = created_workspace["workspace"]["id"].as_str().unwrap();
let create_workspace_status = client
.post(format!("{root_url}/api/admin/workspaces"))
.json(&json!({
"slug": "growth-lab",
"display_name": "Growth Lab",
"settings": {}
}))
.send()
.await
.unwrap()
.status();
assert_eq!(
create_workspace_status,
reqwest::StatusCode::METHOD_NOT_ALLOWED
);
let switched = assert_success_json(
client
.post(format!("{root_url}/api/auth/current-workspace"))
.json(&json!({ "workspace_id": workspace_id }))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(switched["current_workspace_id"], workspace_id);
let switch_status = client
.post(format!("{root_url}/api/auth/current-workspace"))
.json(&json!({ "workspace_id": DEFAULT_WORKSPACE_ID }))
.send()
.await
.unwrap()
.status();
assert_eq!(switch_status, reqwest::StatusCode::NOT_FOUND);
let session = assert_success_json(
client
@@ -1834,7 +1773,7 @@ mod tests {
.unwrap(),
)
.await;
assert_eq!(session["current_workspace_id"], workspace_id);
assert_eq!(session["current_workspace_id"], DEFAULT_WORKSPACE_ID);
}
#[tokio::test(flavor = "multi_thread")]
+2 -265
View File
@@ -1,190 +1,17 @@
use axum::{
Extension, Json,
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,
};
use crate::{error::ApiError, 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>,
@@ -195,93 +22,3 @@ pub async fn export_workspace(
.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}")))
}
+11 -396
View File
@@ -7,23 +7,21 @@ use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode,
AsyncJobHandle, AuditSink, AuthConfig, AuthKind, AuthProfile, AuthProfileId, CapabilityProfile,
CommunityCapabilityProfile, ConfigExport, EditionCapabilities, ExecutionMode, ExportMode,
GeneratedDraft, GeneratedDraftStatus, IdentityError, IdentityProvider, InvitationId,
InvitationStatus, InvitationToken, InvocationLevel, InvocationLog, InvocationLogId,
InvocationSource, InvocationStatus, JobStatus, LoginOutcome, MachineTokenIssuer,
MembershipRole, NoMachineTokenIssuer, NoopAuditSink, OperationId, OperationSecurityLevel,
OperationStatus, OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, PolicyEngine, ProductEdition, Protocol, ResponseCachePolicy, SampleId,
Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus, Target,
TransportBehavior, UsagePeriod, UserId, UserSessionId, WizardState, Workspace, WorkspaceId,
WorkspaceStatus,
GeneratedDraft, GeneratedDraftStatus, IdentityError, IdentityProvider, InvocationLevel,
InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, JobStatus, LoginOutcome,
MachineTokenIssuer, MembershipRole, NoMachineTokenIssuer, NoopAuditSink, OperationId,
OperationSecurityLevel, OperationStatus, OwnerOnlyPolicyEngine, PlatformApiKey,
PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, PolicyEngine, ProductEdition,
Protocol, ResponseCachePolicy, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus,
StreamSession, StreamStatus, Target, TransportBehavior, UsagePeriod, UserId, UserSessionId,
WizardState, Workspace, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
use crank_registry::{
AgentSummary, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
CreateAsyncJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateStreamSessionRequest,
CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord,
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
CreateAsyncJobRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
CreateSecretRequest, CreateStreamSessionRequest, CreateVersionRequest, CreateWorkspaceRequest,
InvocationLogRecord, ListInvocationLogsQuery, OperationAgentRef, OperationSampleMetadata,
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation,
RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest,
@@ -389,24 +387,6 @@ pub struct AgentMutationResult {
pub updated_at: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct InvitationPayload {
pub email: String,
pub role: MembershipRole,
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,
pub invite_token: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct PlatformApiKeyPayload {
pub name: String,
@@ -422,8 +402,6 @@ pub struct CreatedPlatformApiKeyResponse {
#[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>,
@@ -833,7 +811,6 @@ impl AdminService {
self.seed_default_workspace_demo(&admin_user_id, &default_workspace_id)
.await?;
self.seed_growth_workspace_demo(&admin_user_id).await?;
Ok(())
}
@@ -1178,215 +1155,17 @@ impl AdminService {
Ok(WorkspaceRecord { workspace })
}
#[instrument(skip(self))]
pub async fn list_memberships(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<MembershipRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
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_with_context(
format!(
"membership for user {} in workspace {} was not found",
target_user_id.as_str(),
workspace_id.as_str()
),
json!({
"workspace_id": workspace_id.as_str(),
"user_id": target_user_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_with_context(
format!(
"membership for user {} in workspace {} was not found",
target_user_id.as_str(),
workspace_id.as_str()
),
json!({
"workspace_id": workspace_id.as_str(),
"user_id": target_user_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,
workspace_id: &WorkspaceId,
) -> Result<Vec<InvitationRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
Ok(self.registry.list_invitations(workspace_id).await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), email = %payload.email))]
pub async fn create_invitation(
&self,
workspace_id: &WorkspaceId,
payload: InvitationPayload,
) -> Result<CreatedInvitationResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let invite_token = generate_access_secret("invite");
let invitation = InvitationRecord {
invitation: InvitationToken {
id: InvitationId::new(new_prefixed_id("inv")),
workspace_id: workspace_id.clone(),
email: payload.email,
role: payload.role,
status: InvitationStatus::Pending,
token_hash: hash_access_secret(&invite_token),
expires_at: match payload.expires_at {
Some(expires_at) => parse_timestamp(&expires_at)?,
None => default_invitation_expiry()?,
},
created_at: OffsetDateTime::now_utc(),
},
};
self.registry
.create_invitation(CreateInvitationRequest {
invitation: &invitation.invitation,
})
.await?;
Ok(CreatedInvitationResponse {
invitation,
invite_token,
})
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), invitation_id = %invitation_id.as_str()))]
pub async fn delete_invitation(
&self,
workspace_id: &WorkspaceId,
invitation_id: &InvitationId,
) -> Result<(), ApiError> {
self.registry
.delete_invitation(workspace_id, invitation_id)
.await?;
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.registry.list_platform_api_keys(workspace_id).await?;
Ok(WorkspaceExportResponse {
workspace,
memberships,
invitations,
operations,
agents,
platform_api_keys,
@@ -1394,27 +1173,6 @@ impl AdminService {
})
}
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_agent_platform_api_keys(
&self,
@@ -3570,47 +3328,9 @@ impl AdminService {
owner_user_id: &crank_core::UserId,
workspace_id: &WorkspaceId,
) -> Result<(), ApiError> {
let ops_admin_id = self
.ensure_demo_user("ops-manager@crank.demo", "Ops Manager")
.await?;
let analyst_id = self
.ensure_demo_user("analyst@crank.demo", "Revenue Analyst")
.await?;
let contractor_id = self
.ensure_demo_user("contractor@crank.demo", "Delivery Contractor")
.await?;
self.registry
.ensure_membership(workspace_id, owner_user_id, MembershipRole::Owner)
.await?;
self.registry
.ensure_membership(workspace_id, &ops_admin_id, MembershipRole::Admin)
.await?;
self.registry
.ensure_membership(workspace_id, &analyst_id, MembershipRole::Operator)
.await?;
self.registry
.ensure_membership(workspace_id, &contractor_id, MembershipRole::Viewer)
.await?;
self.ensure_demo_invitation(
workspace_id,
InvitationPayload {
email: "partner@crank.demo".to_owned(),
role: MembershipRole::Viewer,
expires_at: None,
},
)
.await?;
self.ensure_demo_invitation(
workspace_id,
InvitationPayload {
email: "automation@crank.demo".to_owned(),
role: MembershipRole::Operator,
expires_at: None,
},
)
.await?;
let rest_operation = self
.ensure_demo_operation(workspace_id, demo_rest_operation_payload())
@@ -3670,98 +3390,6 @@ impl AdminService {
Ok(())
}
async fn seed_growth_workspace_demo(
&self,
owner_user_id: &crank_core::UserId,
) -> Result<(), ApiError> {
let workspace = self
.ensure_demo_workspace(
owner_user_id,
WorkspacePayload {
slug: "growth-lab".to_owned(),
display_name: "Growth Lab".to_owned(),
settings: json!({
"tier": "demo",
"region": "eu-central",
"notes": "Secondary workspace for workspace switch testing"
}),
},
)
.await?;
let workspace_id = workspace.workspace.id;
let growth_pm_id = self
.ensure_demo_user("growth.pm@crank.demo", "Growth PM")
.await?;
self.registry
.ensure_membership(&workspace_id, owner_user_id, MembershipRole::Owner)
.await?;
self.registry
.ensure_membership(&workspace_id, &growth_pm_id, MembershipRole::Admin)
.await?;
self.ensure_demo_invitation(
&workspace_id,
InvitationPayload {
email: "agency@crank.demo".to_owned(),
role: MembershipRole::Viewer,
expires_at: None,
},
)
.await?;
Ok(())
}
async fn ensure_demo_workspace(
&self,
owner_user_id: &crank_core::UserId,
payload: WorkspacePayload,
) -> Result<WorkspaceRecord, ApiError> {
if let Some(existing) = self
.registry
.list_workspaces_for_user(owner_user_id)
.await?
.into_iter()
.find(|record| record.workspace.slug == payload.slug)
{
return Ok(WorkspaceRecord {
workspace: existing.workspace,
});
}
self.create_workspace(owner_user_id, payload).await
}
async fn ensure_demo_user(
&self,
email: &str,
display_name: &str,
) -> Result<crank_core::UserId, ApiError> {
let password_hash = hash_password(DEMO_USER_PASSWORD, &self.auth_settings.password_pepper)?;
self.registry
.upsert_bootstrap_user(email, display_name, &password_hash)
.await
.map_err(ApiError::from)
}
async fn ensure_demo_invitation(
&self,
workspace_id: &WorkspaceId,
payload: InvitationPayload,
) -> Result<(), ApiError> {
if self
.list_invitations(workspace_id)
.await?
.iter()
.any(|record| record.invitation.email == payload.email)
{
return Ok(());
}
self.create_invitation(workspace_id, payload).await?;
Ok(())
}
async fn ensure_demo_platform_api_key(
&self,
workspace_id: &WorkspaceId,
@@ -4004,8 +3632,6 @@ impl AdminService {
}
}
const DEMO_USER_PASSWORD: &str = "CrankDemoPass123!";
fn build_request_preview(
mapping: &MappingSet,
input: &Value,
@@ -4306,11 +3932,6 @@ fn now_string() -> Result<String, ApiError> {
.map_err(|error| ApiError::internal(error.to_string()))
}
fn parse_timestamp(value: &str) -> Result<OffsetDateTime, ApiError> {
OffsetDateTime::parse(value, &Rfc3339)
.map_err(|_| ApiError::validation("timestamp must be RFC 3339"))
}
fn format_timestamp(timestamp: OffsetDateTime) -> String {
timestamp
.format(&Rfc3339)
@@ -4391,12 +4012,6 @@ async fn resolve_runtime_auth_for_task(
ResolvedAuth::from_profile(&auth_profile, &secrets).map(Some)
}
fn default_invitation_expiry() -> Result<OffsetDateTime, ApiError> {
OffsetDateTime::now_utc()
.checked_add(time::Duration::days(7))
.ok_or_else(|| ApiError::internal("failed to compute invitation expiry"))
}
fn runtime_error_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "schema_error",