feat: add platform access foundation
This commit is contained in:
Generated
+2
@@ -7,6 +7,7 @@ name = "admin-api"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"crank-adapter-grpc",
|
"crank-adapter-grpc",
|
||||||
"crank-core",
|
"crank-core",
|
||||||
"crank-mapping",
|
"crank-mapping",
|
||||||
@@ -18,6 +19,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
sha2 = "0.10"
|
||||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres"] }
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
time = { version = "0.3", features = ["formatting", "parsing"] }
|
time = { version = "0.3", features = ["formatting", "parsing"] }
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/agent-publishing`
|
### `feat/platform-access`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- можно создать `agent` в рамках `workspace`
|
- реализованы `platform-api-keys` как отдельная сущность уровня платформы
|
||||||
- можно привязать published operations к `agent`
|
- `memberships` и `invitations` имеют workspace-scoped backend-контракт
|
||||||
- `mcp-server` отдает tools в контексте `workspace + agent`
|
- `platform-api-keys` не смешиваются с upstream `auth_profiles`
|
||||||
- один `agent` видит только свой curated toolset
|
- `Settings` и `Workspace` экраны имеют достаточный backend foundation
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/platform-access`
|
- `feat/observability-api`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ version.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
crank-core = { path = "../../crates/crank-core" }
|
crank-core = { path = "../../crates/crank-core" }
|
||||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||||
crank-proto = { path = "../../crates/crank-proto" }
|
crank-proto = { path = "../../crates/crank-proto" }
|
||||||
@@ -16,6 +17,7 @@ crank-schema = { path = "../../crates/crank-schema" }
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
serde_yaml.workspace = true
|
serde_yaml.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
time.workspace = true
|
time.workspace = true
|
||||||
tokio = { workspace = true, features = ["fs"] }
|
tokio = { workspace = true, features = ["fs"] }
|
||||||
|
|||||||
+133
-1
@@ -5,6 +5,10 @@ use axum::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
routes::{
|
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::{
|
agents::{
|
||||||
create_agent, get_agent, get_agent_version, list_agents, publish_agent,
|
create_agent, get_agent, get_agent_version, list_agents, publish_agent,
|
||||||
save_agent_bindings,
|
save_agent_bindings,
|
||||||
@@ -73,7 +77,28 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
"/auth-profiles",
|
"/auth-profiles",
|
||||||
get(list_auth_profiles).post(create_auth_profile),
|
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()
|
let admin_router = Router::new()
|
||||||
.route("/workspaces", get(list_workspaces).post(create_workspace))
|
.route("/workspaces", get(list_workspaces).post(create_workspace))
|
||||||
@@ -262,6 +287,113 @@ mod tests {
|
|||||||
assert_eq!(published["published_version"], 1);
|
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]
|
#[tokio::test]
|
||||||
async fn creates_publishes_and_tests_graphql_operation() {
|
async fn creates_publishes_and_tests_graphql_operation() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ impl From<RegistryError> for ApiError {
|
|||||||
RegistryError::AgentNotFound { agent_id } => {
|
RegistryError::AgentNotFound { agent_id } => {
|
||||||
Self::not_found(format!("agent {agent_id} was not found"))
|
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 {
|
RegistryError::PublishedAgentNotFound {
|
||||||
workspace_slug,
|
workspace_slug,
|
||||||
agent_slug,
|
agent_slug,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod access;
|
||||||
pub mod agents;
|
pub mod agents;
|
||||||
pub mod auth_profiles;
|
pub mod auth_profiles;
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthConfig, AuthKind,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthConfig, AuthKind,
|
||||||
AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, GeneratedDraftStatus,
|
AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, GeneratedDraftStatus,
|
||||||
OperationId, OperationStatus, Protocol, SampleId, Samples, Target, Workspace, WorkspaceId,
|
InvitationId, InvitationStatus, InvitationToken, MembershipRole, OperationId, OperationStatus,
|
||||||
WorkspaceStatus,
|
PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol,
|
||||||
|
SampleId, Samples, Target, Workspace, WorkspaceId, WorkspaceStatus,
|
||||||
};
|
};
|
||||||
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
|
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
|
||||||
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
|
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateVersionRequest,
|
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
|
||||||
CreateWorkspaceRequest, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
|
CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord,
|
||||||
PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind,
|
MembershipRecord, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
|
||||||
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation,
|
||||||
|
SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||||
SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord,
|
SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord,
|
||||||
};
|
};
|
||||||
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
|
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
|
||||||
use crank_schema::Schema;
|
use crank_schema::Schema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
use tracing::{info, instrument};
|
use tracing::{info, instrument};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -134,6 +138,31 @@ pub struct PublishAgentResponse {
|
|||||||
pub published_at: String,
|
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)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct GenerateDraftPayload {
|
pub struct GenerateDraftPayload {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -300,6 +329,138 @@ impl AdminService {
|
|||||||
Ok(WorkspaceRecord { workspace })
|
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(
|
pub async fn list_operations(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
@@ -1240,12 +1401,30 @@ fn new_prefixed_id(prefix: &str) -> String {
|
|||||||
format!("{prefix}_{}", Uuid::now_v7().simple())
|
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> {
|
fn now_string() -> Result<String, ApiError> {
|
||||||
OffsetDateTime::now_utc()
|
OffsetDateTime::now_utc()
|
||||||
.format(&Rfc3339)
|
.format(&Rfc3339)
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))
|
.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> {
|
fn proto_service_summary(service: ProtoService) -> Result<GrpcServiceSummary, ApiError> {
|
||||||
let methods = service
|
let methods = service
|
||||||
.unary_methods()
|
.unary_methods()
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -43,3 +43,5 @@ define_id!(AuthProfileId);
|
|||||||
define_id!(WorkspaceId);
|
define_id!(WorkspaceId);
|
||||||
define_id!(UserId);
|
define_id!(UserId);
|
||||||
define_id!(AgentId);
|
define_id!(AgentId);
|
||||||
|
define_id!(InvitationId);
|
||||||
|
define_id!(PlatformApiKeyId);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod access;
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
@@ -5,13 +6,18 @@ pub mod operation;
|
|||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
|
pub use access::{
|
||||||
|
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
|
||||||
|
PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
|
||||||
|
};
|
||||||
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
||||||
pub use auth::{
|
pub use auth::{
|
||||||
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
||||||
BearerAuthConfig, SecretRef,
|
BearerAuthConfig, SecretRef,
|
||||||
};
|
};
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AgentId, AuthProfileId, DescriptorId, OperationId, SampleId, ToolId, UserId, WorkspaceId,
|
AgentId, AuthProfileId, DescriptorId, InvitationId, OperationId, PlatformApiKeyId, SampleId,
|
||||||
|
ToolId, UserId, WorkspaceId,
|
||||||
};
|
};
|
||||||
pub use operation::{
|
pub use operation::{
|
||||||
ConfigExport, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlTarget,
|
ConfigExport, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlTarget,
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ pub enum RegistryError {
|
|||||||
WorkspaceNotFound { workspace_id: String },
|
WorkspaceNotFound { workspace_id: String },
|
||||||
#[error("workspace with slug {slug} already exists")]
|
#[error("workspace with slug {slug} already exists")]
|
||||||
WorkspaceSlugAlreadyExists { slug: String },
|
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")]
|
#[error("agent {agent_id} was not found")]
|
||||||
AgentNotFound { agent_id: String },
|
AgentNotFound { agent_id: String },
|
||||||
#[error("published agent {workspace_slug}/{agent_slug} was not found")]
|
#[error("published agent {workspace_slug}/{agent_slug} was not found")]
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ mod postgres;
|
|||||||
|
|
||||||
pub use error::RegistryError;
|
pub use error::RegistryError;
|
||||||
pub use model::{
|
pub use model::{
|
||||||
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateVersionRequest,
|
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
|
||||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata,
|
CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||||
OperationSampleMetadata, OperationSummary, OperationVersionRecord, PublishAgentRequest,
|
CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, InvitationRecord,
|
||||||
PublishRequest, PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
|
MembershipRecord, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
|
||||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
|
||||||
UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
RegistryOperation, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
||||||
YamlImportJobId, YamlImportJobStatus,
|
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest,
|
||||||
|
WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||||
};
|
};
|
||||||
pub use postgres::PostgresRegistry;
|
pub use postgres::PostgresRegistry;
|
||||||
|
|||||||
@@ -15,6 +15,127 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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(
|
query(
|
||||||
"insert into workspaces (
|
"insert into workspaces (
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crank_core::{
|
use crank_core::{
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId,
|
||||||
ExportMode, Operation, OperationId, OperationStatus, Protocol, SampleId, Workspace,
|
ExportMode, InvitationToken, MembershipRole, Operation, OperationId, OperationStatus,
|
||||||
WorkspaceId,
|
PlatformApiKey, Protocol, SampleId, User, Workspace, WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::MappingSet;
|
use crank_mapping::MappingSet;
|
||||||
use crank_schema::Schema;
|
use crank_schema::Schema;
|
||||||
@@ -46,6 +46,24 @@ pub struct WorkspaceRecord {
|
|||||||
pub workspace: Workspace,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct AgentSummary {
|
pub struct AgentSummary {
|
||||||
pub id: AgentId,
|
pub id: AgentId,
|
||||||
@@ -248,6 +266,17 @@ pub struct PublishAgentRequest<'a> {
|
|||||||
pub published_by: Option<&'a str>,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct SaveSampleMetadataRequest<'a> {
|
pub struct SaveSampleMetadataRequest<'a> {
|
||||||
pub sample: &'a OperationSampleMetadata,
|
pub sample: &'a OperationSampleMetadata,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crank_core::{
|
use crank_core::{
|
||||||
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, OperationId,
|
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, InvitationId,
|
||||||
OperationStatus, Workspace, WorkspaceId,
|
InvitationToken, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
||||||
|
PlatformApiKeyStatus, User, UserId, Workspace, WorkspaceId,
|
||||||
};
|
};
|
||||||
use serde::{Serialize, de::DeserializeOwned};
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -14,13 +15,14 @@ use crate::{
|
|||||||
error::RegistryError,
|
error::RegistryError,
|
||||||
migrations,
|
migrations,
|
||||||
model::{
|
model::{
|
||||||
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateVersionRequest,
|
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
|
||||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata,
|
CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||||
OperationSampleMetadata, OperationSummary, OperationVersionRecord, PublishAgentRequest,
|
CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, MembershipRecord,
|
||||||
PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest,
|
OperationSampleMetadata, OperationSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
||||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation,
|
||||||
UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||||
YamlImportJobId, YamlImportJobStatus,
|
SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJob,
|
||||||
|
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,6 +64,231 @@ impl PostgresRegistry {
|
|||||||
rows.iter().map(map_workspace_record).collect()
|
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(
|
pub async fn get_workspace(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
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> {
|
fn map_agent_summary(row: &PgRow) -> Result<AgentSummary, RegistryError> {
|
||||||
Ok(AgentSummary {
|
Ok(AgentSummary {
|
||||||
id: AgentId::new(row.try_get::<String, _>("id")?),
|
id: AgentId::new(row.try_get::<String, _>("id")?),
|
||||||
|
|||||||
@@ -51,9 +51,15 @@
|
|||||||
- `GET /api/admin/workspaces/{workspace_id}`
|
- `GET /api/admin/workspaces/{workspace_id}`
|
||||||
- `PATCH /api/admin/workspaces/{workspace_id}`
|
- `PATCH /api/admin/workspaces/{workspace_id}`
|
||||||
- `GET /api/admin/workspaces/{workspace_id}/members`
|
- `GET /api/admin/workspaces/{workspace_id}/members`
|
||||||
|
- `GET /api/admin/workspaces/{workspace_id}/invitations`
|
||||||
- `POST /api/admin/workspaces/{workspace_id}/invitations`
|
- `POST /api/admin/workspaces/{workspace_id}/invitations`
|
||||||
- `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}`
|
- `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}`
|
||||||
|
|
||||||
|
Контракт:
|
||||||
|
|
||||||
|
- `POST /invitations` возвращает metadata invitation и одноразовый `invite_token`;
|
||||||
|
- `invite_token` доступен только в create-response и не возвращается повторно в list endpoints.
|
||||||
|
|
||||||
### 5.2. Operations
|
### 5.2. Operations
|
||||||
|
|
||||||
- `GET /api/admin/workspaces/{workspace_id}/operations`
|
- `GET /api/admin/workspaces/{workspace_id}/operations`
|
||||||
@@ -106,6 +112,12 @@
|
|||||||
- `POST /api/admin/workspaces/{workspace_id}/platform-api-keys/{key_id}/revoke`
|
- `POST /api/admin/workspaces/{workspace_id}/platform-api-keys/{key_id}/revoke`
|
||||||
- `DELETE /api/admin/workspaces/{workspace_id}/platform-api-keys/{key_id}`
|
- `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
|
### 5.7. Observability
|
||||||
|
|
||||||
- `GET /api/admin/workspaces/{workspace_id}/logs`
|
- `GET /api/admin/workspaces/{workspace_id}/logs`
|
||||||
|
|||||||
+43
-2
@@ -170,7 +170,48 @@
|
|||||||
- `created_at`
|
- `created_at`
|
||||||
- `last_used_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.
|
Продуктовая запись о вызове tool.
|
||||||
|
|
||||||
@@ -189,7 +230,7 @@
|
|||||||
- `response_preview`
|
- `response_preview`
|
||||||
- `created_at`
|
- `created_at`
|
||||||
|
|
||||||
### 3.9. `UsageRollup`
|
### 3.12. `UsageRollup`
|
||||||
|
|
||||||
Агрегированная статистика по периоду.
|
Агрегированная статистика по периоду.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user