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
+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",