feat: add platform access foundation

This commit is contained in:
a.tolmachev
2026-03-29 23:17:05 +03:00
parent 9fb69c1571
commit 587584a8bf
19 changed files with 1058 additions and 34 deletions
+185 -6
View File
@@ -1,24 +1,28 @@
use std::path::PathBuf;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthConfig, AuthKind,
AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, GeneratedDraftStatus,
OperationId, OperationStatus, Protocol, SampleId, Samples, Target, Workspace, WorkspaceId,
WorkspaceStatus,
InvitationId, InvitationStatus, InvitationToken, MembershipRole, OperationId, OperationStatus,
PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol,
SampleId, Samples, Target, Workspace, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
use crank_registry::{
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateVersionRequest,
CreateWorkspaceRequest, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind,
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord,
MembershipRecord, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation,
SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord,
};
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
use crank_schema::Schema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::{info, instrument};
use uuid::Uuid;
@@ -134,6 +138,31 @@ pub struct PublishAgentResponse {
pub published_at: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct InvitationPayload {
pub email: String,
pub role: MembershipRole,
pub expires_at: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct CreatedInvitationResponse {
pub invitation: InvitationRecord,
pub invite_token: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct PlatformApiKeyPayload {
pub name: String,
pub scopes: Vec<PlatformApiKeyScope>,
}
#[derive(Clone, Debug, Serialize)]
pub struct CreatedPlatformApiKeyResponse {
pub api_key: PlatformApiKeyRecord,
pub secret: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GenerateDraftPayload {
#[serde(default)]
@@ -300,6 +329,138 @@ impl AdminService {
Ok(WorkspaceRecord { workspace })
}
#[instrument(skip(self))]
pub async fn list_memberships(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<MembershipRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
Ok(self.registry.list_memberships(workspace_id).await?)
}
#[instrument(skip(self))]
pub async fn list_invitations(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<InvitationRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
Ok(self.registry.list_invitations(workspace_id).await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), email = %payload.email))]
pub async fn create_invitation(
&self,
workspace_id: &WorkspaceId,
payload: InvitationPayload,
) -> Result<CreatedInvitationResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let invite_token = generate_access_secret("invite");
let invitation = InvitationRecord {
invitation: InvitationToken {
id: InvitationId::new(new_prefixed_id("inv")),
workspace_id: workspace_id.clone(),
email: payload.email,
role: payload.role,
status: InvitationStatus::Pending,
token_hash: hash_access_secret(&invite_token),
expires_at: match payload.expires_at {
Some(expires_at) => expires_at,
None => default_invitation_expiry()?,
},
created_at: now_string()?,
},
};
self.registry
.create_invitation(CreateInvitationRequest {
invitation: &invitation.invitation,
})
.await?;
Ok(CreatedInvitationResponse {
invitation,
invite_token,
})
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), invitation_id = %invitation_id.as_str()))]
pub async fn delete_invitation(
&self,
workspace_id: &WorkspaceId,
invitation_id: &InvitationId,
) -> Result<(), ApiError> {
self.registry
.delete_invitation(workspace_id, invitation_id)
.await?;
Ok(())
}
#[instrument(skip(self))]
pub async fn list_platform_api_keys(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<PlatformApiKeyRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
Ok(self.registry.list_platform_api_keys(workspace_id).await?)
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), key_name = %payload.name))]
pub async fn create_platform_api_key(
&self,
workspace_id: &WorkspaceId,
payload: PlatformApiKeyPayload,
) -> Result<CreatedPlatformApiKeyResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let secret = generate_access_secret("crk");
let api_key = PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
workspace_id: workspace_id.clone(),
name: payload.name,
prefix: secret.chars().take(16).collect(),
scopes: payload.scopes,
status: PlatformApiKeyStatus::Active,
created_at: now_string()?,
last_used_at: None,
},
};
self.registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &api_key.api_key,
secret_hash: &hash_access_secret(&secret),
})
.await?;
Ok(CreatedPlatformApiKeyResponse { api_key, secret })
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))]
pub async fn revoke_platform_api_key(
&self,
workspace_id: &WorkspaceId,
key_id: &PlatformApiKeyId,
) -> Result<(), ApiError> {
self.registry
.revoke_platform_api_key(workspace_id, key_id, &now_string()?)
.await?;
Ok(())
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), key_id = %key_id.as_str()))]
pub async fn delete_platform_api_key(
&self,
workspace_id: &WorkspaceId,
key_id: &PlatformApiKeyId,
) -> Result<(), ApiError> {
self.registry
.delete_platform_api_key(workspace_id, key_id)
.await?;
Ok(())
}
pub async fn list_operations(
&self,
workspace_id: &WorkspaceId,
@@ -1240,12 +1401,30 @@ fn new_prefixed_id(prefix: &str) -> String {
format!("{prefix}_{}", Uuid::now_v7().simple())
}
fn generate_access_secret(prefix: &str) -> String {
let random = URL_SAFE_NO_PAD.encode(Uuid::now_v7().as_bytes());
format!("{prefix}_{random}")
}
fn hash_access_secret(secret: &str) -> String {
let digest = Sha256::digest(secret.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
fn now_string() -> Result<String, ApiError> {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))
}
fn default_invitation_expiry() -> Result<String, ApiError> {
OffsetDateTime::now_utc()
.checked_add(time::Duration::days(7))
.ok_or_else(|| ApiError::internal("failed to compute invitation expiry"))?
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))
}
fn proto_service_summary(service: ProtoService) -> Result<GrpcServiceSummary, ApiError> {
let methods = service
.unary_methods()