Limit community to single-user workspace
This commit is contained in:
+64
-125
@@ -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")]
|
||||
|
||||
@@ -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
@@ -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