chore: publish clean community baseline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
use axum::{
|
||||
extract::{OriginalUri, Request, State},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||
use crank_community_auth::{
|
||||
cleared_session_cookie as build_cleared_session_cookie,
|
||||
create_session_cookie as build_session_cookie, hash_password as community_hash_password,
|
||||
session_cookie as build_session_cookie_header,
|
||||
};
|
||||
use crank_core::{User, UserSessionId, WorkspaceId};
|
||||
use crank_registry::WorkspaceMembershipRecord;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
pub use crank_community_auth::{
|
||||
SESSION_COOKIE_NAME, SessionCookie, extract_session_token, hash_session_secret, verify_password,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BootstrapAdminConfig {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthSettings {
|
||||
pub session_secret: String,
|
||||
pub password_pepper: String,
|
||||
pub session_ttl_hours: i64,
|
||||
pub cookie_secure: bool,
|
||||
pub bootstrap_admin: BootstrapAdminConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AuthenticatedSession {
|
||||
pub session_id: UserSessionId,
|
||||
pub user: User,
|
||||
pub memberships: Vec<WorkspaceMembershipRecord>,
|
||||
pub current_workspace_id: Option<WorkspaceId>,
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str, pepper: &str) -> Result<String, ApiError> {
|
||||
community_hash_password(password, pepper)
|
||||
.map_err(|error| ApiError::internal(format!("failed to hash password: {error}")))
|
||||
}
|
||||
|
||||
pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, ApiError> {
|
||||
build_session_cookie(settings.session_ttl_hours)
|
||||
.map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}")))
|
||||
}
|
||||
|
||||
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
|
||||
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours)
|
||||
}
|
||||
|
||||
pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> {
|
||||
build_cleared_session_cookie(settings.cookie_secure)
|
||||
}
|
||||
|
||||
pub async fn require_session(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let session = resolve_authenticated_session(state.clone(), &jar).await?;
|
||||
request.extensions_mut().insert(session);
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
pub async fn require_workspace_session(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
original_uri: OriginalUri,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let session = resolve_authenticated_session(state.clone(), &jar).await?;
|
||||
let workspace_id = workspace_id_from_path(original_uri.path())
|
||||
.ok_or_else(|| ApiError::internal("workspace middleware was applied to an invalid path"))?;
|
||||
|
||||
let has_access = state
|
||||
.service
|
||||
.user_has_workspace_access(&session.user.id, &workspace_id)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(ApiError::forbidden("workspace access denied"));
|
||||
}
|
||||
|
||||
request.extensions_mut().insert(session);
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
async fn resolve_authenticated_session(
|
||||
state: AppState,
|
||||
jar: &CookieJar,
|
||||
) -> Result<AuthenticatedSession, ApiError> {
|
||||
let (session_id, session_value) = extract_session_token(jar)
|
||||
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
|
||||
let session = state
|
||||
.service
|
||||
.get_session(&session_id, &session_value)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
|
||||
|
||||
state.service.touch_session(&session_id).await?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
|
||||
let marker = "/api/admin/workspaces/";
|
||||
let (_, suffix) = path.split_once(marker)?;
|
||||
let workspace_id = suffix.split('/').next()?;
|
||||
if workspace_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(WorkspaceId::new(workspace_id))
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use crank_mapping::MappingError;
|
||||
use crank_registry::RegistryError;
|
||||
use crank_runtime::RuntimeError;
|
||||
use crank_schema::SchemaError;
|
||||
use serde_json::{Value, json};
|
||||
use thiserror::Error;
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::storage::StorageError;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("{message}")]
|
||||
Unauthorized {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Forbidden {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Validation {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
NotFound {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Conflict {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
RateLimited {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Internal {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::Unauthorized {
|
||||
message: message.into(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::Forbidden {
|
||||
message: message.into(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validation(message: impl Into<String>) -> Self {
|
||||
Self::Validation {
|
||||
message: message.into(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::Internal {
|
||||
message: message.into(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn rate_limited_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::RateLimited {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validation_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::Validation {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::NotFound {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn conflict_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::Conflict {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn forbidden_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::Forbidden {
|
||||
message: message.into(),
|
||||
context: Some(context),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
||||
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
|
||||
Self::Validation { .. } => StatusCode::BAD_REQUEST,
|
||||
Self::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
Self::Conflict { .. } => StatusCode::CONFLICT,
|
||||
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unauthorized { .. } => "unauthorized",
|
||||
Self::Forbidden { .. } => "forbidden",
|
||||
Self::Validation { .. } => "validation_error",
|
||||
Self::NotFound { .. } => "not_found",
|
||||
Self::Conflict { .. } => "conflict",
|
||||
Self::RateLimited { .. } => "rate_limited",
|
||||
Self::Internal { .. } => "internal_error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
match &self {
|
||||
Self::Internal { message, .. } => {
|
||||
error!(error_code = self.code(), error_message = %message)
|
||||
}
|
||||
Self::Unauthorized { message, .. }
|
||||
| Self::Forbidden { message, .. }
|
||||
| Self::Validation { message, .. }
|
||||
| Self::NotFound { message, .. }
|
||||
| Self::Conflict { message, .. }
|
||||
| Self::RateLimited { message, .. } => {
|
||||
warn!(error_code = self.code(), error_message = %message)
|
||||
}
|
||||
}
|
||||
|
||||
let mut error = json!({
|
||||
"code": self.code(),
|
||||
"message": self.to_string(),
|
||||
});
|
||||
if let Some(context) = self.context() {
|
||||
error["context"] = context;
|
||||
}
|
||||
|
||||
let body = Json(json!({
|
||||
"error": error
|
||||
}));
|
||||
|
||||
(self.status_code(), body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn context(&self) -> Option<Value> {
|
||||
match self {
|
||||
Self::Unauthorized { context, .. }
|
||||
| Self::Forbidden { context, .. }
|
||||
| Self::Validation { context, .. }
|
||||
| Self::NotFound { context, .. }
|
||||
| Self::Conflict { context, .. }
|
||||
| Self::RateLimited { context, .. }
|
||||
| Self::Internal { context, .. } => context.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RegistryError> for ApiError {
|
||||
fn from(value: RegistryError) -> Self {
|
||||
match value {
|
||||
RegistryError::WorkspaceNotFound { workspace_id } => Self::not_found_with_context(
|
||||
format!("workspace {workspace_id} was not found"),
|
||||
json!({ "workspace_id": workspace_id }),
|
||||
),
|
||||
RegistryError::UserNotFound { user_id } => Self::not_found_with_context(
|
||||
format!("user {user_id} was not found"),
|
||||
json!({ "user_id": user_id }),
|
||||
),
|
||||
RegistryError::MembershipNotFound {
|
||||
workspace_id,
|
||||
user_id,
|
||||
} => Self::not_found_with_context(
|
||||
format!("membership for user {user_id} in workspace {workspace_id} was not found"),
|
||||
json!({
|
||||
"workspace_id": workspace_id,
|
||||
"user_id": user_id,
|
||||
}),
|
||||
),
|
||||
RegistryError::AgentNotFound { agent_id } => Self::not_found_with_context(
|
||||
format!("agent {agent_id} was not found"),
|
||||
json!({ "agent_id": agent_id }),
|
||||
),
|
||||
RegistryError::InvitationNotFound { invitation_id } => Self::not_found_with_context(
|
||||
format!("invitation {invitation_id} was not found"),
|
||||
json!({ "invitation_id": invitation_id }),
|
||||
),
|
||||
RegistryError::PlatformApiKeyNotFound { key_id } => Self::not_found_with_context(
|
||||
format!("platform api key {key_id} was not found"),
|
||||
json!({ "key_id": key_id }),
|
||||
),
|
||||
RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
|
||||
format!("secret {secret_id} was not found"),
|
||||
json!({ "secret_id": secret_id }),
|
||||
),
|
||||
RegistryError::StreamSessionNotFound { session_id } => Self::not_found_with_context(
|
||||
format!("stream session {session_id} was not found"),
|
||||
json!({ "session_id": session_id }),
|
||||
),
|
||||
RegistryError::AsyncJobNotFound { job_id } => Self::not_found_with_context(
|
||||
format!("async job {job_id} was not found"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context(
|
||||
format!("invocation log {log_id} was not found"),
|
||||
json!({ "log_id": log_id }),
|
||||
),
|
||||
RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug,
|
||||
agent_slug,
|
||||
} => Self::not_found_with_context(
|
||||
format!("published agent {workspace_slug}/{agent_slug} was not found"),
|
||||
json!({
|
||||
"workspace_slug": workspace_slug,
|
||||
"agent_slug": agent_slug,
|
||||
}),
|
||||
),
|
||||
RegistryError::OperationNotFound { operation_id } => Self::not_found_with_context(
|
||||
format!("operation {operation_id} was not found"),
|
||||
json!({ "operation_id": operation_id }),
|
||||
),
|
||||
RegistryError::OperationVersionNotFound {
|
||||
operation_id,
|
||||
version,
|
||||
} => Self::not_found_with_context(
|
||||
format!("operation version {version} for {operation_id} was not found"),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"version": version,
|
||||
}),
|
||||
),
|
||||
RegistryError::OperationHasPublishedAgentBindings { operation_id } => {
|
||||
Self::conflict_with_context(
|
||||
format!(
|
||||
"operation {operation_id} cannot be deleted while it is bound to a published agent"
|
||||
),
|
||||
json!({ "operation_id": operation_id }),
|
||||
)
|
||||
}
|
||||
RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context(
|
||||
format!("auth profile {auth_profile_id} was not found"),
|
||||
json!({ "auth_profile_id": auth_profile_id }),
|
||||
),
|
||||
RegistryError::OperationAlreadyExists { operation_id } => Self::conflict_with_context(
|
||||
format!("operation {operation_id} already exists"),
|
||||
json!({ "operation_id": operation_id }),
|
||||
),
|
||||
RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context(
|
||||
format!("workspace with slug {slug} already exists"),
|
||||
json!({ "slug": slug }),
|
||||
),
|
||||
RegistryError::SecretNameAlreadyExists { workspace_id, name } => {
|
||||
Self::conflict_with_context(
|
||||
format!("secret with name {name} already exists in workspace {workspace_id}"),
|
||||
json!({
|
||||
"workspace_id": workspace_id,
|
||||
"name": name,
|
||||
}),
|
||||
)
|
||||
}
|
||||
RegistryError::SecretReferencedByAuthProfile {
|
||||
secret_id,
|
||||
auth_profile_id,
|
||||
} => Self::conflict_with_context(
|
||||
format!("secret {secret_id} is referenced by auth profile {auth_profile_id}"),
|
||||
json!({
|
||||
"secret_id": secret_id,
|
||||
"auth_profile_id": auth_profile_id,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidStreamSessionTransition {
|
||||
session_id,
|
||||
from,
|
||||
to,
|
||||
} => Self::conflict_with_context(
|
||||
format!("invalid stream session transition for {session_id}: {from} -> {to}"),
|
||||
json!({
|
||||
"session_id": session_id,
|
||||
"from": from,
|
||||
"to": to,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidAsyncJobTransition { job_id, from, to } => {
|
||||
Self::conflict_with_context(
|
||||
format!("invalid async job transition for {job_id}: {from} -> {to}"),
|
||||
json!({
|
||||
"job_id": job_id,
|
||||
"from": from,
|
||||
"to": to,
|
||||
}),
|
||||
)
|
||||
}
|
||||
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
|
||||
format!("user with email {email} already exists"),
|
||||
json!({ "email": email }),
|
||||
),
|
||||
RegistryError::InvalidInitialVersion {
|
||||
operation_id,
|
||||
version,
|
||||
} => Self::validation_with_context(
|
||||
format!("operation {operation_id} must start with version 1, got {version}"),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"version": version,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidVersionSequence {
|
||||
operation_id,
|
||||
expected,
|
||||
actual,
|
||||
} => Self::validation_with_context(
|
||||
format!("operation {operation_id} expected next version {expected}, got {actual}"),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"expected": expected,
|
||||
"actual": actual,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidAgentVersionSequence {
|
||||
agent_id,
|
||||
expected,
|
||||
actual,
|
||||
} => Self::validation_with_context(
|
||||
format!("agent {agent_id} expected next version {expected}, got {actual}"),
|
||||
json!({
|
||||
"agent_id": agent_id,
|
||||
"expected": expected,
|
||||
"actual": actual,
|
||||
}),
|
||||
),
|
||||
RegistryError::ImmutableOperationFieldChanged {
|
||||
operation_id,
|
||||
field,
|
||||
} => Self::validation_with_context(
|
||||
format!("operation {operation_id} changed immutable field {field}"),
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"field": field,
|
||||
}),
|
||||
),
|
||||
RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context(
|
||||
format!("unsupported enum representation for field {field}"),
|
||||
json!({ "field": field }),
|
||||
),
|
||||
RegistryError::InvalidNumericValue { field, value } => Self::validation_with_context(
|
||||
format!("invalid numeric value for field {field}: {value}"),
|
||||
json!({
|
||||
"field": field,
|
||||
"value": value,
|
||||
}),
|
||||
),
|
||||
RegistryError::YamlImportJobNotFound { job_id } => Self::validation_with_context(
|
||||
format!("yaml import job {job_id} was not found"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::Storage(_) | RegistryError::Serialization(_) => {
|
||||
Self::internal(value.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MappingError> for ApiError {
|
||||
fn from(value: MappingError) -> Self {
|
||||
Self::validation(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SchemaError> for ApiError {
|
||||
fn from(value: SchemaError) -> Self {
|
||||
Self::validation(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StorageError> for ApiError {
|
||||
fn from(value: StorageError) -> Self {
|
||||
match value {
|
||||
StorageError::InvalidStorageRef { details } => Self::validation_with_context(
|
||||
format!("invalid storage reference: {details}"),
|
||||
json!({ "details": details }),
|
||||
),
|
||||
StorageError::Io(_) | StorageError::Serialization(_) => {
|
||||
Self::internal(value.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
|
||||
let mut payload = json!({
|
||||
"code": runtime_test_failure_code(error),
|
||||
"message": error.to_string()
|
||||
});
|
||||
if let Some(context) = runtime_error_context(error) {
|
||||
payload["context"] = context;
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
RuntimeError::Schema(_) => "runtime_schema_error",
|
||||
RuntimeError::Mapping(_) => "runtime_mapping_error",
|
||||
RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error",
|
||||
RuntimeError::GrpcAdapter(_) => "runtime_grpc_error",
|
||||
RuntimeError::RestAdapter(_) => "runtime_rest_error",
|
||||
RuntimeError::ProtocolAdapter(_) => "runtime_adapter_error",
|
||||
RuntimeError::SoapAdapter(_) => "runtime_soap_error",
|
||||
RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
||||
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
|
||||
RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"runtime_secret_error"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error",
|
||||
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
|
||||
match error {
|
||||
RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({
|
||||
"field": field,
|
||||
"reason": reason,
|
||||
})),
|
||||
RuntimeError::InvalidStreamingPayload { field, reason } => Some(json!({
|
||||
"field": field,
|
||||
"reason": reason,
|
||||
})),
|
||||
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
"reason": reason,
|
||||
})),
|
||||
RuntimeError::SecretCrypto { operation, details } => Some(json!({
|
||||
"operation": operation,
|
||||
"details": details,
|
||||
})),
|
||||
RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({
|
||||
"auth_profile_id": auth_profile_id,
|
||||
})),
|
||||
RuntimeError::MissingSecret { secret_id } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
})),
|
||||
RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
"version": version,
|
||||
})),
|
||||
RuntimeError::MissingStreamingConfig { operation_id } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
})),
|
||||
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
"mode": mode,
|
||||
})),
|
||||
RuntimeError::UnsupportedProtocol { protocol } => Some(json!({
|
||||
"protocol": protocol,
|
||||
})),
|
||||
RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({
|
||||
"kind": kind,
|
||||
"limit": limit,
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{ApiError, runtime_error_context, runtime_test_failure};
|
||||
use crank_registry::RegistryError;
|
||||
use crank_runtime::RuntimeError;
|
||||
|
||||
#[test]
|
||||
fn runtime_test_failure_includes_structured_context() {
|
||||
let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest {
|
||||
field: "request.headers".to_owned(),
|
||||
reason: "must be an object".to_owned(),
|
||||
});
|
||||
|
||||
assert_eq!(payload["code"], "runtime_request_error");
|
||||
assert_eq!(
|
||||
payload["context"],
|
||||
json!({
|
||||
"field": "request.headers",
|
||||
"reason": "must be an object"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_error_context_includes_secret_crypto_operation() {
|
||||
let context = runtime_error_context(&RuntimeError::SecretCrypto {
|
||||
operation: "decode secret envelope",
|
||||
details: "bad base64".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
context,
|
||||
json!({
|
||||
"operation": "decode secret envelope",
|
||||
"details": "bad base64"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_test_failure_includes_runtime_overload_context() {
|
||||
let payload = runtime_test_failure(&RuntimeError::ConcurrencyLimitExceeded {
|
||||
kind: "window",
|
||||
limit: 16,
|
||||
});
|
||||
|
||||
assert_eq!(payload["code"], "runtime_overloaded");
|
||||
assert_eq!(
|
||||
payload["context"],
|
||||
json!({
|
||||
"kind": "window",
|
||||
"limit": 16
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_errors_preserve_structured_context_in_api_error() {
|
||||
let error = ApiError::from(RegistryError::InvalidAsyncJobTransition {
|
||||
job_id: "job_123".to_owned(),
|
||||
from: "running".to_owned(),
|
||||
to: "completed".to_owned(),
|
||||
});
|
||||
|
||||
match error {
|
||||
ApiError::Conflict { context, .. } => {
|
||||
assert_eq!(
|
||||
context,
|
||||
Some(json!({
|
||||
"job_id": "job_123",
|
||||
"from": "running",
|
||||
"to": "completed"
|
||||
}))
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected error variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod rate_limit;
|
||||
pub mod request_context;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
@@ -0,0 +1,156 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
|
||||
use admin_api::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig},
|
||||
service::AdminServiceBuilder,
|
||||
state::AppState,
|
||||
};
|
||||
use crank_community_auth::PasswordIdentityProvider;
|
||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry};
|
||||
use crank_runtime::{
|
||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeCacheConfig, RuntimeCacheStores,
|
||||
RuntimeLimits, SecretCrypto, community_default,
|
||||
};
|
||||
use sqlx::postgres::PgConnectOptions;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
env::var("CRANK_LOG_LEVEL").unwrap_or_else(|_| "admin_api=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let storage_root = PathBuf::from(
|
||||
env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()),
|
||||
);
|
||||
let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into());
|
||||
let base_url = env::var("CRANK_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into());
|
||||
let socket_addr: SocketAddr = bind_addr.parse()?;
|
||||
let pool_config = PostgresPoolConfig::from_env()?;
|
||||
let registry = PostgresRegistry::connect_with_options_and_pool_config(
|
||||
database_options_from_env()?,
|
||||
pool_config,
|
||||
)
|
||||
.await?;
|
||||
let auth_settings = AuthSettings {
|
||||
session_secret: env::var("CRANK_SESSION_SECRET")?,
|
||||
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?,
|
||||
session_ttl_hours: env::var("CRANK_SESSION_TTL_HOURS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(24),
|
||||
cookie_secure: base_url.starts_with("https://"),
|
||||
bootstrap_admin: BootstrapAdminConfig {
|
||||
email: env::var("CRANK_BOOTSTRAP_ADMIN_EMAIL")?,
|
||||
password: env::var("CRANK_BOOTSTRAP_ADMIN_PASSWORD")?,
|
||||
display_name: env::var("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME")
|
||||
.unwrap_or_else(|_| "Crank Owner".into()),
|
||||
},
|
||||
};
|
||||
let runtime_limits = RuntimeLimits::from_env()?;
|
||||
let cache_config = RuntimeCacheConfig::from_env()?;
|
||||
let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?;
|
||||
let api_rate_limit = admin_api_rate_limit_config_from_env()?;
|
||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||
let runtime = community_default()
|
||||
.with_limits(runtime_limits)
|
||||
.with_response_cache(cache_stores.response.clone())
|
||||
.build();
|
||||
let identity_provider =
|
||||
PasswordIdentityProvider::new(registry.clone(), auth_settings.password_pepper.clone());
|
||||
let service = AdminServiceBuilder::new(
|
||||
registry,
|
||||
storage_root,
|
||||
auth_settings,
|
||||
secret_crypto,
|
||||
runtime,
|
||||
)
|
||||
.with_identity_provider(std::sync::Arc::new(identity_provider))
|
||||
.build();
|
||||
service.bootstrap_admin_user().await?;
|
||||
if env_flag("CRANK_DEMO_SEED") {
|
||||
service.seed_demo_assets().await?;
|
||||
}
|
||||
let state = AppState {
|
||||
service,
|
||||
api_rate_limiter: if cache_config.backend.is_external() {
|
||||
RequestRateLimiter::new_shared(api_rate_limit, cache_stores.rate_limit.clone())
|
||||
} else {
|
||||
RequestRateLimiter::new(api_rate_limit)
|
||||
},
|
||||
};
|
||||
let app = build_app(state);
|
||||
let listener = TcpListener::bind(socket_addr).await?;
|
||||
|
||||
info!(
|
||||
runtime_max_concurrent_unary = runtime_limits.max_concurrent_unary,
|
||||
runtime_max_concurrent_window = runtime_limits.max_concurrent_window,
|
||||
runtime_max_concurrent_sessions = runtime_limits.max_concurrent_sessions,
|
||||
runtime_max_concurrent_jobs = runtime_limits.max_concurrent_jobs,
|
||||
admin_rate_limit_rps = api_rate_limit.requests_per_second,
|
||||
admin_rate_limit_burst = api_rate_limit.burst,
|
||||
cache_backend = %cache_config.backend,
|
||||
max_connections = pool_config.max_connections,
|
||||
min_connections = pool_config.min_connections,
|
||||
acquire_timeout_ms = pool_config.acquire_timeout_ms,
|
||||
idle_timeout_ms = pool_config.idle_timeout_ms,
|
||||
max_lifetime_ms = pool_config.max_lifetime_ms,
|
||||
"postgres pool configured"
|
||||
);
|
||||
info!("admin-api listening on {}", socket_addr);
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
matches!(
|
||||
env::var(name)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref(),
|
||||
Some("1" | "true" | "yes" | "on")
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_api_rate_limit_config_from_env()
|
||||
-> Result<RequestRateLimitConfig, Box<dyn std::error::Error>> {
|
||||
let requests_per_second = env::var("CRANK_ADMIN_RATE_LIMIT_RPS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(30);
|
||||
let burst = env::var("CRANK_ADMIN_RATE_LIMIT_BURST")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(60);
|
||||
|
||||
Ok(RequestRateLimitConfig::new(requests_per_second, burst)?)
|
||||
}
|
||||
|
||||
fn database_options_from_env() -> Result<PgConnectOptions, Box<dyn std::error::Error>> {
|
||||
if let Ok(database_url) = env::var("CRANK_DATABASE_URL") {
|
||||
return Ok(database_url.parse::<PgConnectOptions>()?);
|
||||
}
|
||||
|
||||
let host = env::var("POSTGRES_HOST").unwrap_or_else(|_| "postgres".into());
|
||||
let port = env::var("POSTGRES_PORT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(5432);
|
||||
let database = env::var("POSTGRES_DB").unwrap_or_else(|_| "crank".into());
|
||||
let username = env::var("POSTGRES_USER").unwrap_or_else(|_| "crank".into());
|
||||
let password = env::var("POSTGRES_PASSWORD").unwrap_or_else(|_| "crank".into());
|
||||
|
||||
Ok(PgConnectOptions::new()
|
||||
.host(&host)
|
||||
.port(port)
|
||||
.database(&database)
|
||||
.username(&username)
|
||||
.password(&password))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header::{COOKIE, HeaderMap},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use crank_runtime::RateLimitRejection;
|
||||
|
||||
use crate::{auth::SESSION_COOKIE_NAME, error::ApiError, state::AppState};
|
||||
|
||||
pub async fn apply_api_rate_limit(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let key = rate_limit_key(request.headers(), request.uri().path());
|
||||
if let Err(rejection) = state.api_rate_limiter.check(&key).await {
|
||||
return Err(ApiError::rate_limited_with_context(
|
||||
"request rate limit exceeded",
|
||||
rejection_context(rejection),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
fn rejection_context(rejection: RateLimitRejection) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"retry_after_ms": rejection.retry_after_ms,
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_limit_key(headers: &HeaderMap, path: &str) -> String {
|
||||
if let Some(session_id) = session_id_from_headers(headers) {
|
||||
return format!("session:{session_id}");
|
||||
}
|
||||
|
||||
if let Some(forwarded_for) = header_value(headers, "x-forwarded-for") {
|
||||
let ip = forwarded_for
|
||||
.split(',')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("unknown");
|
||||
return format!("ip:{ip}");
|
||||
}
|
||||
|
||||
if let Some(real_ip) = header_value(headers, "x-real-ip") {
|
||||
return format!("ip:{real_ip}");
|
||||
}
|
||||
|
||||
format!("anonymous:{path}")
|
||||
}
|
||||
|
||||
fn session_id_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
let cookies = headers.get(COOKIE)?.to_str().ok()?;
|
||||
for part in cookies.split(';') {
|
||||
let (name, value) = part.trim().split_once('=')?;
|
||||
if name != SESSION_COOKIE_NAME {
|
||||
continue;
|
||||
}
|
||||
let (session_id, _) = value.split_once('.')?;
|
||||
if !session_id.is_empty() {
|
||||
return Some(session_id.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> {
|
||||
headers.get(name)?.to_str().ok().map(str::trim)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::{HeaderMap, HeaderValue, header::COOKIE};
|
||||
|
||||
use super::rate_limit_key;
|
||||
|
||||
#[test]
|
||||
fn keys_by_session_cookie_first() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
COOKIE,
|
||||
HeaderValue::from_static("theme=dark; crank_session=sess_123.secret_456"),
|
||||
);
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.5"));
|
||||
|
||||
assert_eq!(
|
||||
rate_limit_key(&headers, "/api/auth/login"),
|
||||
"session:sess_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_forwarded_ip() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("10.0.0.5, 10.0.0.6"),
|
||||
);
|
||||
|
||||
assert_eq!(rate_limit_key(&headers, "/api/auth/login"), "ip:10.0.0.5");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{HeaderMap, HeaderName, HeaderValue},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
|
||||
const MAX_REQUEST_ID_LEN: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestContext {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
pub async fn apply_request_context(mut request: Request, next: Next) -> Response {
|
||||
let context = RequestContext {
|
||||
request_id: resolve_request_id(request.headers()),
|
||||
};
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_owned();
|
||||
request.extensions_mut().insert(context.clone());
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
info!(
|
||||
request_id = %context.request_id,
|
||||
method = %method,
|
||||
path,
|
||||
status = response.status().as_u16(),
|
||||
"admin request completed"
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&context.request_id) {
|
||||
response.headers_mut().insert(REQUEST_ID_HEADER, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn resolve_request_id(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get(&REQUEST_ID_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| is_valid_request_id(value))
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| Uuid::now_v7().to_string())
|
||||
}
|
||||
|
||||
fn is_valid_request_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_REQUEST_ID_LEN
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use reqwest::Client;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*};
|
||||
|
||||
use super::{REQUEST_ID_HEADER, apply_request_context, is_valid_request_id};
|
||||
|
||||
#[test]
|
||||
fn accepts_visible_ascii_request_ids() {
|
||||
assert!(is_valid_request_id("req_test_123"));
|
||||
assert!(is_valid_request_id("trace-123/abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_or_control_request_ids() {
|
||||
assert!(!is_valid_request_id(""));
|
||||
assert!(!is_valid_request_id("bad value"));
|
||||
assert!(!is_valid_request_id("bad\nvalue"));
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogWriter {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl SharedLogWriter {
|
||||
fn output(&self) -> String {
|
||||
String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for SharedLogWriter {
|
||||
type Writer = SharedLogGuard;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogGuard {
|
||||
buffer: Arc::clone(&self.buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl io::Write for SharedLogGuard {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_request_completion_with_request_id() {
|
||||
let writer = SharedLogWriter::default();
|
||||
let subscriber = tracing_subscriber::registry().with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(writer.clone())
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.with_filter(LevelFilter::INFO),
|
||||
);
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let app = Router::new()
|
||||
.route("/probe", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(apply_request_context));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let response = Client::new()
|
||||
.get(format!("http://{address}/probe"))
|
||||
.header(REQUEST_ID_HEADER.as_str(), "req_admin_trace_123")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[REQUEST_ID_HEADER.as_str()]
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"req_admin_trace_123"
|
||||
);
|
||||
|
||||
let logs = writer.output();
|
||||
assert!(logs.contains("admin request completed"));
|
||||
assert!(logs.contains("req_admin_trace_123"));
|
||||
assert!(logs.contains("GET"));
|
||||
assert!(logs.contains("/probe"));
|
||||
assert!(logs.contains("status=200"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub mod access;
|
||||
pub mod agents;
|
||||
pub mod auth;
|
||||
pub mod auth_profiles;
|
||||
pub mod capabilities;
|
||||
pub mod machine_auth;
|
||||
pub mod observability;
|
||||
pub mod operations;
|
||||
pub mod secrets;
|
||||
pub mod streaming;
|
||||
pub mod workspaces;
|
||||
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
pub async fn health() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"service": "admin-api",
|
||||
"status": "ok"
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use axum::{
|
||||
Extension, 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,
|
||||
};
|
||||
|
||||
#[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>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let exported = state
|
||||
.service
|
||||
.export_workspace(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!(exported)))
|
||||
}
|
||||
|
||||
pub async fn delete_workspace(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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}")))
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||
UpdateAgentPayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceAgentPath {
|
||||
pub workspace_id: String,
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceAgentVersionPath {
|
||||
pub workspace_id: String,
|
||||
pub agent_id: String,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceAgentPlatformApiKeyPath {
|
||||
pub workspace_id: String,
|
||||
pub agent_id: String,
|
||||
pub key_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_agents(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_agents(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_agent(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AgentPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let created = state
|
||||
.service
|
||||
.create_agent(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn get_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let agent = state
|
||||
.service
|
||||
.get_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(agent)))
|
||||
}
|
||||
|
||||
pub async fn update_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateAgentPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let updated = state
|
||||
.service
|
||||
.update_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn delete_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let deleted = state
|
||||
.service
|
||||
.delete_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(deleted)))
|
||||
}
|
||||
|
||||
pub async fn get_agent_version(
|
||||
Path(path): Path<WorkspaceAgentVersionPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let version = state
|
||||
.service
|
||||
.get_agent_version(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
path.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(version)))
|
||||
}
|
||||
|
||||
pub async fn save_agent_bindings(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<Vec<AgentBindingPayload>>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let record = state
|
||||
.service
|
||||
.save_agent_bindings(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(record)))
|
||||
}
|
||||
|
||||
pub async fn publish_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PublishPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let published = state
|
||||
.service
|
||||
.publish_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(published)))
|
||||
}
|
||||
|
||||
pub async fn unpublish_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let updated = state
|
||||
.service
|
||||
.unpublish_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn archive_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let updated = state
|
||||
.service
|
||||
.archive_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn list_agent_platform_api_keys(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_agent_platform_api_keys(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PlatformApiKeyPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let created = state
|
||||
.service
|
||||
.create_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn revoke_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.revoke_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn delete_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.delete_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use axum::{Extension, Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
|
||||
error::ApiError,
|
||||
service::{
|
||||
ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(payload): Json<LoginPayload>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let (session_data, session) = state.service.login(payload).await?;
|
||||
let cookie_value = format!(
|
||||
"{}.{}",
|
||||
session_data.session_id.as_str(),
|
||||
session_data.value
|
||||
);
|
||||
let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value));
|
||||
|
||||
Ok((jar, Json(serde_json::json!(session))))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if let Some((session_id, session_value)) = extract_session_token(&jar) {
|
||||
state.service.logout(&session_id, &session_value).await?;
|
||||
}
|
||||
|
||||
let jar = jar.remove(cleared_session_cookie(state.service.auth_settings()));
|
||||
Ok((jar, StatusCode::NO_CONTENT))
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let (session_id, session_value) = extract_session_token(&jar)
|
||||
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
|
||||
let session = state
|
||||
.service
|
||||
.session_response(&session_id, &session_value)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
|
||||
|
||||
Ok(Json(serde_json::json!(session)))
|
||||
}
|
||||
|
||||
pub async fn get_profile(
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
Ok(Json(json!({
|
||||
"user": session.user,
|
||||
"memberships": session.memberships,
|
||||
"current_workspace_id": session.current_workspace_id
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<UpdateProfilePayload>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let updated = state
|
||||
.service
|
||||
.update_profile(
|
||||
&session.user.id,
|
||||
session.current_workspace_id.as_ref(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn update_current_workspace(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<UpdateCurrentWorkspacePayload>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let updated = state
|
||||
.service
|
||||
.set_current_workspace(
|
||||
&session.session_id,
|
||||
&session.user.id,
|
||||
&payload.workspace_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<ChangePasswordPayload>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.change_password(&session.user.id, payload)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{error::ApiError, service::AuthProfilePayload, state::AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceAuthProfilePath {
|
||||
pub workspace_id: String,
|
||||
pub auth_profile_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_auth_profiles(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_auth_profiles(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_auth_profile(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AuthProfilePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let profile = state
|
||||
.service
|
||||
.create_auth_profile(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(profile)))
|
||||
}
|
||||
|
||||
pub async fn get_auth_profile(
|
||||
Path(path): Path<WorkspaceAuthProfilePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let profile = state
|
||||
.service
|
||||
.get_auth_profile(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.auth_profile_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(profile)))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use axum::{Json, extract::State};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn get_capabilities(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!(state.service.capability_profile().capabilities()))
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use axum::{Json, extract::State};
|
||||
use crank_core::{
|
||||
IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MachineAccessMode, MembershipRole,
|
||||
TokenIssuerActor, TokenIssuerError, UserId, WorkspaceId,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
pub async fn issue_agent_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<IssueAgentTokenRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let edition = state.service.capability_profile().capabilities().edition;
|
||||
let grant_type = payload.grant_type.clone();
|
||||
let response = state
|
||||
.service
|
||||
.token_issuer()
|
||||
.issue_short_lived(payload, &community_token_issuer_actor())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_token_issuer_error(
|
||||
error,
|
||||
edition,
|
||||
MachineAccessMode::ShortLivedToken,
|
||||
json!({
|
||||
"grant_type": grant_type,
|
||||
"upgrade_required": true,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(serde_json::json!(response)))
|
||||
}
|
||||
|
||||
pub async fn issue_one_time_agent_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<IssueOneTimeAgentTokenRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let edition = state.service.capability_profile().capabilities().edition;
|
||||
let operation_id = payload.operation_id.as_str().to_owned();
|
||||
let response = state
|
||||
.service
|
||||
.token_issuer()
|
||||
.issue_one_time(payload, &community_token_issuer_actor())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_token_issuer_error(
|
||||
error,
|
||||
edition,
|
||||
MachineAccessMode::OneTimeToken,
|
||||
json!({
|
||||
"operation_id": operation_id,
|
||||
"upgrade_required": true,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(serde_json::json!(response)))
|
||||
}
|
||||
|
||||
fn community_token_issuer_actor() -> TokenIssuerActor {
|
||||
TokenIssuerActor {
|
||||
user_id: UserId::new("user_community_public"),
|
||||
workspace_id: WorkspaceId::new("ws_community_public"),
|
||||
role: MembershipRole::Viewer,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_token_issuer_error(
|
||||
error: TokenIssuerError,
|
||||
edition: crank_core::ProductEdition,
|
||||
machine_access_mode: MachineAccessMode,
|
||||
extra_context: serde_json::Value,
|
||||
) -> ApiError {
|
||||
match error {
|
||||
TokenIssuerError::NotSupportedInEdition => ApiError::forbidden_with_context(
|
||||
match machine_access_mode {
|
||||
MachineAccessMode::ShortLivedToken => {
|
||||
"short-lived machine access is not available in Community"
|
||||
}
|
||||
MachineAccessMode::OneTimeToken => {
|
||||
"one-time machine access is not available in Community"
|
||||
}
|
||||
MachineAccessMode::StaticAgentKey => {
|
||||
"static agent key machine access is not available for token issue"
|
||||
}
|
||||
},
|
||||
merge_machine_auth_context(edition, machine_access_mode, extra_context),
|
||||
),
|
||||
TokenIssuerError::InvalidGrant(reason) => ApiError::validation_with_context(
|
||||
"invalid machine token grant",
|
||||
json!({ "reason": reason }),
|
||||
),
|
||||
TokenIssuerError::AgentKeyUnknown => ApiError::validation("agent key is unknown"),
|
||||
TokenIssuerError::OperationNotStrict => ApiError::validation("operation is not strict"),
|
||||
TokenIssuerError::OperationNotPublishedForAgent => {
|
||||
ApiError::validation("operation is not published for agent")
|
||||
}
|
||||
TokenIssuerError::RegistryFailure(details) => {
|
||||
ApiError::internal(format!("token issuer registry failure: {details}"))
|
||||
}
|
||||
TokenIssuerError::ReplayGuardFailure(details) => {
|
||||
ApiError::internal(format!("token issuer replay guard failure: {details}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_machine_auth_context(
|
||||
edition: crank_core::ProductEdition,
|
||||
machine_access_mode: MachineAccessMode,
|
||||
extra_context: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
let mut context = json!({
|
||||
"edition": edition,
|
||||
"machine_access_mode": machine_access_mode,
|
||||
});
|
||||
|
||||
if let (Some(base), Some(extra)) = (context.as_object_mut(), extra_context.as_object()) {
|
||||
for (key, value) in extra {
|
||||
base.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
context
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
routes::access::WorkspacePath,
|
||||
service::{LogsQuery, UsageRequestQuery},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WorkspaceLogPath {
|
||||
pub workspace_id: String,
|
||||
pub log_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WorkspaceOperationUsagePath {
|
||||
pub workspace_id: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WorkspaceAgentUsagePath {
|
||||
pub workspace_id: String,
|
||||
pub agent_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_logs(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<LogsQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_logs(&path.workspace_id.as_str().into(), query)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn get_log(
|
||||
Path(path): Path<WorkspaceLogPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let item = state
|
||||
.service
|
||||
.get_log(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.log_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(item)))
|
||||
}
|
||||
|
||||
pub async fn get_usage(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<UsageRequestQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let usage = state
|
||||
.service
|
||||
.get_usage_overview(&path.workspace_id.as_str().into(), query)
|
||||
.await?;
|
||||
Ok(Json(json!(usage)))
|
||||
}
|
||||
|
||||
pub async fn get_operation_usage(
|
||||
Path(path): Path<WorkspaceOperationUsagePath>,
|
||||
Query(query): Query<UsageRequestQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let usage = state
|
||||
.service
|
||||
.get_operation_usage(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
query,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(usage)))
|
||||
}
|
||||
|
||||
pub async fn get_agent_usage(
|
||||
Path(path): Path<WorkspaceAgentUsagePath>,
|
||||
Query(query): Query<UsageRequestQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let usage = state
|
||||
.service
|
||||
.get_agent_usage(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
query,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(usage)))
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
request_context::RequestContext,
|
||||
service::{
|
||||
ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload,
|
||||
PublishPayload, TestRunPayload, UpdateOperationPayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceOperationPath {
|
||||
pub workspace_id: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceOperationVersionPath {
|
||||
pub workspace_id: String,
|
||||
pub operation_id: String,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
pub async fn list_operations(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_operations(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
let total = items.len();
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
"page": 1,
|
||||
"page_size": total,
|
||||
"total": total
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn create_operation(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<OperationPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let created = state
|
||||
.service
|
||||
.create_operation(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn get_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let operation = state
|
||||
.service
|
||||
.get_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(operation)))
|
||||
}
|
||||
|
||||
pub async fn update_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateOperationPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let result = state
|
||||
.service
|
||||
.update_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(result)))
|
||||
}
|
||||
|
||||
pub async fn delete_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let result = state
|
||||
.service
|
||||
.delete_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(result)))
|
||||
}
|
||||
|
||||
pub async fn get_operation_version(
|
||||
Path(path): Path<WorkspaceOperationVersionPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let version = state
|
||||
.service
|
||||
.get_operation_version(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
path.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(version)))
|
||||
}
|
||||
|
||||
pub async fn create_version(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<NewVersionPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let created = state
|
||||
.service
|
||||
.create_version(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
}
|
||||
|
||||
pub async fn publish_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PublishPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let published = state
|
||||
.service
|
||||
.publish_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(published)))
|
||||
}
|
||||
|
||||
pub async fn archive_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let archived = state
|
||||
.service
|
||||
.archive_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(archived)))
|
||||
}
|
||||
|
||||
pub async fn run_test(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<TestRunPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let result = state
|
||||
.service
|
||||
.run_test(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
&request_context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(result)))
|
||||
}
|
||||
|
||||
pub async fn upload_input_json(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let sample = state
|
||||
.service
|
||||
.save_json_sample(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
crank_registry::SampleKind::InputJson,
|
||||
&payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"sample_id": sample.id.as_str(),
|
||||
"sample_kind": sample.sample_kind,
|
||||
"version": sample.version
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn upload_output_json(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let sample = state
|
||||
.service
|
||||
.save_json_sample(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
crank_registry::SampleKind::OutputJson,
|
||||
&payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"sample_id": sample.id.as_str(),
|
||||
"sample_kind": sample.sample_kind,
|
||||
"version": sample.version
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn generate_draft(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<GenerateDraftPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let draft = state
|
||||
.service
|
||||
.generate_draft(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(draft)))
|
||||
}
|
||||
|
||||
pub async fn export_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
Query(query): Query<ExportQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let yaml = state
|
||||
.service
|
||||
.export_operation(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
query,
|
||||
)
|
||||
.await?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
header::HeaderValue::from_static("application/yaml"),
|
||||
);
|
||||
|
||||
Ok((StatusCode::OK, headers, yaml))
|
||||
}
|
||||
|
||||
pub async fn import_operation(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<ImportQuery>,
|
||||
State(state): State<AppState>,
|
||||
body: String,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let imported = state
|
||||
.service
|
||||
.import_operation(&path.workspace_id.as_str().into(), query, &body)
|
||||
.await?;
|
||||
Ok(Json(json!(imported)))
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{RotateSecretPayload, SecretPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceSecretPath {
|
||||
pub workspace_id: String,
|
||||
pub secret_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_secrets(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_secrets(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_secret(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<SecretPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let secret = state
|
||||
.service
|
||||
.create_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
Some(&session.user.id),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
}
|
||||
|
||||
pub async fn get_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let secret = state
|
||||
.service
|
||||
.get_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
}
|
||||
|
||||
pub async fn rotate_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<RotateSecretPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let secret = state
|
||||
.service
|
||||
.rotate_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
Some(&session.user.id),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
}
|
||||
|
||||
pub async fn delete_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
state
|
||||
.service
|
||||
.delete_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{error::ApiError, routes::access::WorkspacePath, state::AppState};
|
||||
|
||||
pub async fn list_protocol_capabilities(
|
||||
Path(_path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
Ok(Json(json!({
|
||||
"items": state.service.list_protocol_capabilities().await
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{UpdateWorkspacePayload, WorkspacePayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn list_workspaces(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_workspaces_for_user(&session.user.id)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<WorkspacePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = state
|
||||
.service
|
||||
.create_workspace(&session.user.id, payload)
|
||||
.await?;
|
||||
Ok(Json(json!(workspace)))
|
||||
}
|
||||
|
||||
pub async fn get_workspace(
|
||||
Path(workspace_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = state
|
||||
.service
|
||||
.get_workspace(&workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!(workspace)))
|
||||
}
|
||||
|
||||
pub async fn update_workspace(
|
||||
Path(workspace_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateWorkspacePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = state
|
||||
.service
|
||||
.update_workspace(&workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(workspace)))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
use crate::service::AdminService;
|
||||
use crank_runtime::RequestRateLimiter;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub service: AdminService,
|
||||
pub api_rate_limiter: RequestRateLimiter,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crank_core::{OperationId, SampleId};
|
||||
use crank_registry::SampleKind;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalArtifactStorage {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum StorageError {
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
#[error("invalid storage reference: {details}")]
|
||||
InvalidStorageRef { details: String },
|
||||
}
|
||||
|
||||
impl LocalArtifactStorage {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
}
|
||||
|
||||
pub async fn write_json_sample(
|
||||
&self,
|
||||
operation_id: &OperationId,
|
||||
version: u32,
|
||||
sample_kind: SampleKind,
|
||||
sample_id: &SampleId,
|
||||
payload: &Value,
|
||||
) -> Result<String, StorageError> {
|
||||
let file_name = match sample_kind {
|
||||
SampleKind::InputJson => "input.json",
|
||||
SampleKind::OutputJson => "output.json",
|
||||
SampleKind::YamlImportSource => "source.json",
|
||||
};
|
||||
let path = self
|
||||
.root
|
||||
.join("samples")
|
||||
.join(operation_id.as_str())
|
||||
.join(format!("v{version}"))
|
||||
.join(format!("{}_{}", sample_id.as_str(), file_name));
|
||||
|
||||
write_json_file(&path, payload).await?;
|
||||
|
||||
Ok(to_storage_ref(&path))
|
||||
}
|
||||
|
||||
pub async fn read_json(&self, storage_ref: &str) -> Result<Value, StorageError> {
|
||||
let path = from_storage_ref(storage_ref)?;
|
||||
let bytes = fs::read(path).await?;
|
||||
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_json_file(path: &Path, payload: &Value) -> Result<(), StorageError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
let bytes = serde_json::to_vec_pretty(payload)?;
|
||||
fs::write(path, bytes).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_storage_ref(path: &Path) -> String {
|
||||
format!("file://{}", path.display())
|
||||
}
|
||||
|
||||
fn from_storage_ref(storage_ref: &str) -> Result<PathBuf, StorageError> {
|
||||
let Some(path) = storage_ref.strip_prefix("file://") else {
|
||||
return Err(StorageError::InvalidStorageRef {
|
||||
details: storage_ref.to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
Reference in New Issue
Block a user