feat: add platform access foundation

This commit is contained in:
a.tolmachev
2026-03-29 23:17:05 +03:00
parent 9fb69c1571
commit 587584a8bf
19 changed files with 1058 additions and 34 deletions
Generated
+2
View File
@@ -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",
+1
View File
@@ -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"] }
+6 -6
View File
@@ -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
+2
View File
@@ -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"] }
+133 -1
View File
@@ -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::<Value>()
.await
.unwrap();
let created_invitation = client
.post(format!("{base_url}/invitations"))
.json(&json!({
"email": "operator@example.com",
"role": "operator"
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let invitation_id = created_invitation["invitation"]["invitation"]["id"]
.as_str()
.unwrap()
.to_owned();
let invitations = client
.get(format!("{base_url}/invitations"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let created_key = client
.post(format!("{base_url}/platform-api-keys"))
.json(&json!({
"name": "workspace-operator",
"scopes": ["read", "write"]
}))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.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;
+6
View File
@@ -102,6 +102,12 @@ impl From<RegistryError> 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,
+1
View File
@@ -1,3 +1,4 @@
pub mod access;
pub mod agents;
pub mod auth_profiles;
pub mod operations;
+129
View File
@@ -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<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 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>,
Json(payload): Json<InvitationPayload>,
) -> Result<Json<Value>, 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<WorkspaceInvitationPath>,
State(state): State<AppState>,
) -> Result<StatusCode, ApiError> {
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<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, 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<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<PlatformApiKeyPayload>,
) -> Result<Json<Value>, 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<WorkspacePlatformApiKeyPath>,
State(state): State<AppState>,
) -> Result<StatusCode, ApiError> {
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<WorkspacePlatformApiKeyPath>,
State(state): State<AppState>,
) -> Result<StatusCode, ApiError> {
state
.service
.delete_platform_api_key(
&path.workspace_id.as_str().into(),
&path.key_id.as_str().into(),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
+185 -6
View File
@@ -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<String>,
}
#[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<PlatformApiKeyScope>,
}
#[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<Vec<MembershipRecord>, 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<Vec<InvitationRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
Ok(self.registry.list_invitations(workspace_id).await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), email = %payload.email))]
pub async fn create_invitation(
&self,
workspace_id: &WorkspaceId,
payload: InvitationPayload,
) -> Result<CreatedInvitationResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let invite_token = generate_access_secret("invite");
let invitation = InvitationRecord {
invitation: InvitationToken {
id: InvitationId::new(new_prefixed_id("inv")),
workspace_id: workspace_id.clone(),
email: payload.email,
role: payload.role,
status: InvitationStatus::Pending,
token_hash: hash_access_secret(&invite_token),
expires_at: match payload.expires_at {
Some(expires_at) => 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<Vec<PlatformApiKeyRecord>, 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<CreatedPlatformApiKeyResponse, ApiError> {
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<String, ApiError> {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))
}
fn default_invitation_expiry() -> Result<String, ApiError> {
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<GrpcServiceSummary, ApiError> {
let methods = service
.unary_methods()
+84
View File
@@ -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<PlatformApiKeyScope>,
pub status: PlatformApiKeyStatus,
pub created_at: String,
pub last_used_at: Option<String>,
}
+2
View File
@@ -43,3 +43,5 @@ define_id!(AuthProfileId);
define_id!(WorkspaceId);
define_id!(UserId);
define_id!(AgentId);
define_id!(InvitationId);
define_id!(PlatformApiKeyId);
+7 -1
View File
@@ -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,
+4
View File
@@ -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")]
+8 -7
View File
@@ -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;
+121
View File
@@ -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,
+31 -2
View File
@@ -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,
+281 -9
View File
@@ -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<Vec<MembershipRecord>, 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<Vec<InvitationRecord>, 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<Vec<PlatformApiKeyRecord>, 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<WorkspaceRecord, RegistryError> {
})
}
fn map_membership_record(row: &PgRow) -> Result<MembershipRecord, RegistryError> {
Ok(MembershipRecord {
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
user: User {
id: UserId::new(row.try_get::<String, _>("user_id")?),
email: row.try_get("email")?,
display_name: row.try_get("display_name")?,
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
created_at: row.try_get("user_created_at")?,
},
role: deserialize_enum_text(&row.try_get::<String, _>("role")?, "role")?,
created_at: row.try_get("created_at")?,
})
}
fn map_invitation_record(row: &PgRow) -> Result<InvitationRecord, RegistryError> {
Ok(InvitationRecord {
invitation: InvitationToken {
id: InvitationId::new(row.try_get::<String, _>("id")?),
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
email: row.try_get("email")?,
role: deserialize_enum_text(&row.try_get::<String, _>("role")?, "role")?,
status: deserialize_enum_text(&row.try_get::<String, _>("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<PlatformApiKeyRecord, RegistryError> {
Ok(PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(row.try_get::<String, _>("id")?),
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
name: row.try_get("name")?,
prefix: row.try_get("prefix")?,
scopes: deserialize_json_value(row.try_get::<Json<Value>, _>("scopes_json")?.0)?,
status: deserialize_enum_text(&row.try_get::<String, _>("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<AgentSummary, RegistryError> {
Ok(AgentSummary {
id: AgentId::new(row.try_get::<String, _>("id")?),
+12
View File
@@ -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`
+43 -2
View File
@@ -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`
Агрегированная статистика по периоду.