feat: add secret store foundation
This commit is contained in:
@@ -47,3 +47,4 @@ define_id!(AgentId);
|
||||
define_id!(InvitationId);
|
||||
define_id!(PlatformApiKeyId);
|
||||
define_id!(InvocationLogId);
|
||||
define_id!(SecretId);
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod ids;
|
||||
pub mod observability;
|
||||
pub mod operation;
|
||||
pub mod protocol;
|
||||
pub mod secret;
|
||||
pub mod workspace;
|
||||
|
||||
pub use access::{
|
||||
@@ -18,7 +19,7 @@ pub use auth::{
|
||||
};
|
||||
pub use ids::{
|
||||
AgentId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId,
|
||||
PlatformApiKeyId, SampleId, ToolId, UserId, UserSessionId, WorkspaceId,
|
||||
PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId, WorkspaceId,
|
||||
};
|
||||
pub use observability::{
|
||||
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
|
||||
@@ -29,4 +30,5 @@ pub use operation::{
|
||||
RetryPolicy, Samples, Target, ToolDescription, ToolExample,
|
||||
};
|
||||
pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol};
|
||||
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
pub use workspace::{Workspace, WorkspaceStatus};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::{SecretId, UserId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SecretKind {
|
||||
Token,
|
||||
UsernamePassword,
|
||||
Header,
|
||||
Generic,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SecretStatus {
|
||||
Active,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Secret {
|
||||
pub id: SecretId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub name: String,
|
||||
pub kind: SecretKind,
|
||||
pub status: SecretStatus,
|
||||
pub current_version: u32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SecretVersion {
|
||||
pub secret_id: SecretId,
|
||||
pub version: u32,
|
||||
pub ciphertext: String,
|
||||
pub key_version: String,
|
||||
pub created_at: String,
|
||||
pub created_by: Option<UserId>,
|
||||
}
|
||||
@@ -23,6 +23,10 @@ pub enum RegistryError {
|
||||
InvitationNotFound { invitation_id: String },
|
||||
#[error("platform api key {key_id} was not found")]
|
||||
PlatformApiKeyNotFound { key_id: String },
|
||||
#[error("secret {secret_id} was not found")]
|
||||
SecretNotFound { secret_id: String },
|
||||
#[error("secret with name {name} already exists in workspace {workspace_id}")]
|
||||
SecretNameAlreadyExists { workspace_id: String, name: String },
|
||||
#[error("invocation log {log_id} was not found")]
|
||||
InvocationLogNotFound { log_id: String },
|
||||
#[error("agent {agent_id} was not found")]
|
||||
|
||||
@@ -6,16 +6,16 @@ mod postgres;
|
||||
pub use error::RegistryError;
|
||||
pub use model::{
|
||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata,
|
||||
InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord,
|
||||
OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary,
|
||||
OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest,
|
||||
PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
|
||||
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
||||
YamlImportJobId, YamlImportJobStatus,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
|
||||
DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
|
||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
||||
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest,
|
||||
PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
|
||||
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
||||
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||
WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
};
|
||||
pub use postgres::PostgresRegistry;
|
||||
|
||||
@@ -328,6 +328,42 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists secrets (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
name text not null,
|
||||
kind text not null,
|
||||
status text not null,
|
||||
current_version integer not null,
|
||||
last_used_at timestamptz null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists secrets_workspace_name_idx on secrets(workspace_id, name)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists secret_versions (
|
||||
secret_id text not null references secrets(id) on delete cascade,
|
||||
version integer not null,
|
||||
ciphertext text not null,
|
||||
key_version text not null,
|
||||
created_at timestamptz not null,
|
||||
created_by text null references users(id) on delete set null,
|
||||
primary key (secret_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists yaml_import_jobs (
|
||||
id text primary key,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId,
|
||||
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole,
|
||||
Operation, OperationId, OperationStatus, PlatformApiKey, Protocol, SampleId, UsagePeriod,
|
||||
UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
|
||||
Operation, OperationId, OperationStatus, PlatformApiKey, Protocol, SampleId, Secret, SecretId,
|
||||
SecretVersion, UsagePeriod, UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_schema::Schema;
|
||||
@@ -85,6 +85,16 @@ pub struct PlatformApiKeyRecord {
|
||||
pub api_key: PlatformApiKey,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SecretRecord {
|
||||
pub secret: Secret,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SecretVersionRecord {
|
||||
pub secret_version: SecretVersion,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InvocationLogRecord {
|
||||
pub log: InvocationLog,
|
||||
@@ -403,6 +413,25 @@ pub struct CreatePlatformApiKeyRequest<'a> {
|
||||
pub secret_hash: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CreateSecretRequest<'a> {
|
||||
pub secret: &'a Secret,
|
||||
pub ciphertext: &'a str,
|
||||
pub key_version: &'a str,
|
||||
pub created_by: Option<&'a crank_core::UserId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RotateSecretRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub secret_id: &'a SecretId,
|
||||
pub ciphertext: &'a str,
|
||||
pub key_version: &'a str,
|
||||
pub created_at: &'a str,
|
||||
pub updated_at: &'a str,
|
||||
pub created_by: Option<&'a crank_core::UserId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SaveSampleMetadataRequest<'a> {
|
||||
pub sample: &'a OperationSampleMetadata,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crank_core::{
|
||||
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, GraphqlOperationType,
|
||||
HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole,
|
||||
OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Target,
|
||||
UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId,
|
||||
OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Secret,
|
||||
SecretId, SecretVersion, Target, UsageRollup, User, UserId, UserSessionId, Workspace,
|
||||
WorkspaceId,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde_json::Value;
|
||||
@@ -18,16 +19,17 @@ use crate::{
|
||||
model::{
|
||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest,
|
||||
CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
|
||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
||||
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest,
|
||||
PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown,
|
||||
UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||
WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||
CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, InvocationLogRecord,
|
||||
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
||||
PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation,
|
||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
|
||||
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SecretRecord,
|
||||
SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
||||
YamlImportJobId, YamlImportJobStatus,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -652,6 +654,212 @@ impl PostgresRegistry {
|
||||
row.as_ref().map(map_platform_api_key_record).transpose()
|
||||
}
|
||||
|
||||
pub async fn list_secrets(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<Vec<SecretRecord>, RegistryError> {
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
kind,
|
||||
status,
|
||||
current_version,
|
||||
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
||||
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
|
||||
to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at
|
||||
from secrets
|
||||
where workspace_id = $1
|
||||
order by name asc",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.iter().map(map_secret_record).collect()
|
||||
}
|
||||
|
||||
pub async fn get_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<Option<SecretRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
kind,
|
||||
status,
|
||||
current_version,
|
||||
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
|
||||
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
|
||||
to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.as_ref().map(map_secret_record).transpose()
|
||||
}
|
||||
|
||||
pub async fn create_secret(
|
||||
&self,
|
||||
request: CreateSecretRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"insert into secrets (
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
kind,
|
||||
status,
|
||||
current_version,
|
||||
last_used_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz, $9::timestamptz
|
||||
)",
|
||||
)
|
||||
.bind(request.secret.id.as_str())
|
||||
.bind(request.secret.workspace_id.as_str())
|
||||
.bind(&request.secret.name)
|
||||
.bind(serialize_enum_text(&request.secret.kind, "kind")?)
|
||||
.bind(serialize_enum_text(&request.secret.status, "status")?)
|
||||
.bind(to_db_version(request.secret.current_version))
|
||||
.bind(request.secret.last_used_at.as_deref())
|
||||
.bind(&request.secret.created_at)
|
||||
.bind(&request.secret.updated_at)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
sqlx::query(
|
||||
"insert into secret_versions (
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
created_at,
|
||||
created_by
|
||||
) values (
|
||||
$1, $2, $3, $4, $5::timestamptz, $6
|
||||
)",
|
||||
)
|
||||
.bind(request.secret.id.as_str())
|
||||
.bind(to_db_version(request.secret.current_version))
|
||||
.bind(request.ciphertext)
|
||||
.bind(request.key_version)
|
||||
.bind(&request.secret.created_at)
|
||||
.bind(request.created_by.map(|value| value.as_str()))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(sqlx::Error::Database(error))
|
||||
if error.constraint() == Some("secrets_workspace_name_idx") =>
|
||||
{
|
||||
Err(RegistryError::SecretNameAlreadyExists {
|
||||
workspace_id: request.secret.workspace_id.as_str().to_owned(),
|
||||
name: request.secret.name.clone(),
|
||||
})
|
||||
}
|
||||
Err(error) => Err(RegistryError::Storage(error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rotate_secret(
|
||||
&self,
|
||||
request: RotateSecretRequest<'_>,
|
||||
) -> Result<SecretVersionRecord, RegistryError> {
|
||||
let existing = self
|
||||
.get_secret(request.workspace_id, request.secret_id)
|
||||
.await?
|
||||
.ok_or_else(|| RegistryError::SecretNotFound {
|
||||
secret_id: request.secret_id.as_str().to_owned(),
|
||||
})?;
|
||||
let next_version = existing.secret.current_version + 1;
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query(
|
||||
"insert into secret_versions (
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
created_at,
|
||||
created_by
|
||||
) values (
|
||||
$1, $2, $3, $4, $5::timestamptz, $6
|
||||
)",
|
||||
)
|
||||
.bind(request.secret_id.as_str())
|
||||
.bind(to_db_version(next_version))
|
||||
.bind(request.ciphertext)
|
||||
.bind(request.key_version)
|
||||
.bind(request.created_at)
|
||||
.bind(request.created_by.map(|value| value.as_str()))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"update secrets
|
||||
set current_version = $3,
|
||||
updated_at = $4::timestamptz
|
||||
where workspace_id = $1 and id = $2",
|
||||
)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(request.secret_id.as_str())
|
||||
.bind(to_db_version(next_version))
|
||||
.bind(request.updated_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(SecretVersionRecord {
|
||||
secret_version: SecretVersion {
|
||||
secret_id: request.secret_id.clone(),
|
||||
version: next_version,
|
||||
ciphertext: request.ciphertext.to_owned(),
|
||||
key_version: request.key_version.to_owned(),
|
||||
created_at: request.created_at.to_owned(),
|
||||
created_by: request.created_by.cloned(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"delete from secrets
|
||||
where workspace_id = $1 and id = $2",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::SecretNotFound {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_platform_api_key(
|
||||
&self,
|
||||
request: CreatePlatformApiKeyRequest<'_>,
|
||||
@@ -3088,6 +3296,25 @@ fn map_platform_api_key_record(row: &PgRow) -> Result<PlatformApiKeyRecord, Regi
|
||||
})
|
||||
}
|
||||
|
||||
fn map_secret_record(row: &PgRow) -> Result<SecretRecord, RegistryError> {
|
||||
Ok(SecretRecord {
|
||||
secret: Secret {
|
||||
id: SecretId::new(row.try_get::<String, _>("id")?),
|
||||
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
||||
name: row.try_get("name")?,
|
||||
kind: deserialize_enum_text(&row.try_get::<String, _>("kind")?, "kind")?,
|
||||
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
||||
current_version: from_db_version(
|
||||
row.try_get::<i32, _>("current_version")?,
|
||||
"current_version",
|
||||
)?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
last_used_at: row.try_get("last_used_at")?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn map_invocation_log_record(row: &PgRow) -> Result<InvocationLogRecord, RegistryError> {
|
||||
Ok(InvocationLogRecord {
|
||||
log: InvocationLog {
|
||||
|
||||
Reference in New Issue
Block a user