diff --git a/Cargo.lock b/Cargo.lock index ff46154..297159f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,6 +7,7 @@ name = "admin-api" version = "0.1.0" dependencies = [ "axum", + "base64", "crank-adapter-grpc", "crank-core", "crank-mapping", @@ -18,6 +19,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "sqlx", "thiserror", "time", diff --git a/Cargo.toml b/Cargo.toml index 1fc8d14..d2eefb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +sha2 = "0.10" sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres"] } thiserror = "2" time = { version = "0.3", features = ["formatting", "parsing"] } diff --git a/TASKS.md b/TASKS.md index 9279353..642ff47 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,19 +2,19 @@ ## Current -### `feat/agent-publishing` +### `feat/platform-access` Status: completed DoD: -- можно создать `agent` в рамках `workspace` -- можно привязать published operations к `agent` -- `mcp-server` отдает tools в контексте `workspace + agent` -- один `agent` видит только свой curated toolset +- реализованы `platform-api-keys` как отдельная сущность уровня платформы +- `memberships` и `invitations` имеют workspace-scoped backend-контракт +- `platform-api-keys` не смешиваются с upstream `auth_profiles` +- `Settings` и `Workspace` экраны имеют достаточный backend foundation ## Next -- `feat/platform-access` +- `feat/observability-api` ## Backlog diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index a6f0074..0652b7f 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -7,6 +7,7 @@ version.workspace = true [dependencies] axum.workspace = true +base64.workspace = true crank-core = { path = "../../crates/crank-core" } crank-mapping = { path = "../../crates/crank-mapping" } crank-proto = { path = "../../crates/crank-proto" } @@ -16,6 +17,7 @@ crank-schema = { path = "../../crates/crank-schema" } serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true +sha2.workspace = true thiserror.workspace = true time.workspace = true tokio = { workspace = true, features = ["fs"] } diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index b2dc766..f72095e 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -5,6 +5,10 @@ use axum::{ use crate::{ routes::{ + access::{ + create_invitation, create_platform_api_key, delete_invitation, delete_platform_api_key, + list_invitations, list_memberships, list_platform_api_keys, revoke_platform_api_key, + }, agents::{ create_agent, get_agent, get_agent_version, list_agents, publish_agent, save_agent_bindings, @@ -73,7 +77,28 @@ pub fn build_app(state: AppState) -> Router { "/auth-profiles", get(list_auth_profiles).post(create_auth_profile), ) - .route("/auth-profiles/{auth_profile_id}", get(get_auth_profile)); + .route("/auth-profiles/{auth_profile_id}", get(get_auth_profile)) + .route("/members", get(list_memberships)) + .route( + "/invitations", + get(list_invitations).post(create_invitation), + ) + .route( + "/invitations/{invitation_id}", + axum::routing::delete(delete_invitation), + ) + .route( + "/platform-api-keys", + get(list_platform_api_keys).post(create_platform_api_key), + ) + .route( + "/platform-api-keys/{key_id}/revoke", + post(revoke_platform_api_key), + ) + .route( + "/platform-api-keys/{key_id}", + axum::routing::delete(delete_platform_api_key), + ); let admin_router = Router::new() .route("/workspaces", get(list_workspaces).post(create_workspace)) @@ -262,6 +287,113 @@ mod tests { assert_eq!(published["published_version"], 1); } + #[tokio::test] + async fn manages_platform_access_resources() { + 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 = reqwest::Client::new(); + + let members = client + .get(format!("{base_url}/members")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let created_invitation = client + .post(format!("{base_url}/invitations")) + .json(&json!({ + "email": "operator@example.com", + "role": "operator" + })) + .send() + .await + .unwrap() + .json::() + .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::() + .await + .unwrap(); + let created_key = client + .post(format!("{base_url}/platform-api-keys")) + .json(&json!({ + "name": "workspace-operator", + "scopes": ["read", "write"] + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let key_id = created_key["api_key"]["api_key"]["id"] + .as_str() + .unwrap() + .to_owned(); + let listed_keys = client + .get(format!("{base_url}/platform-api-keys")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let revoke_status = client + .post(format!("{base_url}/platform-api-keys/{key_id}/revoke")) + .send() + .await + .unwrap() + .status(); + let delete_invitation_status = client + .delete(format!("{base_url}/invitations/{invitation_id}")) + .send() + .await + .unwrap() + .status(); + let delete_key_status = client + .delete(format!("{base_url}/platform-api-keys/{key_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!( + listed_keys["items"][0]["api_key"]["name"], + "workspace-operator" + ); + assert!(created_key["secret"].as_str().unwrap().starts_with("crk_")); + assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT); + assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT); + assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT); + } + #[tokio::test] async fn creates_publishes_and_tests_graphql_operation() { let registry = test_registry().await; diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 644f0e0..92ca27f 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -102,6 +102,12 @@ impl From for ApiError { RegistryError::AgentNotFound { agent_id } => { Self::not_found(format!("agent {agent_id} was not found")) } + RegistryError::InvitationNotFound { invitation_id } => { + Self::not_found(format!("invitation {invitation_id} was not found")) + } + RegistryError::PlatformApiKeyNotFound { key_id } => { + Self::not_found(format!("platform api key {key_id} was not found")) + } RegistryError::PublishedAgentNotFound { workspace_slug, agent_slug, diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index 8040641..2fceb42 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -1,3 +1,4 @@ +pub mod access; pub mod agents; pub mod auth_profiles; pub mod operations; diff --git a/apps/admin-api/src/routes/access.rs b/apps/admin-api/src/routes/access.rs new file mode 100644 index 0000000..79edb45 --- /dev/null +++ b/apps/admin-api/src/routes/access.rs @@ -0,0 +1,129 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{ + error::ApiError, + service::{InvitationPayload, PlatformApiKeyPayload}, + 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 WorkspacePlatformApiKeyPath { + pub workspace_id: String, + pub key_id: String, +} + +pub async fn list_memberships( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_memberships(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn list_invitations( + Path(path): Path, + State(state): State, +) -> Result, 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, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let created = state + .service + .create_invitation(&path.workspace_id.as_str().into(), payload) + .await?; + Ok(Json(json!(created))) +} + +pub async fn delete_invitation( + Path(path): Path, + State(state): State, +) -> Result { + state + .service + .delete_invitation( + &path.workspace_id.as_str().into(), + &path.invitation_id.as_str().into(), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn list_platform_api_keys( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_platform_api_keys(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_platform_api_key( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let created = state + .service + .create_platform_api_key(&path.workspace_id.as_str().into(), payload) + .await?; + Ok(Json(json!(created))) +} + +pub async fn revoke_platform_api_key( + Path(path): Path, + State(state): State, +) -> Result { + state + .service + .revoke_platform_api_key( + &path.workspace_id.as_str().into(), + &path.key_id.as_str().into(), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn delete_platform_api_key( + Path(path): Path, + State(state): State, +) -> Result { + state + .service + .delete_platform_api_key( + &path.workspace_id.as_str().into(), + &path.key_id.as_str().into(), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 68dcd0b..50db63c 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -1,24 +1,28 @@ use std::path::PathBuf; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, GeneratedDraftStatus, - OperationId, OperationStatus, Protocol, SampleId, Samples, Target, Workspace, WorkspaceId, - WorkspaceStatus, + InvitationId, InvitationStatus, InvitationToken, MembershipRole, OperationId, OperationStatus, + PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, + SampleId, Samples, Target, Workspace, WorkspaceId, WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; use crank_proto::{ProtoService, services_from_descriptor_set_bytes}; use crank_registry::{ - AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateVersionRequest, - CreateWorkspaceRequest, OperationSampleMetadata, OperationSummary, OperationVersionRecord, - PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, - SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, + AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, + CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, + MembershipRecord, OperationSampleMetadata, OperationSummary, OperationVersionRecord, + PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, + SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord, }; use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; use crank_schema::Schema; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::{info, instrument}; use uuid::Uuid; @@ -134,6 +138,31 @@ pub struct PublishAgentResponse { pub published_at: String, } +#[derive(Clone, Debug, Deserialize)] +pub struct InvitationPayload { + pub email: String, + pub role: MembershipRole, + pub expires_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct CreatedInvitationResponse { + pub invitation: InvitationRecord, + pub invite_token: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PlatformApiKeyPayload { + pub name: String, + pub scopes: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct CreatedPlatformApiKeyResponse { + pub api_key: PlatformApiKeyRecord, + pub secret: String, +} + #[derive(Clone, Debug, Deserialize)] pub struct GenerateDraftPayload { #[serde(default)] @@ -300,6 +329,138 @@ impl AdminService { Ok(WorkspaceRecord { workspace }) } + #[instrument(skip(self))] + pub async fn list_memberships( + &self, + workspace_id: &WorkspaceId, + ) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + Ok(self.registry.list_memberships(workspace_id).await?) + } + + #[instrument(skip(self))] + pub async fn list_invitations( + &self, + workspace_id: &WorkspaceId, + ) -> Result, 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 { + 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) => expires_at, + None => default_invitation_expiry()?, + }, + created_at: now_string()?, + }, + }; + + 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(()) + } + + #[instrument(skip(self))] + pub async fn list_platform_api_keys( + &self, + workspace_id: &WorkspaceId, + ) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + Ok(self.registry.list_platform_api_keys(workspace_id).await?) + } + + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), key_name = %payload.name))] + pub async fn create_platform_api_key( + &self, + workspace_id: &WorkspaceId, + payload: PlatformApiKeyPayload, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + + let secret = generate_access_secret("crk"); + let api_key = PlatformApiKeyRecord { + api_key: PlatformApiKey { + id: PlatformApiKeyId::new(new_prefixed_id("pk")), + workspace_id: workspace_id.clone(), + name: payload.name, + prefix: secret.chars().take(16).collect(), + scopes: payload.scopes, + status: PlatformApiKeyStatus::Active, + created_at: now_string()?, + last_used_at: None, + }, + }; + + self.registry + .create_platform_api_key(CreatePlatformApiKeyRequest { + api_key: &api_key.api_key, + secret_hash: &hash_access_secret(&secret), + }) + .await?; + + Ok(CreatedPlatformApiKeyResponse { api_key, secret }) + } + + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))] + pub async fn revoke_platform_api_key( + &self, + workspace_id: &WorkspaceId, + key_id: &PlatformApiKeyId, + ) -> Result<(), ApiError> { + self.registry + .revoke_platform_api_key(workspace_id, key_id, &now_string()?) + .await?; + Ok(()) + } + + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))] + pub async fn delete_platform_api_key( + &self, + workspace_id: &WorkspaceId, + key_id: &PlatformApiKeyId, + ) -> Result<(), ApiError> { + self.registry + .delete_platform_api_key(workspace_id, key_id) + .await?; + Ok(()) + } + pub async fn list_operations( &self, workspace_id: &WorkspaceId, @@ -1240,12 +1401,30 @@ fn new_prefixed_id(prefix: &str) -> String { format!("{prefix}_{}", Uuid::now_v7().simple()) } +fn generate_access_secret(prefix: &str) -> String { + let random = URL_SAFE_NO_PAD.encode(Uuid::now_v7().as_bytes()); + format!("{prefix}_{random}") +} + +fn hash_access_secret(secret: &str) -> String { + let digest = Sha256::digest(secret.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + fn now_string() -> Result { OffsetDateTime::now_utc() .format(&Rfc3339) .map_err(|error| ApiError::internal(error.to_string())) } +fn default_invitation_expiry() -> Result { + OffsetDateTime::now_utc() + .checked_add(time::Duration::days(7)) + .ok_or_else(|| ApiError::internal("failed to compute invitation expiry"))? + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string())) +} + fn proto_service_summary(service: ProtoService) -> Result { let methods = service .unary_methods() diff --git a/crates/crank-core/src/access.rs b/crates/crank-core/src/access.rs new file mode 100644 index 0000000..31ee3dc --- /dev/null +++ b/crates/crank-core/src/access.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; + +use crate::ids::{InvitationId, PlatformApiKeyId, UserId, WorkspaceId}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UserStatus { + Active, + Disabled, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MembershipRole { + Owner, + Admin, + Operator, + Viewer, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvitationStatus { + Pending, + Accepted, + Revoked, + Expired, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlatformApiKeyStatus { + Active, + Revoked, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlatformApiKeyScope { + Read, + Write, + Deploy, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct User { + pub id: UserId, + pub email: String, + pub display_name: String, + pub status: UserStatus, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Membership { + pub workspace_id: WorkspaceId, + pub user_id: UserId, + pub role: MembershipRole, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct InvitationToken { + pub id: InvitationId, + pub workspace_id: WorkspaceId, + pub email: String, + pub role: MembershipRole, + pub status: InvitationStatus, + pub token_hash: String, + pub expires_at: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlatformApiKey { + pub id: PlatformApiKeyId, + pub workspace_id: WorkspaceId, + pub name: String, + pub prefix: String, + pub scopes: Vec, + pub status: PlatformApiKeyStatus, + pub created_at: String, + pub last_used_at: Option, +} diff --git a/crates/crank-core/src/ids.rs b/crates/crank-core/src/ids.rs index eb281f5..2f30e0a 100644 --- a/crates/crank-core/src/ids.rs +++ b/crates/crank-core/src/ids.rs @@ -43,3 +43,5 @@ define_id!(AuthProfileId); define_id!(WorkspaceId); define_id!(UserId); define_id!(AgentId); +define_id!(InvitationId); +define_id!(PlatformApiKeyId); diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index 56f186c..2b32d14 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod access; pub mod agent; pub mod auth; pub mod ids; @@ -5,13 +6,18 @@ pub mod operation; pub mod protocol; pub mod workspace; +pub use access::{ + InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey, + PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus, +}; pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion}; pub use auth::{ ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig, BearerAuthConfig, SecretRef, }; pub use ids::{ - AgentId, AuthProfileId, DescriptorId, OperationId, SampleId, ToolId, UserId, WorkspaceId, + AgentId, AuthProfileId, DescriptorId, InvitationId, OperationId, PlatformApiKeyId, SampleId, + ToolId, UserId, WorkspaceId, }; pub use operation::{ ConfigExport, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlTarget, diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index 19911e1..76ef992 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -10,6 +10,10 @@ pub enum RegistryError { WorkspaceNotFound { workspace_id: String }, #[error("workspace with slug {slug} already exists")] WorkspaceSlugAlreadyExists { slug: String }, + #[error("invitation {invitation_id} was not found")] + InvitationNotFound { invitation_id: String }, + #[error("platform api key {key_id} was not found")] + PlatformApiKeyNotFound { key_id: String }, #[error("agent {agent_id} was not found")] AgentNotFound { agent_id: String }, #[error("published agent {workspace_slug}/{agent_slug} was not found")] diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index e7cc131..4c952fc 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -5,12 +5,13 @@ mod postgres; pub use error::RegistryError; pub use model::{ - AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateVersionRequest, - CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, - OperationSampleMetadata, OperationSummary, OperationVersionRecord, PublishAgentRequest, - PublishRequest, PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, - YamlImportJobId, YamlImportJobStatus, + AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, + CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, + CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, InvitationRecord, + MembershipRecord, OperationSampleMetadata, OperationSummary, OperationVersionRecord, + PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool, + RegistryOperation, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest, + WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, }; pub use postgres::PostgresRegistry; diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index 08ce2d3..8299308 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -15,6 +15,127 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + query( + "create table if not exists users ( + id text primary key, + email text not null unique, + display_name text not null, + status text not null, + created_at timestamptz not null + )", + ) + .execute(pool) + .await?; + + query( + "insert into users ( + id, + email, + display_name, + status, + created_at + ) values ( + 'user_default_owner', + 'owner@crank.local', + 'Workspace Owner', + 'active', + now() + ) + on conflict (id) do nothing", + ) + .execute(pool) + .await?; + + query( + "create table if not exists memberships ( + workspace_id text not null references workspaces(id) on delete cascade, + user_id text not null references users(id) on delete cascade, + role text not null, + created_at timestamptz not null, + primary key (workspace_id, user_id) + )", + ) + .execute(pool) + .await?; + + query( + "insert into workspaces ( + id, + slug, + display_name, + status, + settings_json, + created_at, + updated_at + ) values ( + 'ws_default', + 'default', + 'Default Workspace', + 'active', + '{}'::jsonb, + now(), + now() + ) + on conflict (id) do nothing", + ) + .execute(pool) + .await?; + + query( + "insert into memberships ( + workspace_id, + user_id, + role, + created_at + ) values ( + 'ws_default', + 'user_default_owner', + 'owner', + now() + ) + on conflict (workspace_id, user_id) do nothing", + ) + .execute(pool) + .await?; + + query( + "create table if not exists invitation_tokens ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + email text not null, + role text not null, + status text not null, + token_hash text not null, + expires_at timestamptz not null, + created_at timestamptz not null + )", + ) + .execute(pool) + .await?; + + query( + "create table if not exists platform_api_keys ( + id text primary key, + workspace_id text not null references workspaces(id) on delete cascade, + name text not null, + prefix text not null, + secret_hash text not null, + scopes_json jsonb not null, + status text not null, + created_at timestamptz not null, + last_used_at timestamptz null, + revoked_at timestamptz null + )", + ) + .execute(pool) + .await?; + + query( + "create unique index if not exists platform_api_keys_workspace_name_idx on platform_api_keys(workspace_id, name)", + ) + .execute(pool) + .await?; + query( "insert into workspaces ( id, diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index 75f27ce..fcde65a 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -1,7 +1,7 @@ use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId, - ExportMode, Operation, OperationId, OperationStatus, Protocol, SampleId, Workspace, - WorkspaceId, + ExportMode, InvitationToken, MembershipRole, Operation, OperationId, OperationStatus, + PlatformApiKey, Protocol, SampleId, User, Workspace, WorkspaceId, }; use crank_mapping::MappingSet; use crank_schema::Schema; @@ -46,6 +46,24 @@ pub struct WorkspaceRecord { pub workspace: Workspace, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MembershipRecord { + pub workspace_id: WorkspaceId, + pub user: User, + pub role: MembershipRole, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct InvitationRecord { + pub invitation: InvitationToken, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PlatformApiKeyRecord { + pub api_key: PlatformApiKey, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct AgentSummary { pub id: AgentId, @@ -248,6 +266,17 @@ pub struct PublishAgentRequest<'a> { pub published_by: Option<&'a str>, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateInvitationRequest<'a> { + pub invitation: &'a InvitationToken, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CreatePlatformApiKeyRequest<'a> { + pub api_key: &'a PlatformApiKey, + pub secret_hash: &'a str, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SaveSampleMetadataRequest<'a> { pub sample: &'a OperationSampleMetadata, diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index 6ee8a15..62111dd 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -1,6 +1,7 @@ use crank_core::{ - AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, OperationId, - OperationStatus, Workspace, WorkspaceId, + AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, InvitationId, + InvitationToken, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, + PlatformApiKeyStatus, User, UserId, Workspace, WorkspaceId, }; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -14,13 +15,14 @@ use crate::{ error::RegistryError, migrations, model::{ - AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateVersionRequest, - CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata, - OperationSampleMetadata, OperationSummary, OperationVersionRecord, PublishAgentRequest, - PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, - YamlImportJobId, YamlImportJobStatus, + AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, + CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, + CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, MembershipRecord, + OperationSampleMetadata, OperationSummary, OperationVersionRecord, PlatformApiKeyRecord, + PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, + SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, + SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJob, + YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, }, }; @@ -62,6 +64,231 @@ impl PostgresRegistry { rows.iter().map(map_workspace_record).collect() } + pub async fn list_memberships( + &self, + workspace_id: &WorkspaceId, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + m.workspace_id, + m.user_id, + m.role, + to_char(m.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + u.email, + u.display_name, + u.status, + to_char(u.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as user_created_at + from memberships m + join users u on u.id = m.user_id + where m.workspace_id = $1 + order by u.email asc", + ) + .bind(workspace_id.as_str()) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_membership_record).collect() + } + + pub async fn list_invitations( + &self, + workspace_id: &WorkspaceId, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + id, + workspace_id, + email, + role, + status, + token_hash, + to_char(expires_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as expires_at, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at + from invitation_tokens + where workspace_id = $1 + order by created_at desc", + ) + .bind(workspace_id.as_str()) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_invitation_record).collect() + } + + pub async fn create_invitation( + &self, + request: CreateInvitationRequest<'_>, + ) -> Result<(), RegistryError> { + sqlx::query( + "insert into invitation_tokens ( + id, + workspace_id, + email, + role, + status, + token_hash, + expires_at, + created_at + ) values ( + $1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz + )", + ) + .bind(request.invitation.id.as_str()) + .bind(request.invitation.workspace_id.as_str()) + .bind(&request.invitation.email) + .bind(serialize_enum_text(&request.invitation.role, "role")?) + .bind(serialize_enum_text(&request.invitation.status, "status")?) + .bind(&request.invitation.token_hash) + .bind(&request.invitation.expires_at) + .bind(&request.invitation.created_at) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn delete_invitation( + &self, + workspace_id: &WorkspaceId, + invitation_id: &InvitationId, + ) -> Result<(), RegistryError> { + let result = + sqlx::query("delete from invitation_tokens where workspace_id = $1 and id = $2") + .bind(workspace_id.as_str()) + .bind(invitation_id.as_str()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::InvitationNotFound { + invitation_id: invitation_id.as_str().to_owned(), + }); + } + + Ok(()) + } + + pub async fn list_platform_api_keys( + &self, + workspace_id: &WorkspaceId, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + id, + workspace_id, + name, + prefix, + scopes_json, + status, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at + from platform_api_keys + where workspace_id = $1 + order by created_at desc", + ) + .bind(workspace_id.as_str()) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_platform_api_key_record).collect() + } + + pub async fn create_platform_api_key( + &self, + request: CreatePlatformApiKeyRequest<'_>, + ) -> Result<(), RegistryError> { + let result = sqlx::query( + "insert into platform_api_keys ( + id, + workspace_id, + name, + prefix, + secret_hash, + scopes_json, + status, + created_at, + last_used_at, + revoked_at + ) values ( + $1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9::timestamptz, $10::timestamptz + )", + ) + .bind(request.api_key.id.as_str()) + .bind(request.api_key.workspace_id.as_str()) + .bind(&request.api_key.name) + .bind(&request.api_key.prefix) + .bind(request.secret_hash) + .bind(Json(serialize_json_value(&request.api_key.scopes)?)) + .bind(serialize_enum_text(&request.api_key.status, "status")?) + .bind(&request.api_key.created_at) + .bind(request.api_key.last_used_at.as_deref()) + .bind(Option::<&str>::None) + .execute(&self.pool) + .await; + + match result { + Ok(_) => Ok(()), + Err(sqlx::Error::Database(error)) + if error.constraint() == Some("platform_api_keys_workspace_name_idx") => + { + Err(RegistryError::Storage(sqlx::Error::Database(error))) + } + Err(error) => Err(RegistryError::Storage(error)), + } + } + + pub async fn revoke_platform_api_key( + &self, + workspace_id: &WorkspaceId, + key_id: &PlatformApiKeyId, + revoked_at: &str, + ) -> Result<(), RegistryError> { + let result = sqlx::query( + "update platform_api_keys + set status = $1, + revoked_at = $2::timestamptz + where workspace_id = $3 and id = $4", + ) + .bind(serialize_enum_text( + &PlatformApiKeyStatus::Revoked, + "status", + )?) + .bind(revoked_at) + .bind(workspace_id.as_str()) + .bind(key_id.as_str()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::PlatformApiKeyNotFound { + key_id: key_id.as_str().to_owned(), + }); + } + + Ok(()) + } + + pub async fn delete_platform_api_key( + &self, + workspace_id: &WorkspaceId, + key_id: &PlatformApiKeyId, + ) -> Result<(), RegistryError> { + let result = + sqlx::query("delete from platform_api_keys where workspace_id = $1 and id = $2") + .bind(workspace_id.as_str()) + .bind(key_id.as_str()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::PlatformApiKeyNotFound { + key_id: key_id.as_str().to_owned(), + }); + } + + Ok(()) + } + pub async fn get_workspace( &self, workspace_id: &WorkspaceId, @@ -1417,6 +1644,51 @@ fn map_workspace_record(row: &PgRow) -> Result { }) } +fn map_membership_record(row: &PgRow) -> Result { + Ok(MembershipRecord { + workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), + user: User { + id: UserId::new(row.try_get::("user_id")?), + email: row.try_get("email")?, + display_name: row.try_get("display_name")?, + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + created_at: row.try_get("user_created_at")?, + }, + role: deserialize_enum_text(&row.try_get::("role")?, "role")?, + created_at: row.try_get("created_at")?, + }) +} + +fn map_invitation_record(row: &PgRow) -> Result { + Ok(InvitationRecord { + invitation: InvitationToken { + id: InvitationId::new(row.try_get::("id")?), + workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), + email: row.try_get("email")?, + role: deserialize_enum_text(&row.try_get::("role")?, "role")?, + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + token_hash: row.try_get("token_hash")?, + expires_at: row.try_get("expires_at")?, + created_at: row.try_get("created_at")?, + }, + }) +} + +fn map_platform_api_key_record(row: &PgRow) -> Result { + Ok(PlatformApiKeyRecord { + api_key: PlatformApiKey { + id: PlatformApiKeyId::new(row.try_get::("id")?), + workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), + name: row.try_get("name")?, + prefix: row.try_get("prefix")?, + scopes: deserialize_json_value(row.try_get::, _>("scopes_json")?.0)?, + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + created_at: row.try_get("created_at")?, + last_used_at: row.try_get("last_used_at")?, + }, + }) +} + fn map_agent_summary(row: &PgRow) -> Result { Ok(AgentSummary { id: AgentId::new(row.try_get::("id")?), diff --git a/docs/admin-api.md b/docs/admin-api.md index 5335840..d594197 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -51,9 +51,15 @@ - `GET /api/admin/workspaces/{workspace_id}` - `PATCH /api/admin/workspaces/{workspace_id}` - `GET /api/admin/workspaces/{workspace_id}/members` +- `GET /api/admin/workspaces/{workspace_id}/invitations` - `POST /api/admin/workspaces/{workspace_id}/invitations` - `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}` +Контракт: + +- `POST /invitations` возвращает metadata invitation и одноразовый `invite_token`; +- `invite_token` доступен только в create-response и не возвращается повторно в list endpoints. + ### 5.2. Operations - `GET /api/admin/workspaces/{workspace_id}/operations` @@ -106,6 +112,12 @@ - `POST /api/admin/workspaces/{workspace_id}/platform-api-keys/{key_id}/revoke` - `DELETE /api/admin/workspaces/{workspace_id}/platform-api-keys/{key_id}` +Контракт: + +- `POST /platform-api-keys` возвращает metadata ключа и одноразовый `secret`; +- `secret` доступен только в create-response; +- list endpoints возвращают только metadata, `prefix`, `status`, `scopes` и `last_used_at`. + ### 5.7. Observability - `GET /api/admin/workspaces/{workspace_id}/logs` diff --git a/docs/data-model.md b/docs/data-model.md index d7cef75..14c134e 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -170,7 +170,48 @@ - `created_at` - `last_used_at` -### 3.8. `InvocationLog` +Секрет: + +- полный secret показывается только один раз при создании; +- в persistent storage сохраняется только `secret_hash`. + +### 3.8. `User` + +Поля: + +- `id` +- `email` +- `display_name` +- `status` +- `created_at` + +### 3.9. `Membership` + +Поля: + +- `workspace_id` +- `user_id` +- `role` +- `created_at` + +### 3.10. `InvitationToken` + +Поля: + +- `id` +- `workspace_id` +- `email` +- `role` +- `status` +- `expires_at` +- `created_at` + +Токен: + +- полный invite token показывается только один раз при создании; +- в persistent storage сохраняется только `token_hash`. + +### 3.11. `InvocationLog` Продуктовая запись о вызове tool. @@ -189,7 +230,7 @@ - `response_preview` - `created_at` -### 3.9. `UsageRollup` +### 3.12. `UsageRollup` Агрегированная статистика по периоду.