Limit community to single-user workspace
This commit is contained in:
+11
-396
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user