Merge pull request #4 from bsodfather/feat/platform-access
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod access;
|
||||
pub mod agents;
|
||||
pub mod auth_profiles;
|
||||
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 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()
|
||||
|
||||
@@ -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!(UserId);
|
||||
define_id!(AgentId);
|
||||
define_id!(InvitationId);
|
||||
define_id!(PlatformApiKeyId);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")?),
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
Агрегированная статистика по периоду.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user