feat: complete Epic 1 production foundation
This commit is contained in:
@@ -16,6 +16,16 @@ pub enum RegistryError {
|
||||
UserNotFound { user_id: String },
|
||||
#[error("user with email {email} already exists")]
|
||||
UserEmailAlreadyExists { email: String },
|
||||
#[error("admin bootstrap contract is not available")]
|
||||
AdminBootstrapUnavailable,
|
||||
#[error("admin bootstrap contract is invalid, expired or already used")]
|
||||
AdminBootstrapRejected,
|
||||
#[error("admin recovery request is invalid or unavailable")]
|
||||
AdminRecoveryRejected,
|
||||
#[error("admin login is temporarily rate limited")]
|
||||
AdminLoginRateLimited { retry_after_ms: i64 },
|
||||
#[error("admin csrf token is invalid or missing")]
|
||||
AdminCsrfRejected,
|
||||
#[error("membership for user {user_id} in workspace {workspace_id} was not found")]
|
||||
MembershipNotFound {
|
||||
workspace_id: String,
|
||||
@@ -25,8 +35,28 @@ pub enum RegistryError {
|
||||
InvitationNotFound { invitation_id: String },
|
||||
#[error("platform api key {key_id} was not found")]
|
||||
PlatformApiKeyNotFound { key_id: String },
|
||||
#[error("platform api key {key_id} is not active")]
|
||||
PlatformApiKeyInactive { key_id: String },
|
||||
#[error("platform api key with name {name} already exists in workspace {workspace_id}")]
|
||||
PlatformApiKeyNameAlreadyExists { workspace_id: String, name: String },
|
||||
#[error("secret {secret_id} was not found")]
|
||||
SecretNotFound { secret_id: String },
|
||||
#[error("secret {secret_id} is not active")]
|
||||
SecretInactive { secret_id: String },
|
||||
#[error("secret {secret_id} was updated concurrently")]
|
||||
SecretConcurrentUpdate { secret_id: String },
|
||||
#[error("master key identity is not compatible with registered epoch {epoch}")]
|
||||
MasterKeyIdentityMismatch { epoch: i64 },
|
||||
#[error("master key identity metadata is invalid")]
|
||||
InvalidMasterKeyIdentity,
|
||||
#[error("master key rotation is already active")]
|
||||
MasterKeyRotationInProgress,
|
||||
#[error("master key rotation {rotation_id} was not found")]
|
||||
MasterKeyRotationNotFound { rotation_id: String },
|
||||
#[error("master key rotation state is incompatible with requested transition")]
|
||||
MasterKeyRotationConflict,
|
||||
#[error("master key rotation verification failed")]
|
||||
MasterKeyRotationVerificationFailed,
|
||||
#[error("secret with name {name} already exists in workspace {workspace_id}")]
|
||||
SecretNameAlreadyExists { workspace_id: String, name: String },
|
||||
#[error("secret {secret_id} is referenced by auth profile {auth_profile_id}")]
|
||||
@@ -59,12 +89,40 @@ pub enum RegistryError {
|
||||
expected: u32,
|
||||
actual: u32,
|
||||
},
|
||||
#[error("operation {operation_id} is archived")]
|
||||
OperationArchived { operation_id: String },
|
||||
#[error("operation {operation_id} has a stale base version: expected {expected}, got {actual}")]
|
||||
OperationStaleVersion {
|
||||
operation_id: String,
|
||||
expected: u32,
|
||||
actual: u32,
|
||||
},
|
||||
#[error("operation {operation_id} cannot transition from {from} using {action}")]
|
||||
InvalidOperationTransition {
|
||||
operation_id: String,
|
||||
from: String,
|
||||
action: &'static str,
|
||||
},
|
||||
#[error("operation {operation_id} cannot be deleted because durable history exists")]
|
||||
OperationDeleteForbidden { operation_id: String },
|
||||
#[error("operation {operation_id} auth profile reference is unavailable")]
|
||||
OperationAuthProfileUnavailable { operation_id: String },
|
||||
#[error("agent {agent_id} expected next version {expected}, got {actual}")]
|
||||
InvalidAgentVersionSequence {
|
||||
agent_id: String,
|
||||
expected: u32,
|
||||
actual: u32,
|
||||
},
|
||||
#[error("agent version {version} for {agent_id} is immutable")]
|
||||
ImmutableAgentVersion { agent_id: String, version: u32 },
|
||||
#[error("agent {agent_id} state changed before mutation")]
|
||||
AgentStaleRevision { agent_id: String },
|
||||
#[error("agent {agent_id} cannot transition from {from} using {action}")]
|
||||
InvalidAgentTransition {
|
||||
agent_id: String,
|
||||
from: String,
|
||||
action: &'static str,
|
||||
},
|
||||
#[error("operation {operation_id} changed immutable field {field}")]
|
||||
ImmutableOperationFieldChanged {
|
||||
operation_id: String,
|
||||
@@ -84,4 +142,10 @@ pub enum RegistryError {
|
||||
InvalidNumericValue { field: &'static str, value: i64 },
|
||||
#[error("invalid correlation identity for field {field}")]
|
||||
InvalidCorrelationIdentity { field: &'static str },
|
||||
#[error("new invocation record is missing or has incompatible execution field {field}")]
|
||||
InvalidExecutionRecord { field: &'static str },
|
||||
#[error("onboarding state changed before presentation milestone mutation")]
|
||||
OnboardingStaleRevision,
|
||||
#[error("onboarding domain steps are not complete")]
|
||||
OnboardingIncomplete,
|
||||
}
|
||||
|
||||
@@ -13,32 +13,41 @@ pub use migrations::{
|
||||
|
||||
pub mod records {
|
||||
pub use crate::model::{
|
||||
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApprovalRequestRecord,
|
||||
AuthUserRecord, DescriptorKind, DescriptorMetadata, ImportJob, ImportJobApplyResult,
|
||||
ImportJobId, ImportJobKind, ImportJobStatus, InvitationRecord, InvocationHistoryLoss,
|
||||
InvocationHistoryLossCategory, InvocationHistoryWriteOutcome, InvocationLogRecord,
|
||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
||||
OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord,
|
||||
PublishedAgentCatalog, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord,
|
||||
SecretVersionRecord, SessionRecord, SkippedImportOperation, UsageAgentBreakdown,
|
||||
UsageBucket, UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
AdminBootstrapContractRecord, AgentSummary, AgentVersionRecord, AppendProductEventOutcome,
|
||||
AppliedImportOperation, ApprovalRequestRecord, AuthUserRecord, DescriptorKind,
|
||||
DescriptorMetadata, ImportJob, ImportJobApplyResult, ImportJobId, ImportJobKind,
|
||||
ImportJobStatus, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
|
||||
InvocationRetentionPolicy, InvocationRetentionStatus, MasterKeyIdentityRecord,
|
||||
MasterKeyRotationRecord, MasterKeyRotationStatus, MembershipRecord,
|
||||
OnboardingMilestoneResult, OnboardingPresentationMilestone, OperationAgentRef,
|
||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||
Page, PlatformApiKeyRecord, ProductEventRecord, PublishedAgentCatalog, PublishedAgentTool,
|
||||
RegistryOperation, SampleKind, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||
SkippedImportOperation, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown,
|
||||
UsageOutcomeBreakdown, UsageOutcomeGroup, UsageRollupRecord, UsageSummary,
|
||||
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
|
||||
WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod requests {
|
||||
pub use crate::model::{
|
||||
ApplyImportJobRequest, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DecideApprovalRequest, ExpireApprovalRequest, FinishApprovalRequest,
|
||||
FinishImportJobRequest, ImportConflictMode, ImportOperationDraft,
|
||||
ListApprovalRequestsQuery, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
|
||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery,
|
||||
AdminSecurityAuditRequest, AppendProductEventRequest, ApplyImportJobRequest,
|
||||
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode,
|
||||
ImportOperationDraft, ListApprovalRequestsQuery, ListInvocationLogsQuery,
|
||||
ListProductEventsQuery, MasterKeyIdentityCandidate, PublishAgentRequest, PublishRequest,
|
||||
RecordOnboardingCompletionRequest, RecordOnboardingMilestoneRequest,
|
||||
RecoverAdminPasswordRequest, RotateSecretRequest, SaveAgentBindingsRequest,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, UpdateAgentSummaryRequest,
|
||||
UpdateWorkspaceRequest, UsageQuery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,26 +61,34 @@ pub mod infrastructure {
|
||||
}
|
||||
|
||||
pub use model::{
|
||||
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApplyImportJobRequest,
|
||||
ApprovalRequestRecord, AuthUserRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DecideApprovalRequest, DescriptorKind, DescriptorMetadata, ExpireApprovalRequest,
|
||||
FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode, ImportJob,
|
||||
ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobStatus, ImportOperationDraft,
|
||||
InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord,
|
||||
PublishAgentRequest, PublishRequest, PublishedAgentCatalog, PublishedAgentTool,
|
||||
RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
||||
SessionRecord, SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageBucket, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
|
||||
UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream,
|
||||
WorkspaceUpstreamId, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
AdminBootstrapContractRecord, AdminSecurityAuditRequest, AgentStateExpectation, AgentSummary,
|
||||
AgentVersionRecord, AppendProductEventOutcome, AppendProductEventRequest,
|
||||
AppliedImportOperation, ApplyImportJobRequest, ApprovalRequestRecord, AuthUserRecord,
|
||||
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
|
||||
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode,
|
||||
ImportJob, ImportJobApplyResult, ImportJobId, ImportJobKind, ImportJobStatus,
|
||||
ImportOperationDraft, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
|
||||
InvocationRetentionPolicy, InvocationRetentionStatus, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, ListProductEventsQuery, MASTER_KEY_CIPHER_CONTRACT,
|
||||
MasterKeyIdentityCandidate, MasterKeyIdentityRecord, MasterKeyRotationRecord,
|
||||
MasterKeyRotationStatus, MembershipRecord, OnboardingMilestoneResult,
|
||||
OnboardingPresentationMilestone, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationStateExpectation, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||
Page, PlatformApiKeyRecord, ProductEventRecord, PublishAgentRequest, PublishRequest,
|
||||
PublishedAgentCatalog, PublishedAgentTool, RecordOnboardingCompletionRequest,
|
||||
RecordOnboardingMilestoneRequest, RecoverAdminPasswordRequest, RegistryOperation,
|
||||
RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||
SkippedImportOperation, UpdateAgentSummaryRequest, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageBucket, UsageOperationBreakdown, UsageOutcomeBreakdown, UsageOutcomeGroup, UsageQuery,
|
||||
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||
WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob,
|
||||
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
};
|
||||
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
mod admin_auth_lifecycle_v8;
|
||||
mod agent_catalog_lifecycle_v9;
|
||||
mod approval_side_effects_v10;
|
||||
mod authority;
|
||||
mod baseline_v1;
|
||||
mod execution_outcome_v5;
|
||||
mod master_key_identity_v7;
|
||||
mod onboarding_product_events_v11;
|
||||
mod owned_relations;
|
||||
mod platform_key_name_reuse_v6;
|
||||
mod schema_guard;
|
||||
mod schema_guard_v10;
|
||||
mod schema_guard_v11;
|
||||
mod schema_guard_v7;
|
||||
mod schema_guard_v8;
|
||||
mod schema_guard_v9;
|
||||
|
||||
pub use authority::{
|
||||
BackfillBatch, BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationDescriptor,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Postgres, Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("admin_auth_lifecycle_v8.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"6361f3321a1442a77c695cad9b30c4702de1aa3e565bfbf1a7415d653302611f";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.admin_auth_lifecycle",
|
||||
Some(8),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(8),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
create table admin_bootstrap_contracts (
|
||||
id text primary key,
|
||||
token_hash text not null unique,
|
||||
email text not null,
|
||||
display_name text not null,
|
||||
status text not null,
|
||||
attempts integer not null default 0,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null default now(),
|
||||
used_at timestamptz,
|
||||
used_by_user_id text references users(id),
|
||||
constraint admin_bootstrap_contracts_id_check check (
|
||||
octet_length(id) between 1 and 128
|
||||
and id !~ '[[:cntrl:]]'
|
||||
),
|
||||
constraint admin_bootstrap_contracts_token_hash_check check (
|
||||
token_hash ~ '^[A-Za-z0-9_-]{43,86}$'
|
||||
),
|
||||
constraint admin_bootstrap_contracts_email_check check (
|
||||
octet_length(email) between 3 and 254
|
||||
and email like '%@%'
|
||||
and email !~ '[[:space:][:cntrl:]<>]'
|
||||
),
|
||||
constraint admin_bootstrap_contracts_display_name_check check (
|
||||
octet_length(display_name) between 1 and 160
|
||||
and display_name !~ '[[:cntrl:]<>]'
|
||||
),
|
||||
constraint admin_bootstrap_contracts_status_check check (
|
||||
status in ('active', 'used', 'expired', 'revoked')
|
||||
),
|
||||
constraint admin_bootstrap_contracts_attempts_check check (
|
||||
attempts between 0 and 100
|
||||
),
|
||||
constraint admin_bootstrap_contracts_used_shape_check check (
|
||||
(status = 'used' and used_at is not null and used_by_user_id is not null)
|
||||
or (status <> 'used' and used_by_user_id is null)
|
||||
)
|
||||
);
|
||||
|
||||
create unique index admin_bootstrap_contracts_single_active_idx
|
||||
on admin_bootstrap_contracts (status)
|
||||
where status = 'active';
|
||||
|
||||
create index admin_bootstrap_contracts_token_hash_idx
|
||||
on admin_bootstrap_contracts (token_hash);
|
||||
|
||||
alter table user_sessions
|
||||
add column csrf_hash text,
|
||||
add column revoked_at timestamptz,
|
||||
add constraint user_sessions_csrf_hash_check check (
|
||||
csrf_hash is null or csrf_hash ~ '^[A-Za-z0-9_-]{43,86}$'
|
||||
);
|
||||
|
||||
create table admin_login_backoff (
|
||||
scope_hash text primary key,
|
||||
failure_count integer not null default 0,
|
||||
locked_until timestamptz,
|
||||
last_attempt_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint admin_login_backoff_scope_hash_check check (
|
||||
scope_hash ~ '^[A-Za-z0-9_-]{43,86}$'
|
||||
),
|
||||
constraint admin_login_backoff_failure_count_check check (
|
||||
failure_count between 0 and 1000
|
||||
)
|
||||
);
|
||||
|
||||
create table admin_security_audit_events (
|
||||
id text primary key,
|
||||
action text not null,
|
||||
outcome text not null,
|
||||
actor_user_id text references users(id),
|
||||
session_id text,
|
||||
request_id text,
|
||||
trace_id text,
|
||||
source text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint admin_security_audit_events_id_check check (
|
||||
octet_length(id) between 1 and 128
|
||||
and id !~ '[[:cntrl:]]'
|
||||
),
|
||||
constraint admin_security_audit_events_action_check check (
|
||||
action in (
|
||||
'bootstrap_created',
|
||||
'bootstrap_completed',
|
||||
'bootstrap_rejected',
|
||||
'login_succeeded',
|
||||
'login_rejected',
|
||||
'logout',
|
||||
'password_rotated',
|
||||
'recovery_completed',
|
||||
'session_revoked'
|
||||
)
|
||||
),
|
||||
constraint admin_security_audit_events_outcome_check check (
|
||||
outcome in ('success', 'rejected', 'rate_limited', 'expired', 'conflict')
|
||||
),
|
||||
constraint admin_security_audit_events_session_id_check check (
|
||||
session_id is null
|
||||
or (
|
||||
octet_length(session_id) between 1 and 128
|
||||
and session_id !~ '[[:cntrl:]]'
|
||||
)
|
||||
),
|
||||
constraint admin_security_audit_events_request_id_check check (
|
||||
request_id is null
|
||||
or (
|
||||
octet_length(request_id) between 1 and 128
|
||||
and request_id !~ '[[:cntrl:],;]'
|
||||
)
|
||||
),
|
||||
constraint admin_security_audit_events_trace_id_check check (
|
||||
trace_id is null or trace_id ~ '^[0-9a-f]{32}$'
|
||||
),
|
||||
constraint admin_security_audit_events_source_check check (
|
||||
octet_length(source) between 1 and 128
|
||||
and source !~ '[[:cntrl:]]'
|
||||
)
|
||||
);
|
||||
|
||||
create index admin_security_audit_events_created_idx
|
||||
on admin_security_audit_events (created_at desc);
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Postgres, Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("agent_catalog_lifecycle_v9.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"b49bf4b53691407ec27e3810b0531c0d440da7dddc62ea71eb219933c0a30211";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.agent_catalog_lifecycle",
|
||||
Some(9),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(9),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
alter table agents
|
||||
add column catalog_revision bigint not null default 0,
|
||||
add constraint agents_catalog_revision_check check (catalog_revision >= 0);
|
||||
|
||||
alter table published_agents
|
||||
add column catalog_revision bigint not null default 1,
|
||||
add constraint published_agents_catalog_revision_check check (catalog_revision > 0);
|
||||
|
||||
update agents a
|
||||
set catalog_revision = case when pa.agent_id is null then 0 else greatest(pa.catalog_revision, 1) end
|
||||
from agents source
|
||||
left join published_agents pa on pa.agent_id = source.id
|
||||
where a.id = source.id;
|
||||
|
||||
create function crank_reject_published_agent_version_mutation()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if old.status = 'published' then
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'published Agent Version is immutable'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
if row(old.*) is distinct from row(new.*) then
|
||||
raise exception 'published Agent Version is immutable'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
end if;
|
||||
if tg_op = 'DELETE' then
|
||||
return old;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger agent_versions_immutable_guard
|
||||
before update or delete on agent_versions
|
||||
for each row
|
||||
execute function crank_reject_published_agent_version_mutation();
|
||||
|
||||
create function crank_reject_published_agent_binding_mutation()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
bound_status text;
|
||||
checked_agent_id text;
|
||||
checked_agent_version integer;
|
||||
begin
|
||||
checked_agent_id := coalesce(new.agent_id, old.agent_id);
|
||||
checked_agent_version := coalesce(new.agent_version, old.agent_version);
|
||||
select status
|
||||
into bound_status
|
||||
from agent_versions
|
||||
where agent_id = checked_agent_id and version = checked_agent_version;
|
||||
if bound_status = 'published' then
|
||||
raise exception 'published Agent catalog bindings are immutable'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
if tg_op = 'DELETE' then
|
||||
return old;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger agent_operation_bindings_immutable_guard
|
||||
before insert or update or delete on agent_operation_bindings
|
||||
for each row
|
||||
execute function crank_reject_published_agent_binding_mutation();
|
||||
|
||||
create function crank_reject_published_agent_pointer_rewind()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'published Agent catalog pointer is immutable'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
if tg_op = 'INSERT' then
|
||||
return new;
|
||||
end if;
|
||||
if new.catalog_revision <= old.catalog_revision then
|
||||
raise exception 'published Agent catalog revision must increase'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
if new.version < old.version then
|
||||
raise exception 'published Agent catalog version cannot rewind'
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger published_agents_monotonic_guard
|
||||
before insert or update or delete on published_agents
|
||||
for each row
|
||||
execute function crank_reject_published_agent_pointer_rewind();
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Postgres, Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("approval_side_effects_v10.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"6c87f680efdbc275fa2b920826b3e3e390aa34d6f2f1db48fe074a5d7691ba5b";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.approval_side_effects",
|
||||
Some(10),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(10),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
alter table approval_requests
|
||||
add column if not exists request_id text null;
|
||||
|
||||
alter table approval_requests
|
||||
add column if not exists trace_id text null;
|
||||
|
||||
alter table approval_requests
|
||||
add constraint approval_requests_request_id_check check (
|
||||
request_id is null
|
||||
or (
|
||||
octet_length(request_id) between 1 and 128
|
||||
and request_id !~ '[[:cntrl:],;]'
|
||||
)
|
||||
) not valid;
|
||||
|
||||
alter table approval_requests
|
||||
add constraint approval_requests_trace_id_check check (
|
||||
trace_id is null
|
||||
or (
|
||||
trace_id ~ '^[0-9a-f]{32}$'
|
||||
and trace_id <> '00000000000000000000000000000000'
|
||||
)
|
||||
) not valid;
|
||||
|
||||
create unique index if not exists approval_requests_pending_scope_fingerprint_idx
|
||||
on approval_requests(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
operation_id,
|
||||
operation_version,
|
||||
request_fingerprint
|
||||
)
|
||||
where status = 'pending' and request_fingerprint is not null;
|
||||
|
||||
create index if not exists approval_requests_workspace_request_trace_idx
|
||||
on approval_requests(workspace_id, request_id, trace_id)
|
||||
where request_id is not null or trace_id is not null;
|
||||
@@ -1,17 +1,22 @@
|
||||
use std::fmt;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
|
||||
|
||||
use super::admin_auth_lifecycle_v8;
|
||||
use super::agent_catalog_lifecycle_v9;
|
||||
use super::approval_side_effects_v10;
|
||||
use super::execution_outcome_v5;
|
||||
use super::master_key_identity_v7;
|
||||
use super::onboarding_product_events_v11;
|
||||
use super::owned_relations;
|
||||
use super::platform_key_name_reuse_v6;
|
||||
use super::schema_guard::{
|
||||
OWNED_RELATIONS, relation_exists, validate_required_relations, validate_schema_fingerprint,
|
||||
};
|
||||
use super::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline};
|
||||
use crate::ext::ExtensionMigration;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
|
||||
use std::fmt;
|
||||
const MIGRATION_LOCK_ID: i64 = 0x4352_414E_4B4D_4947;
|
||||
const CURRENT_VERSION: i64 = 3;
|
||||
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3];
|
||||
const CURRENT_VERSION: i64 = 11;
|
||||
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const BASELINE_SOURCE_SHA256: &str =
|
||||
"eb1656fc5b4b5be9ee390d237d1d58e4b2274ae5ba9b7ba06a2f3f860dfda675";
|
||||
const CONSOLIDATION_SOURCE: &str = include_str!("consolidation_v2.sql");
|
||||
@@ -20,41 +25,10 @@ const CONSOLIDATION_SOURCE_SHA256: &str =
|
||||
const REQUEST_TRACE_IDENTITY_SOURCE: &str = include_str!("request_trace_identity_v3.sql");
|
||||
const REQUEST_TRACE_IDENTITY_SOURCE_SHA256: &str =
|
||||
"36487625503a8d4d8f18d5771c3c9b6705f845e267acbfcb7244c330f640cd94";
|
||||
const BASELINE_RELATIONS: &[&str] = &[
|
||||
"workspaces",
|
||||
"users",
|
||||
"memberships",
|
||||
"user_sessions",
|
||||
"invitation_tokens",
|
||||
"platform_api_keys",
|
||||
"operations",
|
||||
"operation_versions",
|
||||
"published_operations",
|
||||
"operation_samples",
|
||||
"descriptors",
|
||||
"agents",
|
||||
"agent_versions",
|
||||
"published_agents",
|
||||
"agent_operation_bindings",
|
||||
"secrets",
|
||||
"secret_versions",
|
||||
"auth_profiles",
|
||||
"workspace_upstreams",
|
||||
"yaml_import_jobs",
|
||||
"import_jobs",
|
||||
"approval_requests",
|
||||
"invocation_logs",
|
||||
"usage_rollups",
|
||||
];
|
||||
const CONSOLIDATION_RELATIONS: &[&str] = &[
|
||||
"__crank_migrations",
|
||||
"__crank_migration_legacy_audit",
|
||||
"__crank_mcp_migrations",
|
||||
"mcp_transport_sessions",
|
||||
"__crank_ext_migrations",
|
||||
];
|
||||
const OPERATION_LIFECYCLE_SOURCE: &str = include_str!("operation_lifecycle_v4.sql");
|
||||
const OPERATION_LIFECYCLE_SOURCE_SHA256: &str =
|
||||
"45723712a1ea49cd8bbf59d77148c225f3ec376df7433983d982cc6f9d7fb39c";
|
||||
const REGISTERED_EXTENSION_MIGRATIONS: &[(&str, ExtensionMigration)] = &[];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MigrationDescriptor {
|
||||
pub version: i64,
|
||||
@@ -70,7 +44,6 @@ pub struct MigrationDescriptor {
|
||||
pub readable_schema_max: i64,
|
||||
pub contract_evidence: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BackfillPolicy {
|
||||
None,
|
||||
@@ -80,14 +53,12 @@ pub enum BackfillPolicy {
|
||||
resumable: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BackfillBatch {
|
||||
pub cursor: Option<String>,
|
||||
pub max_rows: u32,
|
||||
pub max_ms: u32,
|
||||
}
|
||||
|
||||
impl BackfillBatch {
|
||||
pub fn validate(&self, policy: BackfillPolicy) -> Result<(), MigrationError> {
|
||||
match policy {
|
||||
@@ -113,19 +84,16 @@ impl BackfillBatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MigrationPreflight {
|
||||
Current { version: i64 },
|
||||
MigrationRequired { current: i64, target: i64 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MigrationApplyResult {
|
||||
Applied { from: i64, to: i64 },
|
||||
AlreadyCurrent { version: i64 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MigrationError {
|
||||
code: &'static str,
|
||||
@@ -133,7 +101,6 @@ pub struct MigrationError {
|
||||
version: Option<i64>,
|
||||
recovery: &'static str,
|
||||
}
|
||||
|
||||
impl MigrationError {
|
||||
pub(super) fn new(
|
||||
code: &'static str,
|
||||
@@ -148,28 +115,22 @@ impl MigrationError {
|
||||
recovery,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn storage(stage: &'static str) -> Self {
|
||||
Self::new("storage_unavailable", stage, None, "contact_operator")
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &'static str {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn stage(&self) -> &'static str {
|
||||
self.stage
|
||||
}
|
||||
|
||||
pub fn version(&self) -> Option<i64> {
|
||||
self.version
|
||||
}
|
||||
|
||||
pub fn recovery(&self) -> &'static str {
|
||||
self.recovery
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MigrationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
@@ -183,16 +144,33 @@ impl fmt::Display for MigrationError {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MigrationError {}
|
||||
|
||||
pub struct MigrationAuthority;
|
||||
|
||||
fn expand_descriptor(
|
||||
version: i64,
|
||||
name: &'static str,
|
||||
checksum: &'static str,
|
||||
readable_schema_min: i64,
|
||||
) -> MigrationDescriptor {
|
||||
MigrationDescriptor {
|
||||
version,
|
||||
name,
|
||||
checksum: checksum.to_owned(),
|
||||
source_digest: checksum.to_owned(),
|
||||
phase: "expand",
|
||||
compatibility: "n-minus-one-readable",
|
||||
owner: "crank-registry",
|
||||
transactional: true,
|
||||
backfill: BackfillPolicy::None,
|
||||
readable_schema_min,
|
||||
readable_schema_max: version,
|
||||
contract_evidence: None,
|
||||
}
|
||||
}
|
||||
impl MigrationAuthority {
|
||||
pub fn registered_extension_migrations() -> &'static [(&'static str, ExtensionMigration)] {
|
||||
REGISTERED_EXTENSION_MIGRATIONS
|
||||
}
|
||||
|
||||
pub fn sequence() -> Vec<MigrationDescriptor> {
|
||||
vec![
|
||||
MigrationDescriptor {
|
||||
@@ -209,41 +187,66 @@ impl MigrationAuthority {
|
||||
readable_schema_max: 1,
|
||||
contract_evidence: None,
|
||||
},
|
||||
MigrationDescriptor {
|
||||
version: 2,
|
||||
name: "legacy-consolidation-v2",
|
||||
checksum: CONSOLIDATION_SOURCE_SHA256.to_owned(),
|
||||
source_digest: CONSOLIDATION_SOURCE_SHA256.to_owned(),
|
||||
phase: "expand",
|
||||
compatibility: "n-minus-one-readable",
|
||||
owner: "crank-registry",
|
||||
transactional: true,
|
||||
backfill: BackfillPolicy::None,
|
||||
readable_schema_min: 1,
|
||||
readable_schema_max: 2,
|
||||
contract_evidence: None,
|
||||
},
|
||||
MigrationDescriptor {
|
||||
version: 3,
|
||||
name: "request-trace-identity-v3",
|
||||
checksum: REQUEST_TRACE_IDENTITY_SOURCE_SHA256.to_owned(),
|
||||
source_digest: REQUEST_TRACE_IDENTITY_SOURCE_SHA256.to_owned(),
|
||||
phase: "expand",
|
||||
compatibility: "n-minus-one-readable",
|
||||
owner: "crank-registry",
|
||||
transactional: true,
|
||||
backfill: BackfillPolicy::None,
|
||||
readable_schema_min: 2,
|
||||
readable_schema_max: 3,
|
||||
contract_evidence: None,
|
||||
},
|
||||
expand_descriptor(2, "legacy-consolidation-v2", CONSOLIDATION_SOURCE_SHA256, 1),
|
||||
expand_descriptor(
|
||||
3,
|
||||
"request-trace-identity-v3",
|
||||
REQUEST_TRACE_IDENTITY_SOURCE_SHA256,
|
||||
2,
|
||||
),
|
||||
expand_descriptor(
|
||||
4,
|
||||
"operation-lifecycle-v4",
|
||||
OPERATION_LIFECYCLE_SOURCE_SHA256,
|
||||
3,
|
||||
),
|
||||
expand_descriptor(
|
||||
5,
|
||||
"execution-outcome-v5",
|
||||
execution_outcome_v5::SOURCE_SHA256,
|
||||
4,
|
||||
),
|
||||
expand_descriptor(
|
||||
6,
|
||||
"platform-key-name-reuse-v6",
|
||||
platform_key_name_reuse_v6::SOURCE_SHA256,
|
||||
5,
|
||||
),
|
||||
expand_descriptor(
|
||||
7,
|
||||
"master-key-identity-v7",
|
||||
master_key_identity_v7::SOURCE_SHA256,
|
||||
6,
|
||||
),
|
||||
expand_descriptor(
|
||||
8,
|
||||
"admin-auth-lifecycle-v8",
|
||||
admin_auth_lifecycle_v8::SOURCE_SHA256,
|
||||
7,
|
||||
),
|
||||
expand_descriptor(
|
||||
9,
|
||||
"agent-catalog-lifecycle-v9",
|
||||
agent_catalog_lifecycle_v9::SOURCE_SHA256,
|
||||
8,
|
||||
),
|
||||
expand_descriptor(
|
||||
10,
|
||||
"approval-side-effects-v10",
|
||||
approval_side_effects_v10::SOURCE_SHA256,
|
||||
9,
|
||||
),
|
||||
expand_descriptor(
|
||||
11,
|
||||
"onboarding-product-events-v11",
|
||||
onboarding_product_events_v11::SOURCE_SHA256,
|
||||
10,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn validate_sequence() -> Result<(), MigrationError> {
|
||||
validate_descriptors(&Self::sequence())
|
||||
}
|
||||
|
||||
pub async fn preflight(pool: &PgPool) -> Result<MigrationPreflight, MigrationError> {
|
||||
Self::validate_sequence()?;
|
||||
let mut connection = pool
|
||||
@@ -252,7 +255,6 @@ impl MigrationAuthority {
|
||||
.map_err(|_| MigrationError::storage("preflight.connect"))?;
|
||||
inspect(&mut connection).await
|
||||
}
|
||||
|
||||
pub async fn require_current(pool: &PgPool) -> Result<(), MigrationError> {
|
||||
match Self::preflight(pool).await? {
|
||||
MigrationPreflight::Current { .. } => Ok(()),
|
||||
@@ -270,7 +272,6 @@ impl MigrationAuthority {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply(pool: &PgPool) -> Result<MigrationApplyResult, MigrationError> {
|
||||
Self::validate_sequence()?;
|
||||
let mut transaction = pool
|
||||
@@ -296,7 +297,6 @@ impl MigrationAuthority {
|
||||
MigrationError::storage("apply.lock")
|
||||
}
|
||||
})?;
|
||||
|
||||
let before = inspect(&mut transaction).await?;
|
||||
let from = match before {
|
||||
MigrationPreflight::Current { version } => {
|
||||
@@ -308,7 +308,6 @@ impl MigrationAuthority {
|
||||
}
|
||||
MigrationPreflight::MigrationRequired { current, .. } => current,
|
||||
};
|
||||
|
||||
if from == 0 {
|
||||
create_core_ledger(&mut transaction).await?;
|
||||
apply_baseline(&mut transaction).await.map_err(|_| {
|
||||
@@ -335,14 +334,36 @@ impl MigrationAuthority {
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if from < 2 {
|
||||
apply_consolidation(&mut transaction).await?;
|
||||
}
|
||||
if from < 3 {
|
||||
apply_request_trace_identity(&mut transaction).await?;
|
||||
}
|
||||
|
||||
if from < 4 {
|
||||
apply_operation_lifecycle(&mut transaction).await?;
|
||||
}
|
||||
if from < 5 {
|
||||
execution_outcome_v5::apply(&mut transaction, &Self::sequence()[4]).await?;
|
||||
}
|
||||
if from < 6 {
|
||||
platform_key_name_reuse_v6::apply(&mut transaction, &Self::sequence()[5]).await?;
|
||||
}
|
||||
if from < 7 {
|
||||
master_key_identity_v7::apply(&mut transaction, &Self::sequence()[6]).await?;
|
||||
}
|
||||
if from < 8 {
|
||||
admin_auth_lifecycle_v8::apply(&mut transaction, &Self::sequence()[7]).await?;
|
||||
}
|
||||
if from < 9 {
|
||||
agent_catalog_lifecycle_v9::apply(&mut transaction, &Self::sequence()[8]).await?;
|
||||
}
|
||||
if from < 10 {
|
||||
approval_side_effects_v10::apply(&mut transaction, &Self::sequence()[9]).await?;
|
||||
}
|
||||
if from < 11 {
|
||||
onboarding_product_events_v11::apply(&mut transaction, &Self::sequence()[10]).await?;
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
@@ -353,11 +374,9 @@ impl MigrationAuthority {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn baseline_source_digest() -> String {
|
||||
let source = include_str!("baseline_v1.rs");
|
||||
@@ -369,7 +388,6 @@ fn baseline_source_digest() -> String {
|
||||
.expect("baseline end marker must exist");
|
||||
sha256_hex(baseline.as_bytes())
|
||||
}
|
||||
|
||||
fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), MigrationError> {
|
||||
if descriptors.is_empty() || descriptors.len() > 1_024 {
|
||||
return Err(MigrationError::new(
|
||||
@@ -461,6 +479,19 @@ fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), Migra
|
||||
|| sha256_hex(CONSOLIDATION_SOURCE.as_bytes()) != CONSOLIDATION_SOURCE_SHA256
|
||||
|| sha256_hex(REQUEST_TRACE_IDENTITY_SOURCE.as_bytes())
|
||||
!= REQUEST_TRACE_IDENTITY_SOURCE_SHA256
|
||||
|| sha256_hex(OPERATION_LIFECYCLE_SOURCE.as_bytes()) != OPERATION_LIFECYCLE_SOURCE_SHA256
|
||||
|| sha256_hex(execution_outcome_v5::SOURCE.as_bytes())
|
||||
!= execution_outcome_v5::SOURCE_SHA256
|
||||
|| sha256_hex(platform_key_name_reuse_v6::SOURCE.as_bytes())
|
||||
!= platform_key_name_reuse_v6::SOURCE_SHA256
|
||||
|| sha256_hex(master_key_identity_v7::SOURCE.as_bytes())
|
||||
!= master_key_identity_v7::SOURCE_SHA256
|
||||
|| sha256_hex(admin_auth_lifecycle_v8::SOURCE.as_bytes())
|
||||
!= admin_auth_lifecycle_v8::SOURCE_SHA256
|
||||
|| sha256_hex(agent_catalog_lifecycle_v9::SOURCE.as_bytes())
|
||||
!= agent_catalog_lifecycle_v9::SOURCE_SHA256
|
||||
|| sha256_hex(approval_side_effects_v10::SOURCE.as_bytes())
|
||||
!= approval_side_effects_v10::SOURCE_SHA256
|
||||
{
|
||||
return Err(MigrationError::new(
|
||||
"invalid_contract",
|
||||
@@ -471,11 +502,9 @@ fn validate_descriptors(descriptors: &[MigrationDescriptor]) -> Result<(), Migra
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, MigrationError> {
|
||||
let core_exists = relation_exists(connection, "__crank_core_migrations").await?;
|
||||
let canonical_exists = relation_exists(connection, "__crank_migrations").await?;
|
||||
|
||||
if !core_exists {
|
||||
let mut owned_exists = canonical_exists;
|
||||
for relation in OWNED_RELATIONS {
|
||||
@@ -495,18 +524,15 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
||||
target: CURRENT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
validate_core_ledger(connection).await?;
|
||||
validate_required_relations(connection, BASELINE_RELATIONS, 1).await?;
|
||||
validate_required_relations(connection, owned_relations::BASELINE, 1).await?;
|
||||
inspect_optional_legacy(connection).await?;
|
||||
|
||||
if !canonical_exists {
|
||||
return Ok(MigrationPreflight::MigrationRequired {
|
||||
current: 1,
|
||||
target: CURRENT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
let descriptors = MigrationAuthority::sequence();
|
||||
let rows = query("select version, name, checksum, phase, compatibility from __crank_migrations order by version limit 1025")
|
||||
.fetch_all(&mut *connection)
|
||||
@@ -580,7 +606,6 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let current = rows
|
||||
.last()
|
||||
.and_then(|row| row.try_get::<i64, _>("version").ok())
|
||||
@@ -600,13 +625,19 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
|
||||
target: CURRENT_VERSION,
|
||||
})
|
||||
} else {
|
||||
validate_required_relations(connection, CONSOLIDATION_RELATIONS, CURRENT_VERSION).await?;
|
||||
validate_required_relations(connection, owned_relations::CONSOLIDATION, CURRENT_VERSION)
|
||||
.await?;
|
||||
validate_required_relations(
|
||||
connection,
|
||||
owned_relations::ONBOARDING_PRODUCT_EVENTS,
|
||||
CURRENT_VERSION,
|
||||
)
|
||||
.await?;
|
||||
validate_schema_fingerprint(connection, CURRENT_VERSION).await?;
|
||||
validate_legacy_audit(connection).await?;
|
||||
Ok(MigrationPreflight::Current { version: current })
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let rows = query("select version, description, checksum from __crank_core_migrations order by version limit 2")
|
||||
.fetch_all(connection)
|
||||
@@ -642,7 +673,6 @@ async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), Migra
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
|
||||
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
|
||||
@@ -749,7 +779,6 @@ async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), Mi
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_legacy_audit(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let rows = query(
|
||||
"select source, source_version, source_checksum
|
||||
@@ -800,7 +829,6 @@ async fn validate_legacy_audit(connection: &mut PgConnection) -> Result<(), Migr
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_core_ledger(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
@@ -824,7 +852,6 @@ async fn create_core_ledger(
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_consolidation(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
@@ -883,7 +910,6 @@ async fn apply_consolidation(
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_request_trace_identity(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
@@ -920,7 +946,42 @@ async fn apply_request_trace_identity(
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_operation_lifecycle(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(OPERATION_LIFECYCLE_SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.operation_lifecycle",
|
||||
Some(4),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
let descriptor = &MigrationAuthority::sequence()[3];
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(4),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "authority_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -8,7 +8,7 @@ fn sequence_is_deterministic_and_append_only() {
|
||||
MigrationAuthority::validate_sequence().unwrap();
|
||||
assert_eq!(
|
||||
first.iter().map(|item| item.version).collect::<Vec<_>>(),
|
||||
vec![1, 2, 3]
|
||||
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
||||
);
|
||||
assert_eq!(first[0].checksum, "crank-community-baseline-v1");
|
||||
assert_eq!(first[0].source_digest, BASELINE_SOURCE_SHA256);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("execution_outcome_v5.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"bd6703249cc407789586327eb7fbbd5776ba27923c5dc7eb85b05d332585bf42";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.execution_outcome",
|
||||
Some(5),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(5),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
alter table invocation_logs
|
||||
add column operation_version integer null,
|
||||
add column execution_stage text null,
|
||||
add column execution_error_code text null,
|
||||
add column retryability text null,
|
||||
add column outcome_certainty text null;
|
||||
|
||||
alter table invocation_logs
|
||||
add constraint invocation_logs_operation_version_check
|
||||
check (operation_version is null or operation_version > 0),
|
||||
add constraint invocation_logs_execution_stage_check
|
||||
check (execution_stage is null or execution_stage in (
|
||||
'authorization', 'input_schema', 'input_mapping', 'request_preparation',
|
||||
'admission', 'adapter', 'upstream', 'output_mapping', 'output_schema',
|
||||
'mandatory_persistence', 'runtime'
|
||||
)),
|
||||
add constraint invocation_logs_execution_error_code_check
|
||||
check (execution_error_code is null or execution_error_code in (
|
||||
'authorization_denied', 'auth_profile_not_found', 'secret_not_found',
|
||||
'secret_invalid', 'input_schema_invalid', 'input_mapping_invalid',
|
||||
'prepared_request_invalid', 'execution_overloaded', 'safety_store_unavailable',
|
||||
'protocol_unsupported', 'execution_mode_unsupported',
|
||||
'adapter_configuration_invalid', 'outbound_target_rejected',
|
||||
'upstream_auth_error', 'upstream_not_found', 'upstream_rate_limited',
|
||||
'upstream_server_error', 'upstream_status_error', 'upstream_timeout',
|
||||
'upstream_transport_error', 'upstream_request_too_large',
|
||||
'upstream_response_too_large',
|
||||
'output_mapping_invalid', 'output_schema_invalid', 'persistence_unavailable',
|
||||
'runtime_internal', 'confirmation_required', 'confirmation_invalid',
|
||||
'idempotency_in_progress', 'idempotency_conflict', 'idempotency_outcome_unknown'
|
||||
)),
|
||||
add constraint invocation_logs_retryability_check
|
||||
check (retryability is null or retryability in (
|
||||
'never', 'safe', 'after_delay', 'manual_reconcile', 'requires_confirmation'
|
||||
)),
|
||||
add constraint invocation_logs_outcome_certainty_check
|
||||
check (outcome_certainty is null or outcome_certainty in ('certain', 'outcome_unknown'));
|
||||
|
||||
create index invocation_logs_workspace_operation_version_idx
|
||||
on invocation_logs(workspace_id, operation_id, operation_version)
|
||||
where operation_version is not null;
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("master_key_identity_v7.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"06e5d4667d9a7346474e9dcb176b8b2b9680c4b89089bea5a18eb5a88d5ac60e";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.master_key_identity",
|
||||
Some(7),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(7),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
create table master_key_identities (
|
||||
epoch bigint primary key,
|
||||
fingerprint text not null unique,
|
||||
cipher_contract text not null,
|
||||
status text not null,
|
||||
backup_ref text,
|
||||
created_at timestamptz not null default now(),
|
||||
activated_at timestamptz,
|
||||
retired_at timestamptz,
|
||||
constraint master_key_identities_epoch_check check (epoch > 0),
|
||||
constraint master_key_identities_fingerprint_check check (
|
||||
fingerprint ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
constraint master_key_identities_cipher_contract_check check (
|
||||
cipher_contract = 'secret-envelope-v2/aes-256-gcm-hkdf-sha256'
|
||||
),
|
||||
constraint master_key_identities_status_check check (
|
||||
status in ('active', 'pending', 'retired', 'revoked')
|
||||
),
|
||||
constraint master_key_identities_backup_ref_check check (
|
||||
backup_ref is null
|
||||
or (
|
||||
octet_length(backup_ref) <= 256
|
||||
and backup_ref !~ '[[:cntrl:]]'
|
||||
and backup_ref !~ '^[A-Za-z][A-Za-z0-9+.-]*://'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create unique index master_key_identities_active_idx
|
||||
on master_key_identities (status)
|
||||
where status = 'active';
|
||||
|
||||
create table master_key_rotations (
|
||||
id text primary key,
|
||||
source_epoch bigint not null,
|
||||
target_epoch bigint not null,
|
||||
target_fingerprint text not null,
|
||||
state text not null,
|
||||
backup_ref text,
|
||||
checkpoint_secret_id text,
|
||||
total_secret_versions bigint not null default 0,
|
||||
processed_secret_versions bigint not null default 0,
|
||||
verified_secret_versions bigint not null default 0,
|
||||
failure_code text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint master_key_rotations_id_check check (
|
||||
octet_length(id) between 1 and 128
|
||||
and id !~ '[[:cntrl:]]'
|
||||
),
|
||||
constraint master_key_rotations_source_epoch_check check (source_epoch > 0),
|
||||
constraint master_key_rotations_target_epoch_check check (target_epoch > source_epoch),
|
||||
constraint master_key_rotations_target_fingerprint_check check (
|
||||
target_fingerprint ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
constraint master_key_rotations_state_check check (
|
||||
state in ('preflighted', 'running', 'verifying', 'verified', 'promoted', 'aborted', 'failed')
|
||||
),
|
||||
constraint master_key_rotations_backup_ref_check check (
|
||||
backup_ref is null
|
||||
or (
|
||||
octet_length(backup_ref) <= 256
|
||||
and backup_ref !~ '[[:cntrl:]]'
|
||||
and backup_ref !~ '^[A-Za-z][A-Za-z0-9+.-]*://'
|
||||
)
|
||||
),
|
||||
constraint master_key_rotations_counts_check check (
|
||||
total_secret_versions >= 0
|
||||
and processed_secret_versions >= 0
|
||||
and verified_secret_versions >= 0
|
||||
and processed_secret_versions <= total_secret_versions
|
||||
and verified_secret_versions <= total_secret_versions
|
||||
),
|
||||
constraint master_key_rotations_failure_code_check check (
|
||||
failure_code is null
|
||||
or (
|
||||
octet_length(failure_code) between 1 and 128
|
||||
and failure_code !~ '[[:cntrl:]]'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
alter table secret_versions
|
||||
add column master_key_epoch bigint not null default 1,
|
||||
add column target_ciphertext text,
|
||||
add column target_key_version text,
|
||||
add column target_master_key_epoch bigint,
|
||||
add constraint secret_versions_master_key_epoch_check check (master_key_epoch > 0),
|
||||
add constraint secret_versions_target_epoch_check check (
|
||||
target_master_key_epoch is null or target_master_key_epoch > master_key_epoch
|
||||
),
|
||||
add constraint secret_versions_target_all_or_none_check check (
|
||||
(
|
||||
target_ciphertext is null
|
||||
and target_key_version is null
|
||||
and target_master_key_epoch is null
|
||||
)
|
||||
or (
|
||||
target_ciphertext is not null
|
||||
and target_key_version is not null
|
||||
and target_master_key_epoch is not null
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Postgres, Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("onboarding_product_events_v11.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"a439bbcdc9cc909d717ed5a868c7a51bd1ad3c49c4e85fd20933743ee3f31166";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.onboarding_product_events",
|
||||
Some(11),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(11),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
alter table platform_api_keys
|
||||
add constraint platform_api_keys_workspace_agent_id_unique
|
||||
unique (workspace_id, agent_id, id);
|
||||
|
||||
alter table invocation_logs
|
||||
add column platform_api_key_id text null;
|
||||
|
||||
alter table invocation_logs
|
||||
add constraint invocation_logs_platform_key_scope_fk
|
||||
foreign key (workspace_id, agent_id, platform_api_key_id)
|
||||
references platform_api_keys(workspace_id, agent_id, id)
|
||||
on delete set null (platform_api_key_id);
|
||||
|
||||
create index invocation_logs_workspace_agent_key_success_idx
|
||||
on invocation_logs(workspace_id, agent_id, platform_api_key_id, created_at desc)
|
||||
where platform_api_key_id is not null
|
||||
and source = 'agent_tool_call'
|
||||
and status = 'ok';
|
||||
|
||||
create table product_events (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
event_name text not null,
|
||||
schema_version integer not null,
|
||||
occurred_at timestamptz not null,
|
||||
idempotency_key text not null,
|
||||
properties_json jsonb not null default '{}'::jsonb,
|
||||
constraint product_events_id_check check (id ~ '^pe_[A-Za-z0-9_-]{1,128}$'),
|
||||
constraint product_events_name_check check (event_name in (
|
||||
'onboarding_eligible', 'onboarding_started', 'onboarding_resumed', 'onboarding_dismissed',
|
||||
'onboarding_abandoned', 'onboarding_completed'
|
||||
)),
|
||||
constraint product_events_schema_version_check check (schema_version = 1),
|
||||
constraint product_events_idempotency_key_check check (
|
||||
octet_length(idempotency_key) between 1 and 256
|
||||
),
|
||||
constraint product_events_properties_check check (
|
||||
jsonb_typeof(properties_json) = 'object'
|
||||
and pg_column_size(properties_json) <= 4096
|
||||
and (
|
||||
event_name <> 'onboarding_eligible'
|
||||
or (
|
||||
properties_json @> '{"eligible": true}'::jsonb
|
||||
and jsonb_typeof(properties_json -> 'eligible_since') = 'string'
|
||||
)
|
||||
)
|
||||
),
|
||||
unique (workspace_id, idempotency_key)
|
||||
);
|
||||
|
||||
create index product_events_workspace_occurred_idx
|
||||
on product_events(workspace_id, occurred_at, id);
|
||||
|
||||
create table product_event_daily_rollups (
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
event_name text not null,
|
||||
day date not null,
|
||||
events_total bigint not null default 0,
|
||||
eligible_total bigint not null default 0,
|
||||
constraint product_event_daily_rollups_name_check check (event_name in (
|
||||
'onboarding_eligible', 'onboarding_started', 'onboarding_resumed', 'onboarding_dismissed',
|
||||
'onboarding_abandoned', 'onboarding_completed'
|
||||
)),
|
||||
constraint product_event_daily_rollups_counts_check check (
|
||||
events_total >= 0 and eligible_total >= 0 and eligible_total <= events_total
|
||||
),
|
||||
primary key (workspace_id, event_name, day)
|
||||
);
|
||||
|
||||
create table onboarding_selections (
|
||||
workspace_id text primary key references workspaces(id) on delete cascade,
|
||||
operation_id text null,
|
||||
operation_version integer null,
|
||||
agent_id text null,
|
||||
catalog_revision bigint null,
|
||||
platform_api_key_id text null,
|
||||
invocation_log_id text null,
|
||||
test_log_id text null,
|
||||
evidence_after timestamptz not null,
|
||||
selected_at timestamptz null,
|
||||
constraint onboarding_selections_shape_check check (
|
||||
(
|
||||
operation_id is null and operation_version is null and agent_id is null
|
||||
and catalog_revision is null and platform_api_key_id is null
|
||||
and invocation_log_id is null and test_log_id is null and selected_at is null
|
||||
) or (
|
||||
operation_id is not null and operation_version is not null and operation_version > 0
|
||||
and agent_id is not null and catalog_revision is not null and catalog_revision > 0
|
||||
and platform_api_key_id is not null and invocation_log_id is not null
|
||||
and selected_at is not null
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
create function crank_reject_product_event_mutation()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if tg_op = 'DELETE'
|
||||
and not exists (select 1 from workspaces where id = old.workspace_id) then
|
||||
return old;
|
||||
end if;
|
||||
raise exception 'ProductEvent is append-only' using errcode = '23514';
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger product_events_append_only_guard
|
||||
before update or delete on product_events
|
||||
for each row execute function crank_reject_product_event_mutation();
|
||||
@@ -0,0 +1,142 @@
|
||||
alter table operation_versions
|
||||
add column name text,
|
||||
add column display_name text,
|
||||
add column category text,
|
||||
add column protocol text,
|
||||
add column security_level text,
|
||||
add column snapshot_provenance text,
|
||||
add column snapshot_observed_at timestamptz,
|
||||
add column published_at timestamptz,
|
||||
add column published_by text;
|
||||
|
||||
update operation_versions ov
|
||||
set name = o.name,
|
||||
display_name = o.display_name,
|
||||
category = o.category,
|
||||
protocol = o.protocol,
|
||||
security_level = o.security_level,
|
||||
snapshot_provenance = 'legacy_observed',
|
||||
snapshot_observed_at = statement_timestamp()
|
||||
from operations o
|
||||
where o.id = ov.operation_id;
|
||||
|
||||
update operation_versions ov
|
||||
set published_at = po.published_at,
|
||||
published_by = po.published_by
|
||||
from published_operations po
|
||||
where po.operation_id = ov.operation_id
|
||||
and po.version = ov.version;
|
||||
|
||||
alter table operation_versions
|
||||
alter column name set not null,
|
||||
alter column display_name set not null,
|
||||
alter column category set not null,
|
||||
alter column protocol set not null,
|
||||
alter column security_level set not null,
|
||||
alter column snapshot_provenance set not null,
|
||||
alter column snapshot_observed_at set not null,
|
||||
add constraint operation_versions_snapshot_provenance_check
|
||||
check (snapshot_provenance in ('legacy_observed', 'native_v4'));
|
||||
|
||||
create or replace function crank_guard_operation_version_immutable()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $guard$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
if old.status = 'published' then
|
||||
raise exception 'published operation version is immutable'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
return old;
|
||||
end if;
|
||||
|
||||
if old.status = 'draft'
|
||||
and new.status = 'published'
|
||||
and old.operation_id = new.operation_id
|
||||
and old.version = new.version
|
||||
and old.name = new.name
|
||||
and old.display_name = new.display_name
|
||||
and old.category = new.category
|
||||
and old.protocol = new.protocol
|
||||
and old.security_level = new.security_level
|
||||
and old.target_json = new.target_json
|
||||
and old.input_schema_json = new.input_schema_json
|
||||
and old.output_schema_json = new.output_schema_json
|
||||
and old.input_mapping_json = new.input_mapping_json
|
||||
and old.output_mapping_json = new.output_mapping_json
|
||||
and old.execution_config_json = new.execution_config_json
|
||||
and old.tool_description_json = new.tool_description_json
|
||||
and old.samples_json is not distinct from new.samples_json
|
||||
and old.generated_draft_json is not distinct from new.generated_draft_json
|
||||
and old.config_export_json is not distinct from new.config_export_json
|
||||
and old.wizard_state_json is not distinct from new.wizard_state_json
|
||||
and old.change_note is not distinct from new.change_note
|
||||
and old.created_at = new.created_at
|
||||
and old.created_by is not distinct from new.created_by
|
||||
and old.snapshot_provenance = new.snapshot_provenance
|
||||
and old.snapshot_observed_at = new.snapshot_observed_at
|
||||
and old.published_at is null
|
||||
and new.published_at is not null
|
||||
then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
raise exception 'operation version is append-only'
|
||||
using errcode = '55000';
|
||||
end;
|
||||
$guard$;
|
||||
|
||||
create trigger operation_versions_immutable_guard
|
||||
before update or delete on operation_versions
|
||||
for each row execute function crank_guard_operation_version_immutable();
|
||||
|
||||
create or replace function crank_guard_published_operation_pointer()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $guard$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'published operation pointer is immutable'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
if new.operation_id = old.operation_id and new.version > old.version then
|
||||
return new;
|
||||
end if;
|
||||
raise exception 'published operation pointer cannot rewind'
|
||||
using errcode = '55000';
|
||||
end;
|
||||
$guard$;
|
||||
|
||||
create trigger published_operations_monotonic_guard
|
||||
before update or delete on published_operations
|
||||
for each row execute function crank_guard_published_operation_pointer();
|
||||
|
||||
create or replace function crank_guard_operation_latest_pointer()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $guard$
|
||||
begin
|
||||
if old.latest_published_version is not null
|
||||
and (new.latest_published_version is null
|
||||
or new.latest_published_version < old.latest_published_version) then
|
||||
raise exception 'latest published operation version cannot rewind'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
if new.latest_published_version is distinct from old.latest_published_version
|
||||
and new.latest_published_version is not null
|
||||
and not exists (
|
||||
select 1 from published_operations po
|
||||
where po.operation_id = new.id
|
||||
and po.version = new.latest_published_version
|
||||
) then
|
||||
raise exception 'latest published operation version has no authoritative pointer'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$guard$;
|
||||
|
||||
create trigger operations_latest_pointer_monotonic_guard
|
||||
before update on operations
|
||||
for each row execute function crank_guard_operation_latest_pointer();
|
||||
@@ -0,0 +1,40 @@
|
||||
pub(super) const BASELINE: &[&str] = &[
|
||||
"workspaces",
|
||||
"users",
|
||||
"memberships",
|
||||
"user_sessions",
|
||||
"invitation_tokens",
|
||||
"platform_api_keys",
|
||||
"operations",
|
||||
"operation_versions",
|
||||
"published_operations",
|
||||
"operation_samples",
|
||||
"descriptors",
|
||||
"agents",
|
||||
"agent_versions",
|
||||
"published_agents",
|
||||
"agent_operation_bindings",
|
||||
"secrets",
|
||||
"secret_versions",
|
||||
"auth_profiles",
|
||||
"workspace_upstreams",
|
||||
"yaml_import_jobs",
|
||||
"import_jobs",
|
||||
"approval_requests",
|
||||
"invocation_logs",
|
||||
"usage_rollups",
|
||||
];
|
||||
|
||||
pub(super) const ONBOARDING_PRODUCT_EVENTS: &[&str] = &[
|
||||
"product_events",
|
||||
"product_event_daily_rollups",
|
||||
"onboarding_selections",
|
||||
];
|
||||
|
||||
pub(super) const CONSOLIDATION: &[&str] = &[
|
||||
"__crank_migrations",
|
||||
"__crank_migration_legacy_audit",
|
||||
"__crank_mcp_migrations",
|
||||
"mcp_transport_sessions",
|
||||
"__crank_ext_migrations",
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Transaction, query};
|
||||
|
||||
use super::authority::{MigrationDescriptor, MigrationError};
|
||||
|
||||
pub(super) const SOURCE: &str = include_str!("platform_key_name_reuse_v6.sql");
|
||||
pub(super) const SOURCE_SHA256: &str =
|
||||
"94dba9b9dd3364607bc37e7698478ba6644607a7f268a1621cd3acea6bc96c2d";
|
||||
|
||||
pub(super) async fn apply(
|
||||
transaction: &mut Transaction<'_, sqlx::Postgres>,
|
||||
descriptor: &MigrationDescriptor,
|
||||
) -> Result<(), MigrationError> {
|
||||
sqlx::raw_sql(SOURCE)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.platform_key_name_reuse",
|
||||
Some(6),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
query(
|
||||
"insert into __crank_migrations (version, name, checksum, phase, compatibility)
|
||||
values ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(descriptor.version)
|
||||
.bind(descriptor.name)
|
||||
.bind(&descriptor.checksum)
|
||||
.bind(descriptor.phase)
|
||||
.bind(descriptor.compatibility)
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MigrationError::new(
|
||||
"apply_failed",
|
||||
"apply.canonical_ledger",
|
||||
Some(6),
|
||||
"restore_known_good_backup",
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
drop index if exists platform_api_keys_workspace_name_idx;
|
||||
|
||||
create unique index if not exists platform_api_keys_workspace_name_active_idx
|
||||
on platform_api_keys(workspace_id, name)
|
||||
where status <> 'deleted';
|
||||
@@ -26,6 +26,8 @@ pub(super) const OWNED_RELATIONS: &[&str] = &[
|
||||
"agent_operation_bindings",
|
||||
"secrets",
|
||||
"secret_versions",
|
||||
"master_key_identities",
|
||||
"master_key_rotations",
|
||||
"auth_profiles",
|
||||
"workspace_upstreams",
|
||||
"yaml_import_jobs",
|
||||
@@ -77,6 +79,37 @@ const REQUIRED_COLUMNS: &[(&str, &[&str])] = &[
|
||||
"expires_at",
|
||||
],
|
||||
),
|
||||
(
|
||||
"master_key_identities",
|
||||
&[
|
||||
"epoch",
|
||||
"fingerprint",
|
||||
"cipher_contract",
|
||||
"status",
|
||||
"backup_ref",
|
||||
"created_at",
|
||||
"activated_at",
|
||||
"retired_at",
|
||||
],
|
||||
),
|
||||
(
|
||||
"master_key_rotations",
|
||||
&[
|
||||
"id",
|
||||
"source_epoch",
|
||||
"target_epoch",
|
||||
"target_fingerprint",
|
||||
"state",
|
||||
"backup_ref",
|
||||
"checkpoint_secret_id",
|
||||
"total_secret_versions",
|
||||
"processed_secret_versions",
|
||||
"verified_secret_versions",
|
||||
"failure_code",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
const REQUIRED_COLUMN_TYPES: &[(&str, &str, &str, bool)] = &[
|
||||
@@ -165,6 +198,67 @@ const REQUIRED_COLUMN_TYPES: &[(&str, &str, &str, bool)] = &[
|
||||
"timestamp with time zone",
|
||||
true,
|
||||
),
|
||||
("master_key_identities", "epoch", "bigint", false),
|
||||
("master_key_identities", "fingerprint", "text", false),
|
||||
("master_key_identities", "cipher_contract", "text", false),
|
||||
("master_key_identities", "status", "text", false),
|
||||
("master_key_identities", "backup_ref", "text", true),
|
||||
(
|
||||
"master_key_identities",
|
||||
"created_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"master_key_identities",
|
||||
"activated_at",
|
||||
"timestamp with time zone",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"master_key_identities",
|
||||
"retired_at",
|
||||
"timestamp with time zone",
|
||||
true,
|
||||
),
|
||||
("master_key_rotations", "id", "text", false),
|
||||
("master_key_rotations", "source_epoch", "bigint", false),
|
||||
("master_key_rotations", "target_epoch", "bigint", false),
|
||||
("master_key_rotations", "target_fingerprint", "text", false),
|
||||
("master_key_rotations", "state", "text", false),
|
||||
("master_key_rotations", "backup_ref", "text", true),
|
||||
("master_key_rotations", "checkpoint_secret_id", "text", true),
|
||||
(
|
||||
"master_key_rotations",
|
||||
"total_secret_versions",
|
||||
"bigint",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"master_key_rotations",
|
||||
"processed_secret_versions",
|
||||
"bigint",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"master_key_rotations",
|
||||
"verified_secret_versions",
|
||||
"bigint",
|
||||
false,
|
||||
),
|
||||
("master_key_rotations", "failure_code", "text", true),
|
||||
(
|
||||
"master_key_rotations",
|
||||
"created_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"master_key_rotations",
|
||||
"updated_at",
|
||||
"timestamp with time zone",
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
pub(super) async fn relation_exists(
|
||||
@@ -363,9 +457,397 @@ pub(super) async fn validate_schema_fingerprint(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let lifecycle_columns = [
|
||||
"name",
|
||||
"display_name",
|
||||
"category",
|
||||
"protocol",
|
||||
"security_level",
|
||||
"snapshot_provenance",
|
||||
"snapshot_observed_at",
|
||||
"published_at",
|
||||
"published_by",
|
||||
];
|
||||
for column in lifecycle_columns {
|
||||
let present = query(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'operation_versions'
|
||||
and column_name = $1
|
||||
) as present",
|
||||
)
|
||||
.bind(column)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if present != (current_version >= 4) {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
if current_version >= 4 {
|
||||
for (table, trigger) in [
|
||||
("operation_versions", "operation_versions_immutable_guard"),
|
||||
(
|
||||
"published_operations",
|
||||
"published_operations_monotonic_guard",
|
||||
),
|
||||
("operations", "operations_latest_pointer_monotonic_guard"),
|
||||
] {
|
||||
let trigger_present = query(
|
||||
"select exists (
|
||||
select 1 from pg_catalog.pg_trigger tg
|
||||
join pg_catalog.pg_class t on t.oid = tg.tgrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and t.relname = $1
|
||||
and tg.tgname = $2
|
||||
and not tg.tgisinternal
|
||||
) as present",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(trigger)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if !trigger_present {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
}
|
||||
let outcome_columns = [
|
||||
"operation_version",
|
||||
"execution_stage",
|
||||
"execution_error_code",
|
||||
"retryability",
|
||||
"outcome_certainty",
|
||||
];
|
||||
for column in outcome_columns {
|
||||
let row = query(
|
||||
"select data_type, is_nullable from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = $1",
|
||||
)
|
||||
.bind(column)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if current_version < 5 {
|
||||
if row.is_some() {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
let expected_type = if column == "operation_version" {
|
||||
"integer"
|
||||
} else {
|
||||
"text"
|
||||
};
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("data_type").ok().as_deref() == Some(expected_type)
|
||||
&& row.try_get::<String, _>("is_nullable").ok().as_deref() == Some("YES")
|
||||
});
|
||||
if !valid {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
}
|
||||
}
|
||||
if current_version >= 5 {
|
||||
validate_v5_execution_contract(connection).await?;
|
||||
}
|
||||
if current_version >= 6 {
|
||||
validate_v6_platform_key_name_reuse(connection).await?;
|
||||
}
|
||||
if current_version < 7 {
|
||||
let v7_relations_present = relation_exists(connection, "master_key_identities").await?
|
||||
|| relation_exists(connection, "master_key_rotations").await?;
|
||||
let mut v7_secret_columns_present = false;
|
||||
for column in [
|
||||
"master_key_epoch",
|
||||
"target_ciphertext",
|
||||
"target_key_version",
|
||||
"target_master_key_epoch",
|
||||
] {
|
||||
v7_secret_columns_present |=
|
||||
column_exists(connection, "secret_versions", column).await?;
|
||||
}
|
||||
if v7_relations_present || v7_secret_columns_present {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v7::validate_v7_master_key_identity(connection).await?;
|
||||
}
|
||||
if current_version < 8 {
|
||||
let v8_relations_present = relation_exists(connection, "admin_bootstrap_contracts").await?
|
||||
|| relation_exists(connection, "admin_login_backoff").await?
|
||||
|| relation_exists(connection, "admin_security_audit_events").await?;
|
||||
let v8_session_columns_present = column_exists(connection, "user_sessions", "csrf_hash")
|
||||
.await?
|
||||
|| column_exists(connection, "user_sessions", "revoked_at").await?;
|
||||
if v8_relations_present || v8_session_columns_present {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v8::validate_v8_admin_auth_lifecycle(connection).await?;
|
||||
}
|
||||
if current_version < 9 {
|
||||
if !super::schema_guard_v9::validate_v9_absent(connection).await? {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v9::validate_v9_agent_catalog_lifecycle(connection).await?;
|
||||
}
|
||||
if current_version < 10 {
|
||||
if !super::schema_guard_v10::validate_v10_absent(connection).await? {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v10::validate_v10_approval_side_effects(connection).await?;
|
||||
}
|
||||
if current_version < 11 {
|
||||
if !super::schema_guard_v11::validate_v11_absent(connection).await? {
|
||||
return Err(schema_error(current_version));
|
||||
}
|
||||
} else {
|
||||
super::schema_guard_v11::validate_v11_onboarding_product_events(connection).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn column_exists(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
column: &str,
|
||||
) -> Result<bool, MigrationError> {
|
||||
query(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = $1
|
||||
and column_name = $2
|
||||
) as present",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(column)
|
||||
.fetch_one(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
|
||||
async fn validate_v5_execution_contract(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
let operation_version = constraint_expression(
|
||||
connection,
|
||||
"invocation_logs",
|
||||
"invocation_logs_operation_version_check",
|
||||
)
|
||||
.await?;
|
||||
if normalize_definition(&operation_version) != "operation_versionisnulloroperation_version>0" {
|
||||
return Err(schema_error(5));
|
||||
}
|
||||
for (constraint, column, allowed) in [
|
||||
(
|
||||
"invocation_logs_execution_stage_check",
|
||||
"execution_stage",
|
||||
&[
|
||||
"authorization",
|
||||
"input_schema",
|
||||
"input_mapping",
|
||||
"request_preparation",
|
||||
"admission",
|
||||
"adapter",
|
||||
"upstream",
|
||||
"output_mapping",
|
||||
"output_schema",
|
||||
"mandatory_persistence",
|
||||
"runtime",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"invocation_logs_execution_error_code_check",
|
||||
"execution_error_code",
|
||||
&[
|
||||
"authorization_denied",
|
||||
"auth_profile_not_found",
|
||||
"secret_not_found",
|
||||
"secret_invalid",
|
||||
"input_schema_invalid",
|
||||
"input_mapping_invalid",
|
||||
"prepared_request_invalid",
|
||||
"execution_overloaded",
|
||||
"safety_store_unavailable",
|
||||
"protocol_unsupported",
|
||||
"execution_mode_unsupported",
|
||||
"adapter_configuration_invalid",
|
||||
"outbound_target_rejected",
|
||||
"upstream_auth_error",
|
||||
"upstream_not_found",
|
||||
"upstream_rate_limited",
|
||||
"upstream_server_error",
|
||||
"upstream_status_error",
|
||||
"upstream_timeout",
|
||||
"upstream_transport_error",
|
||||
"upstream_request_too_large",
|
||||
"upstream_response_too_large",
|
||||
"output_mapping_invalid",
|
||||
"output_schema_invalid",
|
||||
"persistence_unavailable",
|
||||
"runtime_internal",
|
||||
"confirmation_required",
|
||||
"confirmation_invalid",
|
||||
"idempotency_in_progress",
|
||||
"idempotency_conflict",
|
||||
"idempotency_outcome_unknown",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"invocation_logs_retryability_check",
|
||||
"retryability",
|
||||
&[
|
||||
"never",
|
||||
"safe",
|
||||
"after_delay",
|
||||
"manual_reconcile",
|
||||
"requires_confirmation",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"invocation_logs_outcome_certainty_check",
|
||||
"outcome_certainty",
|
||||
&["certain", "outcome_unknown"][..],
|
||||
),
|
||||
] {
|
||||
let expression = constraint_expression(connection, "invocation_logs", constraint).await?;
|
||||
if !enum_constraint_matches(&expression, column, allowed) {
|
||||
return Err(schema_error(5));
|
||||
}
|
||||
}
|
||||
validate_v5_index(connection).await
|
||||
}
|
||||
|
||||
pub(super) async fn constraint_expression(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
constraint: &str,
|
||||
) -> Result<String, MigrationError> {
|
||||
query(
|
||||
"select pg_get_expr(c.conbin, c.conrelid) as expression
|
||||
from pg_catalog.pg_constraint c
|
||||
join pg_catalog.pg_class t on t.oid = c.conrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema() and t.relname = $1 and c.conname = $2 and c.contype = 'c'",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(constraint)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.and_then(|row| row.try_get::<String, _>("expression").ok())
|
||||
.ok_or_else(|| schema_error(5))
|
||||
}
|
||||
|
||||
pub(super) fn enum_constraint_matches(expression: &str, column: &str, allowed: &[&str]) -> bool {
|
||||
let normalized = normalize_definition(expression);
|
||||
if !normalized.contains(column) || normalized.contains("ortrue") {
|
||||
return false;
|
||||
}
|
||||
let mut values = expression
|
||||
.split('\'')
|
||||
.enumerate()
|
||||
.filter_map(|(index, value)| (index % 2 == 1).then_some(value))
|
||||
.collect::<Vec<_>>();
|
||||
values.sort_unstable();
|
||||
values.dedup();
|
||||
let mut expected = allowed.to_vec();
|
||||
expected.sort_unstable();
|
||||
values == expected
|
||||
}
|
||||
|
||||
async fn validate_v5_index(connection: &mut PgConnection) -> Result<(), MigrationError> {
|
||||
let row = query(
|
||||
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
|
||||
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
|
||||
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
|
||||
pg_get_expr(i.indpred, i.indrelid) as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema()
|
||||
and idx.relname = 'invocation_logs_workspace_operation_version_idx'",
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some("invocation_logs")
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(false)
|
||||
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
|
||||
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("operation_id")
|
||||
&& row.try_get::<String, _>("third_column").ok().as_deref() == Some("operation_version")
|
||||
&& row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.is_some_and(|value| normalize_definition(&value) == "operation_versionisnotnull")
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(5)) }
|
||||
}
|
||||
|
||||
async fn validate_v6_platform_key_name_reuse(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
if relation_exists(connection, "platform_api_keys_workspace_name_idx").await? {
|
||||
return Err(schema_error(6));
|
||||
}
|
||||
let row = query(
|
||||
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
|
||||
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
|
||||
pg_get_expr(i.indpred, i.indrelid) as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema()
|
||||
and idx.relname = 'platform_api_keys_workspace_name_active_idx'",
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some("platform_api_keys")
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(true)
|
||||
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
|
||||
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("name")
|
||||
&& row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.is_some_and(|value| {
|
||||
matches!(
|
||||
normalize_definition(&value).as_str(),
|
||||
"status<>'deleted'::text" | "status!='deleted'::text"
|
||||
)
|
||||
})
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(6)) }
|
||||
}
|
||||
|
||||
async fn named_constraint_exists(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
@@ -433,7 +915,7 @@ async fn validate_index(
|
||||
if valid { Ok(()) } else { Err(schema_error(3)) }
|
||||
}
|
||||
|
||||
fn normalize_definition(value: &str) -> String {
|
||||
pub(super) fn normalize_definition(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| !character.is_ascii_whitespace() && !matches!(character, '(' | ')'))
|
||||
@@ -441,7 +923,7 @@ fn normalize_definition(value: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn schema_error(version: i64) -> MigrationError {
|
||||
pub(super) fn schema_error(version: i64) -> MigrationError {
|
||||
MigrationError::new(
|
||||
"partial_sequence",
|
||||
"preflight.schema",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use sqlx::{PgConnection, Row, query};
|
||||
|
||||
use super::authority::MigrationError;
|
||||
use super::schema_guard::{normalize_definition, relation_exists, schema_error};
|
||||
|
||||
pub(super) async fn validate_v10_absent(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<bool, MigrationError> {
|
||||
let old_present =
|
||||
relation_exists(connection, "approval_requests_pending_fingerprint_idx").await?;
|
||||
let new_present = relation_exists(
|
||||
connection,
|
||||
"approval_requests_pending_scope_fingerprint_idx",
|
||||
)
|
||||
.await?;
|
||||
Ok(old_present && !new_present)
|
||||
}
|
||||
|
||||
pub(super) async fn validate_v10_approval_side_effects(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
if !relation_exists(connection, "approval_requests_pending_fingerprint_idx").await? {
|
||||
return Err(schema_error(10));
|
||||
}
|
||||
for column in ["request_id", "trace_id"] {
|
||||
let present = query(
|
||||
"select 1
|
||||
from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'approval_requests'
|
||||
and column_name = $1",
|
||||
)
|
||||
.bind(column)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.is_some();
|
||||
if !present {
|
||||
return Err(schema_error(10));
|
||||
}
|
||||
}
|
||||
let row = query(
|
||||
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
|
||||
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
|
||||
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
|
||||
pg_get_indexdef(i.indexrelid, 4, true) as fourth_column,
|
||||
pg_get_indexdef(i.indexrelid, 5, true) as fifth_column,
|
||||
pg_get_expr(i.indpred, i.indrelid) as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema()
|
||||
and idx.relname = 'approval_requests_pending_scope_fingerprint_idx'",
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some("approval_requests")
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(true)
|
||||
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
|
||||
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("agent_id")
|
||||
&& row.try_get::<String, _>("third_column").ok().as_deref() == Some("operation_id")
|
||||
&& row.try_get::<String, _>("fourth_column").ok().as_deref()
|
||||
== Some("operation_version")
|
||||
&& row.try_get::<String, _>("fifth_column").ok().as_deref()
|
||||
== Some("request_fingerprint")
|
||||
&& row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.is_some_and(|value| {
|
||||
normalize_definition(&value)
|
||||
== "status='pending'::textandrequest_fingerprintisnotnull"
|
||||
})
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(10)) }
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use sqlx::{PgConnection, Row, query};
|
||||
|
||||
use super::authority::MigrationError;
|
||||
use super::schema_guard::{
|
||||
column_exists, constraint_expression, normalize_definition, relation_exists, schema_error,
|
||||
};
|
||||
|
||||
pub(super) async fn validate_v11_absent(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<bool, MigrationError> {
|
||||
Ok(!relation_exists(connection, "product_events").await?
|
||||
&& !relation_exists(connection, "product_event_daily_rollups").await?
|
||||
&& !relation_exists(connection, "onboarding_selections").await?
|
||||
&& !column_exists(connection, "invocation_logs", "platform_api_key_id").await?)
|
||||
}
|
||||
|
||||
pub(super) async fn validate_v11_onboarding_product_events(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
for relation in [
|
||||
"product_events",
|
||||
"product_event_daily_rollups",
|
||||
"onboarding_selections",
|
||||
] {
|
||||
if !relation_exists(connection, relation).await? {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
if !column_exists(connection, "invocation_logs", "platform_api_key_id").await? {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
for relation in [
|
||||
"product_events_workspace_id_idempotency_key_key",
|
||||
"product_events_workspace_occurred_idx",
|
||||
"invocation_logs_workspace_agent_key_success_idx",
|
||||
] {
|
||||
if !relation_exists(connection, relation).await? {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
for (table, constraint, required) in [
|
||||
(
|
||||
"product_events",
|
||||
"product_events_id_check",
|
||||
&["id~", "^pe_[a-za-z0-9_-]{1,128}$"][..],
|
||||
),
|
||||
(
|
||||
"product_events",
|
||||
"product_events_name_check",
|
||||
&[
|
||||
"event_name=any",
|
||||
"onboarding_eligible",
|
||||
"onboarding_started",
|
||||
"onboarding_resumed",
|
||||
"onboarding_dismissed",
|
||||
"onboarding_abandoned",
|
||||
"onboarding_completed",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"product_events",
|
||||
"product_events_schema_version_check",
|
||||
&["schema_version=1"][..],
|
||||
),
|
||||
(
|
||||
"product_events",
|
||||
"product_events_idempotency_key_check",
|
||||
&[
|
||||
"octet_lengthidempotency_key>=1",
|
||||
"octet_lengthidempotency_key<=256",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"product_events",
|
||||
"product_events_properties_check",
|
||||
&[
|
||||
"jsonb_typeofproperties_json='object'",
|
||||
"pg_column_sizeproperties_json<=4096",
|
||||
"event_name<>'onboarding_eligible'",
|
||||
"properties_json@>'{\"eligible\":true}'",
|
||||
"jsonb_typeofproperties_json->'eligible_since'",
|
||||
"='string'",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"product_event_daily_rollups",
|
||||
"product_event_daily_rollups_name_check",
|
||||
&[
|
||||
"event_name=any",
|
||||
"onboarding_eligible",
|
||||
"onboarding_completed",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"product_event_daily_rollups",
|
||||
"product_event_daily_rollups_counts_check",
|
||||
&[
|
||||
"events_total>=0",
|
||||
"eligible_total>=0",
|
||||
"eligible_total<=events_total",
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
let definition =
|
||||
normalize_definition(&constraint_expression(connection, table, constraint).await?);
|
||||
if required.iter().any(|snippet| !definition.contains(snippet)) {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
validate_index(
|
||||
connection,
|
||||
"product_events_workspace_occurred_idx",
|
||||
"product_events",
|
||||
false,
|
||||
&["workspace_id", "occurred_at", "id"],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
validate_index(
|
||||
connection,
|
||||
"invocation_logs_workspace_agent_key_success_idx",
|
||||
"invocation_logs",
|
||||
false,
|
||||
&[
|
||||
"workspace_id",
|
||||
"agent_id",
|
||||
"platform_api_key_id",
|
||||
"created_atdesc",
|
||||
],
|
||||
&[
|
||||
"platform_api_key_idisnotnull",
|
||||
"source='agent_tool_call'",
|
||||
"status='ok'",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let key_scope_fk = constraint_definition(
|
||||
connection,
|
||||
"invocation_logs",
|
||||
"invocation_logs_platform_key_scope_fk",
|
||||
)
|
||||
.await?;
|
||||
for required in [
|
||||
"foreignkeyworkspace_id,agent_id,platform_api_key_id",
|
||||
"referencesplatform_api_keysworkspace_id,agent_id,id",
|
||||
"ondeletesetnullplatform_api_key_id",
|
||||
] {
|
||||
if !key_scope_fk.contains(required) {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
for (kind, name) in [
|
||||
("constraint", "invocation_logs_platform_key_scope_fk"),
|
||||
("trigger", "product_events_append_only_guard"),
|
||||
] {
|
||||
let present: bool = match kind {
|
||||
"constraint" => query(
|
||||
"select exists (select 1 from pg_constraint c
|
||||
join pg_namespace n on n.oid = c.connamespace
|
||||
where n.nspname = current_schema() and c.conname = $1) as present",
|
||||
),
|
||||
_ => query(
|
||||
"select exists (select 1 from pg_trigger t
|
||||
join pg_class c on c.oid = t.tgrelid
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = current_schema() and t.tgname = $1 and not t.tgisinternal) as present",
|
||||
),
|
||||
}
|
||||
.bind(name)
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
if !present {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
let nullable = query(
|
||||
"select is_nullable from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'invocation_logs'
|
||||
and column_name = 'platform_api_key_id'",
|
||||
)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.and_then(|row| row.try_get::<String, _>("is_nullable").ok());
|
||||
if nullable.as_deref() != Some("YES") {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
let shape = constraint_expression(
|
||||
connection,
|
||||
"onboarding_selections",
|
||||
"onboarding_selections_shape_check",
|
||||
)
|
||||
.await?;
|
||||
let normalized_shape = normalize_definition(&shape);
|
||||
for required in [
|
||||
"operation_idisnull",
|
||||
"operation_version>0",
|
||||
"catalog_revision>0",
|
||||
"invocation_log_idisnotnull",
|
||||
"selected_atisnull",
|
||||
"selected_atisnotnull",
|
||||
] {
|
||||
if !normalized_shape.contains(required) {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
}
|
||||
let function_definition = query(
|
||||
"select p.prosrc as definition
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = current_schema()
|
||||
and p.proname = 'crank_reject_product_event_mutation'",
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.and_then(|row| row.try_get::<String, _>("definition").ok());
|
||||
if !function_definition.is_some_and(|definition| {
|
||||
let normalized = normalize_definition(&definition);
|
||||
[
|
||||
"notexists",
|
||||
"fromworkspaces",
|
||||
"old.workspace_id",
|
||||
"returnold",
|
||||
"producteventisappend-only",
|
||||
]
|
||||
.into_iter()
|
||||
.all(|required| normalized.contains(required))
|
||||
}) {
|
||||
return Err(schema_error(11));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn constraint_definition(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
constraint: &str,
|
||||
) -> Result<String, MigrationError> {
|
||||
query(
|
||||
"select pg_get_constraintdef(c.oid, true) as definition
|
||||
from pg_catalog.pg_constraint c
|
||||
join pg_catalog.pg_class t on t.oid = c.conrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema() and t.relname = $1 and c.conname = $2",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(constraint)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.ok_or_else(|| schema_error(11))?
|
||||
.try_get::<String, _>("definition")
|
||||
.map(|definition| normalize_definition(&definition))
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
|
||||
async fn validate_index(
|
||||
connection: &mut PgConnection,
|
||||
index: &str,
|
||||
expected_table: &str,
|
||||
expected_unique: bool,
|
||||
expected_columns: &[&str],
|
||||
predicate_snippets: &[&str],
|
||||
) -> Result<(), MigrationError> {
|
||||
let row = query(
|
||||
"select t.relname as table_name, am.amname as access_method,
|
||||
i.indisvalid, i.indisready, i.indisunique,
|
||||
pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_indexdef(i.indexrelid, 2, true) as second_column,
|
||||
pg_get_indexdef(i.indexrelid, 3, true) as third_column,
|
||||
pg_get_indexdef(i.indexrelid, 4, true) as fourth_column,
|
||||
pg_get_indexdef(i.indexrelid) as definition,
|
||||
coalesce(pg_get_expr(i.indpred, i.indrelid), '') as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema() and idx.relname = $1",
|
||||
)
|
||||
.bind(index)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
let actual_columns = [
|
||||
"first_column",
|
||||
"second_column",
|
||||
"third_column",
|
||||
"fourth_column",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|field| row.try_get::<Option<String>, _>(field).ok().flatten())
|
||||
.map(|value| normalize_definition(&value))
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let expected_base_columns = expected_columns
|
||||
.iter()
|
||||
.map(|column| column.strip_suffix("desc").unwrap_or(column).to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let definition = row
|
||||
.try_get::<String, _>("definition")
|
||||
.ok()
|
||||
.map(|value| normalize_definition(&value))
|
||||
.unwrap_or_default();
|
||||
let predicate = row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.map(|value| normalize_definition(&value))
|
||||
.unwrap_or_default();
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some(expected_table)
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(expected_unique)
|
||||
&& actual_columns == expected_base_columns
|
||||
&& expected_columns
|
||||
.iter()
|
||||
.filter(|column| column.ends_with("desc"))
|
||||
.all(|column| definition.contains(column))
|
||||
&& predicate_snippets
|
||||
.iter()
|
||||
.all(|snippet| predicate.contains(snippet))
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(11)) }
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use sqlx::{PgConnection, Row, query};
|
||||
|
||||
use super::{
|
||||
authority::MigrationError,
|
||||
schema_guard::{
|
||||
column_exists, constraint_expression, enum_constraint_matches, normalize_definition,
|
||||
relation_exists, schema_error,
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) async fn validate_v7_master_key_identity(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
for relation in ["master_key_identities", "master_key_rotations"] {
|
||||
if !relation_exists(connection, relation).await? {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
}
|
||||
for (column, data_type, nullable) in [
|
||||
("master_key_epoch", "bigint", false),
|
||||
("target_ciphertext", "text", true),
|
||||
("target_key_version", "text", true),
|
||||
("target_master_key_epoch", "bigint", true),
|
||||
] {
|
||||
if !secret_version_column_matches(connection, column, data_type, nullable).await? {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
}
|
||||
validate_identity_constraints(connection).await?;
|
||||
validate_rotation_constraints(connection).await?;
|
||||
validate_secret_version_constraints(connection).await?;
|
||||
validate_active_identity_index(connection).await
|
||||
}
|
||||
|
||||
async fn secret_version_column_matches(
|
||||
connection: &mut PgConnection,
|
||||
column: &str,
|
||||
data_type: &str,
|
||||
nullable: bool,
|
||||
) -> Result<bool, MigrationError> {
|
||||
if !column_exists(connection, "secret_versions", column).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
let row = query(
|
||||
"select data_type, is_nullable from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'secret_versions'
|
||||
and column_name = $1",
|
||||
)
|
||||
.bind(column)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
Ok(row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("data_type").ok().as_deref() == Some(data_type)
|
||||
&& row.try_get::<String, _>("is_nullable").ok().as_deref()
|
||||
== Some(if nullable { "YES" } else { "NO" })
|
||||
}))
|
||||
}
|
||||
|
||||
async fn validate_identity_constraints(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
let identity_epoch = constraint_expression(
|
||||
connection,
|
||||
"master_key_identities",
|
||||
"master_key_identities_epoch_check",
|
||||
)
|
||||
.await?;
|
||||
if normalize_definition(&identity_epoch) != "epoch>0" {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
let identity_fingerprint = constraint_expression(
|
||||
connection,
|
||||
"master_key_identities",
|
||||
"master_key_identities_fingerprint_check",
|
||||
)
|
||||
.await?;
|
||||
if !regex_constraint_matches(&identity_fingerprint, "fingerprint", "^[0-9a-f]{64}$") {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
let identity_cipher = constraint_expression(
|
||||
connection,
|
||||
"master_key_identities",
|
||||
"master_key_identities_cipher_contract_check",
|
||||
)
|
||||
.await?;
|
||||
if !enum_constraint_matches(
|
||||
&identity_cipher,
|
||||
"cipher_contract",
|
||||
&["secret-envelope-v2/aes-256-gcm-hkdf-sha256"],
|
||||
) {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
let identity_status = constraint_expression(
|
||||
connection,
|
||||
"master_key_identities",
|
||||
"master_key_identities_status_check",
|
||||
)
|
||||
.await?;
|
||||
if enum_constraint_matches(
|
||||
&identity_status,
|
||||
"status",
|
||||
&["active", "pending", "retired", "revoked"],
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(schema_error(7))
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_rotation_constraints(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
let rotation_state = constraint_expression(
|
||||
connection,
|
||||
"master_key_rotations",
|
||||
"master_key_rotations_state_check",
|
||||
)
|
||||
.await?;
|
||||
if enum_constraint_matches(
|
||||
&rotation_state,
|
||||
"state",
|
||||
&[
|
||||
"preflighted",
|
||||
"running",
|
||||
"verifying",
|
||||
"verified",
|
||||
"promoted",
|
||||
"aborted",
|
||||
"failed",
|
||||
],
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(schema_error(7))
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_secret_version_constraints(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
let secret_epoch = constraint_expression(
|
||||
connection,
|
||||
"secret_versions",
|
||||
"secret_versions_master_key_epoch_check",
|
||||
)
|
||||
.await?;
|
||||
if normalize_definition(&secret_epoch) != "master_key_epoch>0" {
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
let secret_target_epoch = constraint_expression(
|
||||
connection,
|
||||
"secret_versions",
|
||||
"secret_versions_target_epoch_check",
|
||||
)
|
||||
.await?;
|
||||
if normalize_definition(&secret_target_epoch)
|
||||
!= "target_master_key_epochisnullortarget_master_key_epoch>master_key_epoch"
|
||||
{
|
||||
return Err(schema_error(7));
|
||||
}
|
||||
let secret_all_or_none = constraint_expression(
|
||||
connection,
|
||||
"secret_versions",
|
||||
"secret_versions_target_all_or_none_check",
|
||||
)
|
||||
.await?;
|
||||
if target_ciphertext_all_or_none_matches(&secret_all_or_none) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(schema_error(7))
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_active_identity_index(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
let active_index = query(
|
||||
"select t.relname as table_name, am.amname as access_method, i.indisvalid, i.indisready,
|
||||
i.indisunique, pg_get_indexdef(i.indexrelid, 1, true) as first_column,
|
||||
pg_get_expr(i.indpred, i.indrelid) as predicate
|
||||
from pg_catalog.pg_index i
|
||||
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
|
||||
join pg_catalog.pg_class t on t.oid = i.indrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
join pg_catalog.pg_am am on am.oid = idx.relam
|
||||
where n.nspname = current_schema()
|
||||
and idx.relname = 'master_key_identities_active_idx'",
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = active_index.is_some_and(|row| {
|
||||
row.try_get::<String, _>("table_name").ok().as_deref() == Some("master_key_identities")
|
||||
&& row.try_get::<String, _>("access_method").ok().as_deref() == Some("btree")
|
||||
&& row.try_get::<bool, _>("indisvalid").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisready").ok() == Some(true)
|
||||
&& row.try_get::<bool, _>("indisunique").ok() == Some(true)
|
||||
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("status")
|
||||
&& row
|
||||
.try_get::<String, _>("predicate")
|
||||
.ok()
|
||||
.is_some_and(|value| normalize_definition(&value) == "status='active'::text")
|
||||
});
|
||||
if valid { Ok(()) } else { Err(schema_error(7)) }
|
||||
}
|
||||
|
||||
fn regex_constraint_matches(expression: &str, column: &str, pattern: &str) -> bool {
|
||||
let normalized = normalize_definition(expression);
|
||||
if !normalized.contains(column) || normalized.contains("ortrue") {
|
||||
return false;
|
||||
}
|
||||
let values = expression
|
||||
.split('\'')
|
||||
.enumerate()
|
||||
.filter_map(|(index, value)| (index % 2 == 1).then_some(value))
|
||||
.collect::<Vec<_>>();
|
||||
values == [pattern]
|
||||
}
|
||||
|
||||
fn target_ciphertext_all_or_none_matches(expression: &str) -> bool {
|
||||
let normalized = normalize_definition(expression);
|
||||
!normalized.contains("ortrue")
|
||||
&& normalized.contains("target_ciphertextisnull")
|
||||
&& normalized.contains("target_key_versionisnull")
|
||||
&& normalized.contains("target_master_key_epochisnull")
|
||||
&& normalized.contains("target_ciphertextisnotnull")
|
||||
&& normalized.contains("target_key_versionisnotnull")
|
||||
&& normalized.contains("target_master_key_epochisnotnull")
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use sqlx::PgConnection;
|
||||
|
||||
use super::authority::MigrationError;
|
||||
use super::schema_guard::{column_exists, relation_exists, schema_error};
|
||||
|
||||
pub(super) async fn validate_v8_admin_auth_lifecycle(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
for relation in [
|
||||
"admin_bootstrap_contracts",
|
||||
"admin_bootstrap_contracts_single_active_idx",
|
||||
"admin_bootstrap_contracts_token_hash_idx",
|
||||
"admin_login_backoff",
|
||||
"admin_security_audit_events",
|
||||
"admin_security_audit_events_created_idx",
|
||||
] {
|
||||
if !relation_exists(connection, relation).await? {
|
||||
return Err(schema_error(8));
|
||||
}
|
||||
}
|
||||
for column in ["csrf_hash", "revoked_at"] {
|
||||
if !column_exists(connection, "user_sessions", column).await? {
|
||||
return Err(schema_error(8));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use sqlx::{PgConnection, Row, query};
|
||||
|
||||
use super::authority::MigrationError;
|
||||
use super::schema_guard::{
|
||||
column_exists, constraint_expression, normalize_definition, schema_error,
|
||||
};
|
||||
|
||||
pub(super) async fn validate_v9_absent(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<bool, MigrationError> {
|
||||
let columns_present = column_exists(connection, "agents", "catalog_revision").await?
|
||||
|| column_exists(connection, "published_agents", "catalog_revision").await?;
|
||||
let triggers_present = trigger_exists(
|
||||
connection,
|
||||
"agent_versions",
|
||||
"agent_versions_immutable_guard",
|
||||
)
|
||||
.await?
|
||||
|| trigger_exists(
|
||||
connection,
|
||||
"agent_operation_bindings",
|
||||
"agent_operation_bindings_immutable_guard",
|
||||
)
|
||||
.await?
|
||||
|| trigger_exists(
|
||||
connection,
|
||||
"published_agents",
|
||||
"published_agents_monotonic_guard",
|
||||
)
|
||||
.await?;
|
||||
Ok(!columns_present && !triggers_present)
|
||||
}
|
||||
|
||||
pub(super) async fn validate_v9_agent_catalog_lifecycle(
|
||||
connection: &mut PgConnection,
|
||||
) -> Result<(), MigrationError> {
|
||||
for (table, column) in [
|
||||
("agents", "catalog_revision"),
|
||||
("published_agents", "catalog_revision"),
|
||||
] {
|
||||
let row = query(
|
||||
"select data_type, is_nullable
|
||||
from information_schema.columns
|
||||
where table_schema = current_schema()
|
||||
and table_name = $1
|
||||
and column_name = $2",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(column)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
let valid = row.is_some_and(|row| {
|
||||
row.try_get::<String, _>("data_type").ok().as_deref() == Some("bigint")
|
||||
&& row.try_get::<String, _>("is_nullable").ok().as_deref() == Some("NO")
|
||||
});
|
||||
if !valid {
|
||||
return Err(schema_error(9));
|
||||
}
|
||||
}
|
||||
|
||||
let agents_revision =
|
||||
constraint_expression(connection, "agents", "agents_catalog_revision_check").await?;
|
||||
if normalize_definition(&agents_revision) != "catalog_revision>=0" {
|
||||
return Err(schema_error(9));
|
||||
}
|
||||
let published_revision = constraint_expression(
|
||||
connection,
|
||||
"published_agents",
|
||||
"published_agents_catalog_revision_check",
|
||||
)
|
||||
.await?;
|
||||
if normalize_definition(&published_revision) != "catalog_revision>0" {
|
||||
return Err(schema_error(9));
|
||||
}
|
||||
|
||||
for (table, trigger, expected_events, expected_function, function_snippets) in [
|
||||
(
|
||||
"agent_versions",
|
||||
"agent_versions_immutable_guard",
|
||||
&["before", "update", "delete"][..],
|
||||
"crank_reject_published_agent_version_mutation",
|
||||
&["old.status='published'", "returnold", "returnnew"][..],
|
||||
),
|
||||
(
|
||||
"agent_operation_bindings",
|
||||
"agent_operation_bindings_immutable_guard",
|
||||
&["before", "insert", "update", "delete"][..],
|
||||
"crank_reject_published_agent_binding_mutation",
|
||||
&[
|
||||
"coalescenew.agent_id,old.agent_id",
|
||||
"bound_status='published'",
|
||||
"returnold",
|
||||
"returnnew",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"published_agents",
|
||||
"published_agents_monotonic_guard",
|
||||
&["before", "insert", "update", "delete"][..],
|
||||
"crank_reject_published_agent_pointer_rewind",
|
||||
&[
|
||||
"tg_op='delete'",
|
||||
"new.catalog_revision<=old.catalog_revision",
|
||||
"new.version<old.version",
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
let Some(trigger_definition) = trigger_definition(connection, table, trigger).await? else {
|
||||
return Err(schema_error(9));
|
||||
};
|
||||
let normalized_trigger = normalize_definition(&trigger_definition);
|
||||
if expected_events
|
||||
.iter()
|
||||
.any(|event| !normalized_trigger.contains(event))
|
||||
|| !normalized_trigger.contains(expected_function)
|
||||
{
|
||||
return Err(schema_error(9));
|
||||
}
|
||||
let function_definition = function_definition(connection, expected_function).await?;
|
||||
let normalized_function = normalize_definition(&function_definition);
|
||||
if function_snippets
|
||||
.iter()
|
||||
.any(|snippet| !normalized_function.contains(snippet))
|
||||
{
|
||||
return Err(schema_error(9));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn trigger_exists(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
trigger: &str,
|
||||
) -> Result<bool, MigrationError> {
|
||||
query(
|
||||
"select exists (
|
||||
select 1
|
||||
from pg_catalog.pg_trigger trg
|
||||
join pg_catalog.pg_class t on t.oid = trg.tgrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and t.relname = $1
|
||||
and trg.tgname = $2
|
||||
and not trg.tgisinternal
|
||||
) as present",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(trigger)
|
||||
.fetch_one(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.try_get::<bool, _>("present")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
|
||||
async fn trigger_definition(
|
||||
connection: &mut PgConnection,
|
||||
table: &str,
|
||||
trigger: &str,
|
||||
) -> Result<Option<String>, MigrationError> {
|
||||
let row = query(
|
||||
"select pg_get_triggerdef(trg.oid) as definition
|
||||
from pg_catalog.pg_trigger trg
|
||||
join pg_catalog.pg_class t on t.oid = trg.tgrelid
|
||||
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
|
||||
where n.nspname = current_schema()
|
||||
and t.relname = $1
|
||||
and trg.tgname = $2
|
||||
and not trg.tgisinternal",
|
||||
)
|
||||
.bind(table)
|
||||
.bind(trigger)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?;
|
||||
row.map(|row| row.try_get::<String, _>("definition"))
|
||||
.transpose()
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
|
||||
async fn function_definition(
|
||||
connection: &mut PgConnection,
|
||||
function_name: &str,
|
||||
) -> Result<String, MigrationError> {
|
||||
query(
|
||||
"select pg_get_functiondef(p.oid) as definition
|
||||
from pg_catalog.pg_proc p
|
||||
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = current_schema()
|
||||
and p.proname = $1",
|
||||
)
|
||||
.bind(function_name)
|
||||
.fetch_optional(connection)
|
||||
.await
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))?
|
||||
.ok_or_else(|| schema_error(9))?
|
||||
.try_get::<String, _>("definition")
|
||||
.map_err(|_| MigrationError::storage("preflight.schema"))
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
|
||||
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExportMode,
|
||||
InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, Operation,
|
||||
OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
||||
Protocol, SampleId, Secret, SecretId, SecretVersion, ToolSelectionPolicy, UsagePeriod,
|
||||
UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
|
||||
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExecutionErrorCode,
|
||||
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource,
|
||||
InvocationStatus, MembershipRole, OnboardingProjection, Operation, OperationId,
|
||||
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, ProductEvent,
|
||||
ProductEventId, ProductEventKind, Protocol, SampleId, Secret, SecretId, SecretVersion,
|
||||
ToolSelectionPolicy, UsagePeriod, UsageRollup, User, UserId, UserSessionId, Workspace,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_schema::Schema;
|
||||
@@ -84,6 +86,56 @@ pub struct SessionRecord {
|
||||
pub user: User,
|
||||
pub memberships: Vec<WorkspaceMembershipRecord>,
|
||||
pub current_workspace_id: Option<WorkspaceId>,
|
||||
pub csrf_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdminBootstrapContractRecord {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub status: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub expires_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub used_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CreateAdminBootstrapContractRequest<'a> {
|
||||
pub id: &'a str,
|
||||
pub token_hash: &'a str,
|
||||
pub email: &'a str,
|
||||
pub display_name: &'a str,
|
||||
pub expires_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConsumeAdminBootstrapContractRequest<'a> {
|
||||
pub token_hash: &'a str,
|
||||
pub password_hash: &'a str,
|
||||
pub now: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AdminSecurityAuditRequest<'a> {
|
||||
pub id: &'a str,
|
||||
pub action: &'a str,
|
||||
pub outcome: &'a str,
|
||||
pub actor_user_id: Option<&'a UserId>,
|
||||
pub session_id: Option<&'a UserSessionId>,
|
||||
pub request_id: Option<&'a str>,
|
||||
pub trace_id: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RecoverAdminPasswordRequest<'a> {
|
||||
pub email: &'a str,
|
||||
pub password_hash: &'a str,
|
||||
pub audit_id: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -109,6 +161,60 @@ pub struct SecretRecord {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SecretVersionRecord {
|
||||
pub secret_version: SecretVersion,
|
||||
pub master_key_epoch: i64,
|
||||
pub target_ciphertext: Option<String>,
|
||||
pub target_key_version: Option<String>,
|
||||
pub target_master_key_epoch: Option<i64>,
|
||||
}
|
||||
|
||||
pub const MASTER_KEY_CIPHER_CONTRACT: &str = "secret-envelope-v2/aes-256-gcm-hkdf-sha256";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MasterKeyIdentityRecord {
|
||||
pub epoch: i64,
|
||||
pub fingerprint: String,
|
||||
pub cipher_contract: String,
|
||||
pub status: String,
|
||||
pub backup_ref: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub activated_at: Option<OffsetDateTime>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub retired_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MasterKeyIdentityCandidate<'a> {
|
||||
pub epoch: i64,
|
||||
pub fingerprint: &'a str,
|
||||
pub cipher_contract: &'a str,
|
||||
pub observed_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MasterKeyRotationRecord {
|
||||
pub id: String,
|
||||
pub source_epoch: i64,
|
||||
pub target_epoch: i64,
|
||||
pub target_fingerprint: String,
|
||||
pub state: String,
|
||||
pub backup_ref: Option<String>,
|
||||
pub checkpoint_secret_id: Option<String>,
|
||||
pub total_secret_versions: i64,
|
||||
pub processed_secret_versions: i64,
|
||||
pub verified_secret_versions: i64,
|
||||
pub failure_code: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MasterKeyRotationStatus {
|
||||
pub active_identity: Option<MasterKeyIdentityRecord>,
|
||||
pub rotations: Vec<MasterKeyRotationRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -170,6 +276,114 @@ pub struct UsageAgentBreakdown {
|
||||
pub p99_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageOutcomeGroup {
|
||||
Success,
|
||||
Upstream,
|
||||
Client,
|
||||
Schema,
|
||||
Crank,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UsageOutcomeBreakdown {
|
||||
pub group: UsageOutcomeGroup,
|
||||
pub execution_error_code: Option<ExecutionErrorCode>,
|
||||
pub calls_total: u64,
|
||||
pub p50_ms: u64,
|
||||
pub p95_ms: u64,
|
||||
pub p99_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AppendProductEventOutcome {
|
||||
Recorded,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProductEventRecord {
|
||||
pub event: ProductEvent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppendProductEventRequest<'a> {
|
||||
pub event: &'a ProductEvent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ListProductEventsQuery<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub kind: Option<ProductEventKind>,
|
||||
pub created_after: OffsetDateTime,
|
||||
pub created_before: OffsetDateTime,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OnboardingPresentationMilestone {
|
||||
Eligible,
|
||||
Started,
|
||||
Resumed,
|
||||
Dismissed,
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecordOnboardingMilestoneRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub event_id: &'a ProductEventId,
|
||||
pub milestone: OnboardingPresentationMilestone,
|
||||
pub idempotency_key: &'a str,
|
||||
pub expected_revision: i64,
|
||||
pub occurred_at: OffsetDateTime,
|
||||
pub eligible_since: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecordOnboardingCompletionRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub event_id: &'a ProductEventId,
|
||||
pub idempotency_key: &'a str,
|
||||
pub expected_revision: i64,
|
||||
pub occurred_at: OffsetDateTime,
|
||||
pub eligible_since: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OnboardingMilestoneResult {
|
||||
pub accepted: bool,
|
||||
pub projection: OnboardingProjection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvocationRetentionStatus {
|
||||
Noop,
|
||||
Completed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InvocationRetentionPolicy {
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub requested_cutoff: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub effective_cutoff: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub usage_preservation_floor: OffsetDateTime,
|
||||
pub preserved_usage_window_days: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InvocationRetentionOutcome {
|
||||
pub status: InvocationRetentionStatus,
|
||||
pub deleted_records: u64,
|
||||
pub policy: InvocationRetentionPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentSummary {
|
||||
pub id: AgentId,
|
||||
@@ -180,6 +394,7 @@ pub struct AgentSummary {
|
||||
pub status: AgentStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
pub catalog_revision: i64,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
pub published_at: Option<OffsetDateTime>,
|
||||
@@ -196,6 +411,15 @@ pub struct AgentVersionRecord {
|
||||
pub bindings: Vec<AgentOperationBinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AgentStateExpectation {
|
||||
pub status: AgentStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
pub catalog_revision: i64,
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PublishedAgentTool {
|
||||
pub workspace_id: WorkspaceId,
|
||||
@@ -211,6 +435,7 @@ pub struct PublishedAgentTool {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PublishedAgentCatalog {
|
||||
pub agent_version: u32,
|
||||
pub catalog_revision: String,
|
||||
pub tool_selection_policy: ToolSelectionPolicy,
|
||||
pub tools: Vec<PublishedAgentTool>,
|
||||
}
|
||||
@@ -229,6 +454,7 @@ pub struct OperationSummary {
|
||||
pub status: OperationStatus,
|
||||
pub current_draft_version: u32,
|
||||
pub latest_published_version: Option<u32>,
|
||||
pub can_delete: bool,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
pub published_at: Option<OffsetDateTime>,
|
||||
@@ -485,11 +711,16 @@ impl InvocationHistoryWriteOutcome {
|
||||
pub struct ListInvocationLogsQuery<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub level: Option<InvocationLevel>,
|
||||
pub status: Option<InvocationStatus>,
|
||||
pub outcome_group: Option<UsageOutcomeGroup>,
|
||||
pub search_text: Option<&'a str>,
|
||||
pub source: Option<InvocationSource>,
|
||||
pub operation_id: Option<&'a OperationId>,
|
||||
pub agent_id: Option<&'a AgentId>,
|
||||
pub created_after: Option<&'a str>,
|
||||
pub created_before: Option<&'a str>,
|
||||
pub cursor_created_at: Option<&'a str>,
|
||||
pub cursor_id: Option<&'a InvocationLogId>,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
@@ -507,6 +738,7 @@ pub struct UsageQuery<'a> {
|
||||
pub period: UsagePeriod,
|
||||
pub source: Option<InvocationSource>,
|
||||
pub created_after: &'a str,
|
||||
pub created_before: &'a str,
|
||||
pub bucket: UsageBucket,
|
||||
}
|
||||
|
||||
@@ -524,6 +756,13 @@ pub struct CreateVersionRequest<'a> {
|
||||
pub created_by: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OperationStateExpectation {
|
||||
pub current_draft_version: u32,
|
||||
pub status: OperationStatus,
|
||||
pub latest_published_version: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PublishRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
@@ -579,12 +818,22 @@ pub struct CreateAgentDraftVersionRequest<'a> {
|
||||
pub updated_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UpdateAgentSummaryRequest<'a> {
|
||||
pub slug: &'a str,
|
||||
pub display_name: &'a str,
|
||||
pub description: &'a str,
|
||||
pub updated_at: &'a OffsetDateTime,
|
||||
pub expected_state: Option<&'a AgentStateExpectation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SaveAgentBindingsRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub agent_id: &'a AgentId,
|
||||
pub agent_version: u32,
|
||||
pub bindings: &'a [AgentOperationBinding],
|
||||
pub expected_state: Option<&'a AgentStateExpectation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -594,6 +843,7 @@ pub struct SaveAgentCatalogConfigRequest<'a> {
|
||||
pub agent_version: u32,
|
||||
pub bindings: &'a [AgentOperationBinding],
|
||||
pub tool_selection_policy: &'a ToolSelectionPolicy,
|
||||
pub expected_state: Option<&'a AgentStateExpectation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -603,6 +853,7 @@ pub struct PublishAgentRequest<'a> {
|
||||
pub version: u32,
|
||||
pub published_at: &'a OffsetDateTime,
|
||||
pub published_by: Option<&'a str>,
|
||||
pub expected_state: Option<&'a AgentStateExpectation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -633,9 +884,12 @@ pub struct DecideApprovalRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub agent_id: &'a AgentId,
|
||||
pub approval_id: &'a ApprovalRequestId,
|
||||
pub operation_id: &'a OperationId,
|
||||
pub operation_version: u32,
|
||||
pub request_payload: &'a Value,
|
||||
pub status: ApprovalRequestStatus,
|
||||
pub decided_at: OffsetDateTime,
|
||||
pub decided_by_key_id: &'a PlatformApiKeyId,
|
||||
pub decided_by_key_id: Option<&'a PlatformApiKeyId>,
|
||||
pub response_payload: Option<Value>,
|
||||
pub decision_note: Option<&'a str>,
|
||||
}
|
||||
@@ -663,6 +917,7 @@ pub struct CreateSecretRequest<'a> {
|
||||
pub secret: &'a Secret,
|
||||
pub ciphertext: &'a str,
|
||||
pub key_version: &'a str,
|
||||
pub master_key_epoch: i64,
|
||||
pub created_by: Option<&'a crank_core::UserId>,
|
||||
}
|
||||
|
||||
@@ -672,6 +927,7 @@ pub struct RotateSecretRequest<'a> {
|
||||
pub secret_id: &'a SecretId,
|
||||
pub ciphertext: &'a str,
|
||||
pub key_version: &'a str,
|
||||
pub master_key_epoch: i64,
|
||||
pub created_at: &'a OffsetDateTime,
|
||||
pub updated_at: &'a OffsetDateTime,
|
||||
pub created_by: Option<&'a crank_core::UserId>,
|
||||
|
||||
@@ -1,47 +1,12 @@
|
||||
use super::*;
|
||||
use crate::model::{AgentStateExpectation, UpdateAgentSummaryRequest};
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn get_published_agent_catalog_by_slug(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<PublishedAgentCatalog, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
pa.version,
|
||||
av.tool_selection_policy_json
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_versions av on av.agent_id = a.id and av.version = pa.version
|
||||
where w.slug = $1 and a.slug = $2",
|
||||
)
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or_else(|| RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug: workspace_slug.to_owned(),
|
||||
agent_slug: agent_slug.to_owned(),
|
||||
})?;
|
||||
let tools = self
|
||||
.get_published_agent_tools_by_slug(workspace_slug, agent_slug)
|
||||
.await?;
|
||||
|
||||
Ok(PublishedAgentCatalog {
|
||||
agent_version: from_db_version(row.try_get("version")?, "agent_version")?,
|
||||
tool_selection_policy: deserialize_json_value(
|
||||
row.try_get("tool_selection_policy_json")?,
|
||||
)?,
|
||||
tools,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_agents(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<Vec<AgentSummary>, RegistryError> {
|
||||
let rows = sqlx::query!(
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
workspace_id,
|
||||
@@ -51,31 +16,33 @@ impl PostgresRegistry {
|
||||
status,
|
||||
current_draft_version,
|
||||
latest_published_version,
|
||||
created_at as \"created_at!: time::OffsetDateTime\",
|
||||
updated_at as \"updated_at!: time::OffsetDateTime\",
|
||||
published_at as \"published_at: time::OffsetDateTime\"
|
||||
catalog_revision,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at
|
||||
from agents
|
||||
where workspace_id = $1
|
||||
order by slug asc",
|
||||
workspace_id.as_str(),
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
build_agent_summary(
|
||||
row.id,
|
||||
row.workspace_id,
|
||||
row.slug,
|
||||
row.display_name,
|
||||
row.description,
|
||||
row.status,
|
||||
row.current_draft_version,
|
||||
row.latest_published_version,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
row.published_at,
|
||||
row.try_get("id")?,
|
||||
row.try_get("workspace_id")?,
|
||||
row.try_get("slug")?,
|
||||
row.try_get("display_name")?,
|
||||
row.try_get("description")?,
|
||||
row.try_get("status")?,
|
||||
row.try_get("current_draft_version")?,
|
||||
row.try_get("latest_published_version")?,
|
||||
row.try_get("catalog_revision")?,
|
||||
row.try_get("created_at")?,
|
||||
row.try_get("updated_at")?,
|
||||
row.try_get("published_at")?,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
@@ -86,7 +53,7 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<Option<AgentSummary>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
workspace_id,
|
||||
@@ -96,30 +63,32 @@ impl PostgresRegistry {
|
||||
status,
|
||||
current_draft_version,
|
||||
latest_published_version,
|
||||
created_at as \"created_at!: time::OffsetDateTime\",
|
||||
updated_at as \"updated_at!: time::OffsetDateTime\",
|
||||
published_at as \"published_at: time::OffsetDateTime\"
|
||||
catalog_revision,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at
|
||||
from agents
|
||||
where workspace_id = $1 and id = $2",
|
||||
workspace_id.as_str(),
|
||||
agent_id.as_str(),
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
build_agent_summary(
|
||||
row.id,
|
||||
row.workspace_id,
|
||||
row.slug,
|
||||
row.display_name,
|
||||
row.description,
|
||||
row.status,
|
||||
row.current_draft_version,
|
||||
row.latest_published_version,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
row.published_at,
|
||||
row.try_get("id")?,
|
||||
row.try_get("workspace_id")?,
|
||||
row.try_get("slug")?,
|
||||
row.try_get("display_name")?,
|
||||
row.try_get("description")?,
|
||||
row.try_get("status")?,
|
||||
row.try_get("current_draft_version")?,
|
||||
row.try_get("latest_published_version")?,
|
||||
row.try_get("catalog_revision")?,
|
||||
row.try_get("created_at")?,
|
||||
row.try_get("updated_at")?,
|
||||
row.try_get("published_at")?,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
@@ -162,6 +131,7 @@ impl PostgresRegistry {
|
||||
insert_agent_version_row(&mut tx, request.version).await?;
|
||||
replace_agent_bindings_rows(
|
||||
&mut tx,
|
||||
&request.agent.workspace_id,
|
||||
&request.agent.id,
|
||||
request.version.version,
|
||||
request.bindings,
|
||||
@@ -198,6 +168,7 @@ impl PostgresRegistry {
|
||||
insert_agent_version_row(&mut tx, request.version).await?;
|
||||
replace_agent_bindings_rows(
|
||||
&mut tx,
|
||||
request.workspace_id,
|
||||
request.agent_id,
|
||||
request.version.version,
|
||||
request.bindings,
|
||||
@@ -268,19 +239,19 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: SaveAgentBindingsRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
if self
|
||||
.get_agent_summary(request.workspace_id, request.agent_id)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
request.workspace_id,
|
||||
request.agent_id,
|
||||
request.expected_state,
|
||||
)
|
||||
.await?;
|
||||
Self::reject_published_agent_version(&mut tx, request.agent_id, request.agent_version)
|
||||
.await?;
|
||||
replace_agent_bindings_rows(
|
||||
&mut tx,
|
||||
request.workspace_id,
|
||||
request.agent_id,
|
||||
request.agent_version,
|
||||
request.bindings,
|
||||
@@ -294,17 +265,16 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: SaveAgentCatalogConfigRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
if self
|
||||
.get_agent_summary(request.workspace_id, request.agent_id)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
request.workspace_id,
|
||||
request.agent_id,
|
||||
request.expected_state,
|
||||
)
|
||||
.await?;
|
||||
Self::reject_published_agent_version(&mut tx, request.agent_id, request.agent_version)
|
||||
.await?;
|
||||
let updated = sqlx::query(
|
||||
"update agent_versions
|
||||
set tool_selection_policy_json = $3
|
||||
@@ -323,24 +293,124 @@ impl PostgresRegistry {
|
||||
}
|
||||
replace_agent_bindings_rows(
|
||||
&mut tx,
|
||||
request.workspace_id,
|
||||
request.agent_id,
|
||||
request.agent_version,
|
||||
request.bindings,
|
||||
)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update agents
|
||||
set updated_at = now()
|
||||
where id = $1 and workspace_id = $2",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(request.workspace_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn lock_agent_and_validate_expected_state(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
status,
|
||||
current_draft_version,
|
||||
latest_published_version,
|
||||
catalog_revision,
|
||||
updated_at
|
||||
from agents
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
if let Some(expected) = expected_state {
|
||||
let status = deserialize_enum_text::<AgentStatus>(row.try_get("status")?, "status")?;
|
||||
let current_draft_version = from_db_version(
|
||||
row.try_get("current_draft_version")?,
|
||||
"current_draft_version",
|
||||
)?;
|
||||
let latest_published_version = row
|
||||
.try_get::<Option<i32>, _>("latest_published_version")?
|
||||
.map(|value| from_db_version(value, "latest_published_version"))
|
||||
.transpose()?;
|
||||
let catalog_revision: i64 = row.try_get("catalog_revision")?;
|
||||
let updated_at: time::OffsetDateTime = row.try_get("updated_at")?;
|
||||
|
||||
if status != expected.status
|
||||
|| current_draft_version != expected.current_draft_version
|
||||
|| latest_published_version != expected.latest_published_version
|
||||
|| catalog_revision != expected.catalog_revision
|
||||
|| updated_at != expected.updated_at
|
||||
{
|
||||
return Err(RegistryError::AgentStaleRevision {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reject_published_agent_version(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
agent_id: &AgentId,
|
||||
version: u32,
|
||||
) -> Result<(), RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select status
|
||||
from agent_versions
|
||||
where agent_id = $1 and version = $2
|
||||
for update",
|
||||
)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(to_db_version(version))
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
let status = deserialize_enum_text::<AgentStatus>(row.try_get("status")?, "status")?;
|
||||
if status == AgentStatus::Published {
|
||||
return Err(RegistryError::ImmutableAgentVersion {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
version,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_agent_summary(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
slug: &str,
|
||||
display_name: &str,
|
||||
description: &str,
|
||||
updated_at: &time::OffsetDateTime,
|
||||
update: UpdateAgentSummaryRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
update.expected_state,
|
||||
)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"update agents
|
||||
set slug = $3,
|
||||
@@ -351,11 +421,11 @@ impl PostgresRegistry {
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(slug)
|
||||
.bind(display_name)
|
||||
.bind(description)
|
||||
.bind(updated_at)
|
||||
.execute(&self.pool)
|
||||
.bind(update.slug)
|
||||
.bind(update.display_name)
|
||||
.bind(update.description)
|
||||
.bind(update.updated_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
@@ -364,6 +434,7 @@ impl PostgresRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -371,19 +442,36 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query("delete from agents where workspace_id = $1 and id = $2")
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
expected_state,
|
||||
)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"delete from agents
|
||||
where workspace_id = $1
|
||||
and id = $2
|
||||
and latest_published_version is null",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
return Err(RegistryError::InvalidAgentTransition {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
from: "published".to_owned(),
|
||||
action: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -391,37 +479,102 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: PublishAgentRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
if self
|
||||
.get_agent_version(request.workspace_id, request.agent_id, request.version)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let Some(agent_row) = sqlx::query(
|
||||
"select status, current_draft_version, latest_published_version, catalog_revision, updated_at
|
||||
from agents
|
||||
where id = $1 and workspace_id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(request.workspace_id.as_str())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
else {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
if let Some(expected) = request.expected_state {
|
||||
let status =
|
||||
deserialize_enum_text::<AgentStatus>(agent_row.try_get("status")?, "status")?;
|
||||
let expected_current_draft_version = from_db_version(
|
||||
agent_row.try_get("current_draft_version")?,
|
||||
"current_draft_version",
|
||||
)?;
|
||||
let expected_latest_published_version = agent_row
|
||||
.try_get::<Option<i32>, _>("latest_published_version")?
|
||||
.map(|value| from_db_version(value, "latest_published_version"))
|
||||
.transpose()?;
|
||||
let expected_catalog_revision: i64 = agent_row.try_get("catalog_revision")?;
|
||||
let expected_updated_at: time::OffsetDateTime = agent_row.try_get("updated_at")?;
|
||||
if status != expected.status
|
||||
|| expected_current_draft_version != expected.current_draft_version
|
||||
|| expected_latest_published_version != expected.latest_published_version
|
||||
|| expected_catalog_revision != expected.catalog_revision
|
||||
|| expected_updated_at != expected.updated_at
|
||||
{
|
||||
return Err(RegistryError::AgentStaleRevision {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let agent_status =
|
||||
deserialize_enum_text::<AgentStatus>(agent_row.try_get("status")?, "status")?;
|
||||
if agent_status == AgentStatus::Archived {
|
||||
return Err(RegistryError::InvalidAgentTransition {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
from: "archived".to_owned(),
|
||||
action: "publish",
|
||||
});
|
||||
}
|
||||
let current_draft_version = from_db_version(
|
||||
agent_row.try_get("current_draft_version")?,
|
||||
"current_draft_version",
|
||||
)?;
|
||||
if request.version != current_draft_version {
|
||||
return Err(RegistryError::InvalidAgentVersionSequence {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
expected: current_draft_version,
|
||||
actual: request.version,
|
||||
});
|
||||
}
|
||||
let latest_published_version = agent_row
|
||||
.try_get::<Option<i32>, _>("latest_published_version")?
|
||||
.map(|value| from_db_version(value, "latest_published_version"))
|
||||
.transpose()?;
|
||||
if latest_published_version.is_some_and(|latest| request.version < latest) {
|
||||
return Err(RegistryError::InvalidAgentVersionSequence {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
expected: latest_published_version.unwrap_or(request.version),
|
||||
actual: request.version,
|
||||
});
|
||||
}
|
||||
let catalog_revision: i64 = agent_row.try_get("catalog_revision")?;
|
||||
let next_catalog_revision = catalog_revision
|
||||
.checked_add(1)
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or(RegistryError::InvalidNumericValue {
|
||||
field: "catalog_revision",
|
||||
value: catalog_revision,
|
||||
})?;
|
||||
|
||||
let version_exists = sqlx::query(
|
||||
"select 1
|
||||
from agent_versions
|
||||
where agent_id = $1 and version = $2",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(to_db_version(request.version))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.is_some();
|
||||
if !version_exists {
|
||||
return Err(RegistryError::AgentNotFound {
|
||||
agent_id: request.agent_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query(
|
||||
"insert into published_agents (
|
||||
agent_id,
|
||||
version,
|
||||
published_at,
|
||||
published_by
|
||||
) values ($1, $2, $3::timestamptz, $4)
|
||||
on conflict(agent_id) do update set
|
||||
version = excluded.version,
|
||||
published_at = excluded.published_at,
|
||||
published_by = excluded.published_by",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(to_db_version(request.version))
|
||||
.bind(request.published_at)
|
||||
.bind(request.published_by)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"update agent_versions
|
||||
set status = $1
|
||||
@@ -433,16 +586,40 @@ impl PostgresRegistry {
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"insert into published_agents (
|
||||
agent_id,
|
||||
version,
|
||||
catalog_revision,
|
||||
published_at,
|
||||
published_by
|
||||
) values ($1, $2, $3, $4::timestamptz, $5)
|
||||
on conflict(agent_id) do update set
|
||||
version = excluded.version,
|
||||
catalog_revision = excluded.catalog_revision,
|
||||
published_at = excluded.published_at,
|
||||
published_by = excluded.published_by",
|
||||
)
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(to_db_version(request.version))
|
||||
.bind(next_catalog_revision)
|
||||
.bind(request.published_at)
|
||||
.bind(request.published_by)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"update agents
|
||||
set status = $1,
|
||||
latest_published_version = $2,
|
||||
published_at = $3::timestamptz,
|
||||
updated_at = $4::timestamptz
|
||||
where id = $5 and workspace_id = $6",
|
||||
catalog_revision = $3,
|
||||
published_at = $4::timestamptz,
|
||||
updated_at = $5::timestamptz
|
||||
where id = $6 and workspace_id = $7",
|
||||
)
|
||||
.bind(serialize_enum_text(&AgentStatus::Published, "status")?)
|
||||
.bind(to_db_version(request.version))
|
||||
.bind(next_catalog_revision)
|
||||
.bind(request.published_at)
|
||||
.bind(request.published_at)
|
||||
.bind(request.agent_id.as_str())
|
||||
@@ -459,38 +636,55 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
updated_at: &time::OffsetDateTime,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query("delete from published_agents where agent_id = $1")
|
||||
.bind(agent_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"update agent_versions
|
||||
set status = $1
|
||||
where agent_id = $2
|
||||
and version = (
|
||||
select current_draft_version
|
||||
from agents
|
||||
where id = $2 and workspace_id = $3
|
||||
)",
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
expected_state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let current_status = sqlx::query(
|
||||
"select status, catalog_revision
|
||||
from agents
|
||||
where id = $1 and workspace_id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(serialize_enum_text(&AgentStatus::Draft, "status")?)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let status =
|
||||
deserialize_enum_text::<AgentStatus>(current_status.try_get("status")?, "status")?;
|
||||
if status != AgentStatus::Published {
|
||||
return Err(RegistryError::InvalidAgentTransition {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
from: serialize_enum_text(&status, "status")?,
|
||||
action: "unpublish",
|
||||
});
|
||||
}
|
||||
let catalog_revision: i64 = current_status.try_get("catalog_revision")?;
|
||||
let next_catalog_revision = catalog_revision
|
||||
.checked_add(1)
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or(RegistryError::InvalidNumericValue {
|
||||
field: "catalog_revision",
|
||||
value: catalog_revision,
|
||||
})?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"update agents
|
||||
set status = $1,
|
||||
catalog_revision = $2,
|
||||
published_at = null,
|
||||
updated_at = $2::timestamptz
|
||||
where id = $3 and workspace_id = $4",
|
||||
updated_at = $3::timestamptz
|
||||
where id = $4 and workspace_id = $5",
|
||||
)
|
||||
.bind(serialize_enum_text(&AgentStatus::Draft, "status")?)
|
||||
.bind(next_catalog_revision)
|
||||
.bind(updated_at)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
@@ -512,38 +706,55 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
updated_at: &time::OffsetDateTime,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query("delete from published_agents where agent_id = $1")
|
||||
.bind(agent_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"update agent_versions
|
||||
set status = $1
|
||||
where agent_id = $2
|
||||
and version = (
|
||||
select current_draft_version
|
||||
from agents
|
||||
where id = $2 and workspace_id = $3
|
||||
)",
|
||||
Self::lock_agent_and_validate_expected_state(
|
||||
&mut tx,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
expected_state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let current_status = sqlx::query(
|
||||
"select status, catalog_revision
|
||||
from agents
|
||||
where id = $1 and workspace_id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(serialize_enum_text(&AgentStatus::Archived, "status")?)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let status =
|
||||
deserialize_enum_text::<AgentStatus>(current_status.try_get("status")?, "status")?;
|
||||
if status == AgentStatus::Archived {
|
||||
return Err(RegistryError::InvalidAgentTransition {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
from: "archived".to_owned(),
|
||||
action: "archive",
|
||||
});
|
||||
}
|
||||
let catalog_revision: i64 = current_status.try_get("catalog_revision")?;
|
||||
let next_catalog_revision = catalog_revision
|
||||
.checked_add(1)
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or(RegistryError::InvalidNumericValue {
|
||||
field: "catalog_revision",
|
||||
value: catalog_revision,
|
||||
})?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"update agents
|
||||
set status = $1,
|
||||
catalog_revision = $2,
|
||||
published_at = null,
|
||||
updated_at = $2::timestamptz
|
||||
where id = $3 and workspace_id = $4",
|
||||
updated_at = $3::timestamptz
|
||||
where id = $4 and workspace_id = $5",
|
||||
)
|
||||
.bind(serialize_enum_text(&AgentStatus::Archived, "status")?)
|
||||
.bind(next_catalog_revision)
|
||||
.bind(updated_at)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
@@ -559,123 +770,4 @@ impl PostgresRegistry {
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_published_agent_tools_by_slug(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
||||
let rows = sqlx::query!(
|
||||
"select
|
||||
w.id as workspace_id,
|
||||
w.slug as workspace_slug,
|
||||
a.id as agent_id,
|
||||
a.slug as agent_slug,
|
||||
b.tool_name,
|
||||
b.tool_title,
|
||||
coalesce(b.tool_description_override, ov.tool_description_json->>'description') as \"tool_description!\",
|
||||
o.id,
|
||||
o.name,
|
||||
o.display_name,
|
||||
o.category,
|
||||
o.protocol,
|
||||
o.security_level,
|
||||
o.created_at as \"operation_created_at!: time::OffsetDateTime\",
|
||||
o.updated_at as \"operation_updated_at!: time::OffsetDateTime\",
|
||||
o.published_at as \"operation_published_at: time::OffsetDateTime\",
|
||||
ov.version,
|
||||
ov.status,
|
||||
ov.target_json,
|
||||
ov.input_schema_json,
|
||||
ov.output_schema_json,
|
||||
ov.input_mapping_json,
|
||||
ov.output_mapping_json,
|
||||
ov.execution_config_json,
|
||||
ov.tool_description_json,
|
||||
ov.samples_json,
|
||||
ov.generated_draft_json,
|
||||
ov.config_export_json,
|
||||
ov.wizard_state_json,
|
||||
ov.change_note,
|
||||
ov.created_at as \"created_at!: time::OffsetDateTime\",
|
||||
ov.created_by
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version
|
||||
join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version
|
||||
join operations o on o.id = ov.operation_id and o.workspace_id = w.id
|
||||
where w.slug = $1 and a.slug = $2 and b.enabled = true
|
||||
order by b.tool_name asc",
|
||||
workspace_slug,
|
||||
agent_slug,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
let exists = sqlx::query!(
|
||||
"select 1 as \"present!\"
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
where w.slug = $1 and a.slug = $2",
|
||||
workspace_slug,
|
||||
agent_slug,
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if exists.is_none() {
|
||||
return Err(RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug: workspace_slug.to_owned(),
|
||||
agent_slug: agent_slug.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let workspace_id = row.workspace_id.clone();
|
||||
build_published_agent_tool(
|
||||
row.workspace_id,
|
||||
row.workspace_slug,
|
||||
row.agent_id,
|
||||
row.agent_slug,
|
||||
row.tool_name,
|
||||
row.tool_title,
|
||||
row.tool_description,
|
||||
build_operation_version_record(
|
||||
row.id,
|
||||
workspace_id,
|
||||
row.name,
|
||||
row.display_name,
|
||||
row.category,
|
||||
row.protocol,
|
||||
row.security_level,
|
||||
row.operation_created_at,
|
||||
row.operation_updated_at,
|
||||
row.operation_published_at,
|
||||
row.version,
|
||||
row.status,
|
||||
row.target_json,
|
||||
row.input_schema_json,
|
||||
row.output_schema_json,
|
||||
row.input_mapping_json,
|
||||
row.output_mapping_json,
|
||||
row.execution_config_json,
|
||||
row.tool_description_json,
|
||||
row.samples_json,
|
||||
row.generated_draft_json,
|
||||
row.config_export_json,
|
||||
row.wizard_state_json,
|
||||
row.change_note,
|
||||
row.created_at,
|
||||
row.created_by,
|
||||
)?
|
||||
.snapshot,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn get_published_agent_catalog_by_slug(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<PublishedAgentCatalog, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("set transaction isolation level repeatable read")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
pa.version,
|
||||
pa.catalog_revision,
|
||||
av.tool_selection_policy_json
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_versions av on av.agent_id = a.id and av.version = pa.version
|
||||
where w.slug = $1
|
||||
and a.slug = $2
|
||||
and a.status = 'published'
|
||||
and av.status = 'published'",
|
||||
)
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug: workspace_slug.to_owned(),
|
||||
agent_slug: agent_slug.to_owned(),
|
||||
})?;
|
||||
let tools = query_published_agent_tools(&mut tx, workspace_slug, agent_slug).await?;
|
||||
|
||||
let agent_version = from_db_version(row.try_get("version")?, "agent_version")?;
|
||||
let catalog_revision: i64 = row.try_get("catalog_revision")?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(PublishedAgentCatalog {
|
||||
agent_version,
|
||||
catalog_revision: format!(
|
||||
"agent-version-{agent_version}-catalog-revision-{catalog_revision}"
|
||||
),
|
||||
tool_selection_policy: deserialize_json_value(
|
||||
row.try_get("tool_selection_policy_json")?,
|
||||
)?,
|
||||
tools,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_published_agent_tools_by_slug(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
||||
let rows = published_agent_tools_query()
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
let exists = sqlx::query(
|
||||
"select 1 as present
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
where w.slug = $1
|
||||
and a.slug = $2
|
||||
and a.status = 'published'",
|
||||
)
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if exists.is_none() {
|
||||
return Err(RegistryError::PublishedAgentNotFound {
|
||||
workspace_slug: workspace_slug.to_owned(),
|
||||
agent_slug: agent_slug.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
published_agent_tools_from_rows(rows)
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_published_agent_tools(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
||||
let rows = published_agent_tools_query()
|
||||
.bind(workspace_slug)
|
||||
.bind(agent_slug)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
published_agent_tools_from_rows(rows)
|
||||
}
|
||||
|
||||
fn published_agent_tools_query()
|
||||
-> sqlx::query::Query<'static, sqlx::Postgres, sqlx::postgres::PgArguments> {
|
||||
sqlx::query(
|
||||
"select
|
||||
w.id as workspace_id,
|
||||
w.slug as workspace_slug,
|
||||
a.id as agent_id,
|
||||
a.slug as agent_slug,
|
||||
b.tool_name,
|
||||
b.tool_title,
|
||||
coalesce(b.tool_description_override, ov.tool_description_json->>'description') as tool_description,
|
||||
o.id,
|
||||
ov.name,
|
||||
ov.display_name,
|
||||
ov.category,
|
||||
ov.protocol,
|
||||
ov.security_level,
|
||||
ov.created_at as operation_created_at,
|
||||
ov.created_at as operation_updated_at,
|
||||
ov.published_at as operation_published_at,
|
||||
ov.version,
|
||||
ov.status,
|
||||
ov.target_json,
|
||||
ov.input_schema_json,
|
||||
ov.output_schema_json,
|
||||
ov.input_mapping_json,
|
||||
ov.output_mapping_json,
|
||||
ov.execution_config_json,
|
||||
ov.tool_description_json,
|
||||
ov.samples_json,
|
||||
ov.generated_draft_json,
|
||||
ov.config_export_json,
|
||||
ov.wizard_state_json,
|
||||
ov.change_note,
|
||||
ov.created_at as created_at,
|
||||
ov.created_by
|
||||
from workspaces w
|
||||
join agents a on a.workspace_id = w.id
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_operation_bindings b on b.agent_id = a.id and b.agent_version = pa.version
|
||||
join operation_versions ov on ov.operation_id = b.operation_id and ov.version = b.operation_version
|
||||
join operations o on o.id = ov.operation_id and o.workspace_id = w.id
|
||||
where w.slug = $1
|
||||
and a.slug = $2
|
||||
and a.status = 'published'
|
||||
and ov.status = 'published'
|
||||
and b.enabled = true
|
||||
order by b.tool_name asc",
|
||||
)
|
||||
}
|
||||
|
||||
fn published_agent_tools_from_rows(
|
||||
rows: Vec<sqlx::postgres::PgRow>,
|
||||
) -> Result<Vec<PublishedAgentTool>, RegistryError> {
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let workspace_id: String = row.try_get("workspace_id")?;
|
||||
build_published_agent_tool(
|
||||
workspace_id.clone(),
|
||||
row.try_get("workspace_slug")?,
|
||||
row.try_get("agent_id")?,
|
||||
row.try_get("agent_slug")?,
|
||||
row.try_get("tool_name")?,
|
||||
row.try_get("tool_title")?,
|
||||
row.try_get("tool_description")?,
|
||||
build_operation_version_record(
|
||||
row.try_get("id")?,
|
||||
workspace_id,
|
||||
row.try_get("name")?,
|
||||
row.try_get("display_name")?,
|
||||
row.try_get("category")?,
|
||||
row.try_get("protocol")?,
|
||||
row.try_get("security_level")?,
|
||||
row.try_get("operation_created_at")?,
|
||||
row.try_get("operation_updated_at")?,
|
||||
row.try_get("operation_published_at")?,
|
||||
row.try_get("version")?,
|
||||
row.try_get("status")?,
|
||||
row.try_get("target_json")?,
|
||||
row.try_get("input_schema_json")?,
|
||||
row.try_get("output_schema_json")?,
|
||||
row.try_get("input_mapping_json")?,
|
||||
row.try_get("output_mapping_json")?,
|
||||
row.try_get("execution_config_json")?,
|
||||
row.try_get("tool_description_json")?,
|
||||
row.try_get("samples_json")?,
|
||||
row.try_get("generated_draft_json")?,
|
||||
row.try_get("config_export_json")?,
|
||||
row.try_get("wizard_state_json")?,
|
||||
row.try_get("change_note")?,
|
||||
row.try_get("created_at")?,
|
||||
row.try_get("created_by")?,
|
||||
)?
|
||||
.snapshot,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -274,9 +274,18 @@ impl PostgresRegistry {
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(error))
|
||||
if error.constraint() == Some("platform_api_keys_workspace_name_idx") =>
|
||||
if matches!(
|
||||
error.constraint(),
|
||||
Some(
|
||||
"platform_api_keys_workspace_name_idx"
|
||||
| "platform_api_keys_workspace_name_active_idx"
|
||||
)
|
||||
) =>
|
||||
{
|
||||
Err(RegistryError::Storage(sqlx::Error::Database(error)))
|
||||
Err(RegistryError::PlatformApiKeyNameAlreadyExists {
|
||||
workspace_id: request.api_key.workspace_id.as_str().to_owned(),
|
||||
name: request.api_key.name.clone(),
|
||||
})
|
||||
}
|
||||
Err(error) => Err(RegistryError::Storage(error)),
|
||||
}
|
||||
@@ -288,11 +297,22 @@ impl PostgresRegistry {
|
||||
key_id: &PlatformApiKeyId,
|
||||
revoked_at: &time::OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = $2::timestamptz
|
||||
where workspace_id = $3 and id = $4",
|
||||
let row = sqlx::query(
|
||||
"with target as (
|
||||
select id
|
||||
from platform_api_keys
|
||||
where workspace_id = $3 and id = $4
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = $2::timestamptz
|
||||
where workspace_id = $3
|
||||
and id = $4
|
||||
and status = 'active'
|
||||
returning id
|
||||
)
|
||||
select exists(select 1 from target) as exists,
|
||||
exists(select 1 from updated) as updated",
|
||||
)
|
||||
.bind(serialize_enum_text(
|
||||
&PlatformApiKeyStatus::Revoked,
|
||||
@@ -301,14 +321,19 @@ impl PostgresRegistry {
|
||||
.bind(revoked_at)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("updated") {
|
||||
return Err(RegistryError::PlatformApiKeyInactive {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -320,11 +345,23 @@ impl PostgresRegistry {
|
||||
key_id: &PlatformApiKeyId,
|
||||
revoked_at: &time::OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = $2::timestamptz
|
||||
where workspace_id = $3 and agent_id = $4 and id = $5",
|
||||
let row = sqlx::query(
|
||||
"with target as (
|
||||
select id
|
||||
from platform_api_keys
|
||||
where workspace_id = $3 and agent_id = $4 and id = $5
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = $2::timestamptz
|
||||
where workspace_id = $3
|
||||
and agent_id = $4
|
||||
and id = $5
|
||||
and status = 'active'
|
||||
returning id
|
||||
)
|
||||
select exists(select 1 from target) as exists,
|
||||
exists(select 1 from updated) as updated",
|
||||
)
|
||||
.bind(serialize_enum_text(
|
||||
&PlatformApiKeyStatus::Revoked,
|
||||
@@ -334,14 +371,19 @@ impl PostgresRegistry {
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("updated") {
|
||||
return Err(RegistryError::PlatformApiKeyInactive {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -351,18 +393,42 @@ impl PostgresRegistry {
|
||||
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?;
|
||||
let row = sqlx::query(
|
||||
"with target as (
|
||||
select id
|
||||
from platform_api_keys
|
||||
where workspace_id = $2 and id = $3
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where workspace_id = $2
|
||||
and id = $3
|
||||
and status <> 'deleted'
|
||||
returning id
|
||||
)
|
||||
select exists(select 1 from target) as exists,
|
||||
exists(select 1 from updated) as updated",
|
||||
)
|
||||
.bind(serialize_enum_text(
|
||||
&PlatformApiKeyStatus::Deleted,
|
||||
"status",
|
||||
)?)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("updated") {
|
||||
return Err(RegistryError::PlatformApiKeyInactive {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -373,20 +439,44 @@ impl PostgresRegistry {
|
||||
agent_id: &AgentId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"delete from platform_api_keys where workspace_id = $1 and agent_id = $2 and id = $3",
|
||||
let row = sqlx::query(
|
||||
"with target as (
|
||||
select id
|
||||
from platform_api_keys
|
||||
where workspace_id = $2 and agent_id = $3 and id = $4
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
set status = $1,
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where workspace_id = $2
|
||||
and agent_id = $3
|
||||
and id = $4
|
||||
and status <> 'deleted'
|
||||
returning id
|
||||
)
|
||||
select exists(select 1 from target) as exists,
|
||||
exists(select 1 from updated) as updated",
|
||||
)
|
||||
.bind(serialize_enum_text(
|
||||
&PlatformApiKeyStatus::Deleted,
|
||||
"status",
|
||||
)?)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("updated") {
|
||||
return Err(RegistryError::PlatformApiKeyInactive {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -397,23 +487,31 @@ impl PostgresRegistry {
|
||||
key_id: &PlatformApiKeyId,
|
||||
used_at: &time::OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"with target as (
|
||||
select id
|
||||
let row = sqlx::query(
|
||||
"with target as materialized (
|
||||
select id,
|
||||
last_used_at,
|
||||
status = 'active'
|
||||
and (expires_at is null or expires_at > now()) as active
|
||||
from platform_api_keys
|
||||
where workspace_id = $1 and id = $2
|
||||
for update
|
||||
), updated as (
|
||||
update platform_api_keys
|
||||
update platform_api_keys as api_key
|
||||
set last_used_at = $3::timestamptz
|
||||
where workspace_id = $1
|
||||
and id = $2
|
||||
from target
|
||||
where api_key.workspace_id = $1
|
||||
and api_key.id = target.id
|
||||
and target.active
|
||||
and (
|
||||
last_used_at is null
|
||||
or last_used_at < $3::timestamptz - interval '1 minute'
|
||||
target.last_used_at is null
|
||||
or target.last_used_at < $3::timestamptz - interval '1 minute'
|
||||
)
|
||||
returning id
|
||||
returning api_key.id
|
||||
)
|
||||
select exists(select 1 from target)",
|
||||
select
|
||||
exists(select 1 from target) as exists,
|
||||
coalesce((select active from target), false) as active",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
@@ -421,11 +519,16 @@ impl PostgresRegistry {
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if !exists {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::PlatformApiKeyNotFound {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("active") {
|
||||
return Err(RegistryError::PlatformApiKeyInactive {
|
||||
key_id: key_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -11,13 +11,15 @@ impl PostgresRegistry {
|
||||
sqlx::query(
|
||||
"update approval_requests
|
||||
set status = 'expired'
|
||||
where agent_id = $1
|
||||
and operation_id = $2
|
||||
and operation_version = $3
|
||||
and request_fingerprint = $4
|
||||
where workspace_id = $1
|
||||
and agent_id = $2
|
||||
and operation_id = $3
|
||||
and operation_version = $4
|
||||
and request_fingerprint = $5
|
||||
and status = 'pending'
|
||||
and expires_at <= $5",
|
||||
and expires_at <= $6",
|
||||
)
|
||||
.bind(request.approval.workspace_id.as_str())
|
||||
.bind(request.approval.agent_id.as_str())
|
||||
.bind(request.approval.operation_id.as_str())
|
||||
.bind(to_db_version(request.approval.operation_version))
|
||||
@@ -34,6 +36,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -44,15 +48,22 @@ impl PostgresRegistry {
|
||||
request_fingerprint
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||
$9, $10::timestamptz, $11::timestamptz, $12::timestamptz,
|
||||
$13, $14, $15
|
||||
$9, $10, $11, $12::timestamptz, $13::timestamptz,
|
||||
$14::timestamptz, $15, $16, $17
|
||||
)
|
||||
on conflict (
|
||||
workspace_id,
|
||||
agent_id,
|
||||
operation_id,
|
||||
operation_version,
|
||||
request_fingerprint
|
||||
)
|
||||
on conflict (agent_id, operation_id, operation_version, request_fingerprint)
|
||||
where status = 'pending' and request_fingerprint is not null
|
||||
do update set request_fingerprint = excluded.request_fingerprint
|
||||
returning
|
||||
id, workspace_id, agent_id, operation_id, operation_version,
|
||||
status, risk_level, request_payload_json, response_payload_json,
|
||||
status, risk_level, request_id, trace_id, request_payload_json,
|
||||
response_payload_json,
|
||||
created_at, expires_at, decided_at, decided_by_key_id, decision_note",
|
||||
)
|
||||
.bind(request.approval.id.as_str())
|
||||
@@ -68,6 +79,8 @@ impl PostgresRegistry {
|
||||
&request.approval.risk_level,
|
||||
"approval_risk_level",
|
||||
)?)
|
||||
.bind(request.approval.request_id.as_deref())
|
||||
.bind(request.approval.trace_id.as_deref())
|
||||
.bind(Json(&request.approval.request_payload))
|
||||
.bind(request.approval.response_payload.as_ref().map(Json))
|
||||
.bind(request.approval.created_at)
|
||||
@@ -103,6 +116,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -140,6 +155,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -179,6 +196,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -215,6 +234,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -239,6 +260,7 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: DecideApprovalRequest<'_>,
|
||||
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||
let expected_fingerprint = approval_request_fingerprint(request.request_payload)?;
|
||||
let row = sqlx::query(
|
||||
"update approval_requests
|
||||
set status = $1,
|
||||
@@ -249,6 +271,9 @@ impl PostgresRegistry {
|
||||
where workspace_id = $6
|
||||
and agent_id = $7
|
||||
and id = $8
|
||||
and operation_id = $9
|
||||
and operation_version = $10
|
||||
and request_fingerprint = $11
|
||||
and status = 'pending'
|
||||
and expires_at > $3::timestamptz
|
||||
returning
|
||||
@@ -259,6 +284,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -270,11 +297,14 @@ impl PostgresRegistry {
|
||||
.bind(serialize_enum_text(&request.status, "approval_status")?)
|
||||
.bind(request.response_payload.as_ref().map(Json))
|
||||
.bind(request.decided_at)
|
||||
.bind(request.decided_by_key_id.as_str())
|
||||
.bind(request.decided_by_key_id.map(PlatformApiKeyId::as_str))
|
||||
.bind(request.decision_note)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(request.agent_id.as_str())
|
||||
.bind(request.approval_id.as_str())
|
||||
.bind(request.operation_id.as_str())
|
||||
.bind(to_db_version(request.operation_version))
|
||||
.bind(expected_fingerprint)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
@@ -285,6 +315,10 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
request: FinishApprovalRequest<'_>,
|
||||
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
|
||||
let response_payload = request
|
||||
.response_payload
|
||||
.as_ref()
|
||||
.map(crank_core::sanitize_invocation_preview);
|
||||
let row = sqlx::query(
|
||||
"update approval_requests
|
||||
set status = $1,
|
||||
@@ -302,6 +336,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -311,7 +347,7 @@ impl PostgresRegistry {
|
||||
decision_note",
|
||||
)
|
||||
.bind(serialize_enum_text(&request.status, "approval_status")?)
|
||||
.bind(request.response_payload.as_ref().map(Json))
|
||||
.bind(response_payload.as_ref().map(Json))
|
||||
.bind(request.decision_note)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(request.agent_id.as_str())
|
||||
@@ -340,7 +376,8 @@ impl PostgresRegistry {
|
||||
and status = 'approved'
|
||||
returning
|
||||
id, workspace_id, agent_id, operation_id, operation_version,
|
||||
status, risk_level, request_payload_json, response_payload_json,
|
||||
status, risk_level, request_id, trace_id, request_payload_json,
|
||||
response_payload_json,
|
||||
created_at, expires_at, decided_at, decided_by_key_id, decision_note",
|
||||
)
|
||||
.bind(started_at)
|
||||
@@ -377,7 +414,8 @@ impl PostgresRegistry {
|
||||
returning
|
||||
approval.id, approval.workspace_id, approval.agent_id,
|
||||
approval.operation_id, approval.operation_version, approval.status,
|
||||
approval.risk_level, approval.request_payload_json,
|
||||
approval.risk_level, approval.request_id, approval.trace_id,
|
||||
approval.request_payload_json,
|
||||
approval.response_payload_json, approval.created_at, approval.expires_at,
|
||||
approval.decided_at, approval.decided_by_key_id, approval.decision_note",
|
||||
)
|
||||
@@ -421,7 +459,8 @@ impl PostgresRegistry {
|
||||
returning
|
||||
approval.id, approval.workspace_id, approval.agent_id,
|
||||
approval.operation_id, approval.operation_version, approval.status,
|
||||
approval.risk_level, approval.request_payload_json,
|
||||
approval.risk_level, approval.request_id, approval.trace_id,
|
||||
approval.request_payload_json,
|
||||
approval.response_payload_json, approval.created_at, approval.expires_at,
|
||||
approval.decided_at, approval.decided_by_key_id, approval.decision_note",
|
||||
)
|
||||
@@ -453,6 +492,8 @@ impl PostgresRegistry {
|
||||
operation_version,
|
||||
status,
|
||||
risk_level,
|
||||
request_id,
|
||||
trace_id,
|
||||
request_payload_json,
|
||||
response_payload_json,
|
||||
created_at,
|
||||
@@ -473,11 +514,53 @@ impl PostgresRegistry {
|
||||
}
|
||||
|
||||
fn approval_request_fingerprint(payload: &Value) -> Result<String, RegistryError> {
|
||||
let canonical = canonical_json(payload);
|
||||
let canonical = canonical_json(&approval_fingerprint_payload(payload));
|
||||
let encoded = serde_json::to_vec(&canonical)?;
|
||||
Ok(format!("{:x}", Sha256::digest(encoded)))
|
||||
}
|
||||
|
||||
fn approval_fingerprint_payload(value: &Value) -> Value {
|
||||
approval_fingerprint_payload_at(value, 0)
|
||||
}
|
||||
|
||||
fn approval_fingerprint_payload_at(value: &Value, depth: usize) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let filtered = object
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
if depth == 0 && is_approval_control_field(key) {
|
||||
None
|
||||
} else {
|
||||
Some((
|
||||
key.clone(),
|
||||
approval_fingerprint_payload_at(value, depth + 1),
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect::<serde_json::Map<String, Value>>();
|
||||
Value::Object(filtered)
|
||||
}
|
||||
Value::Array(items) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| approval_fingerprint_payload_at(item, depth + 1))
|
||||
.collect(),
|
||||
),
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_approval_control_field(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"_crank_confirmation_token"
|
||||
| "_crank_approval_id"
|
||||
| "_crank_approval_token"
|
||||
| "_crank_runtime_confirmation_token"
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_json(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
@@ -505,6 +588,8 @@ fn map_approval_request_row(row: PgRow) -> Result<ApprovalRequestRecord, Registr
|
||||
&row.get::<String, _>("risk_level"),
|
||||
"approval_risk_level",
|
||||
)?,
|
||||
request_id: row.get("request_id"),
|
||||
trace_id: row.get("trace_id"),
|
||||
request_payload: row.get::<Value, _>("request_payload_json"),
|
||||
response_payload: row.get::<Option<Value>, _>("response_payload_json"),
|
||||
created_at: row.get("created_at"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use crate::model::RecoverAdminPasswordRequest;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
const MAX_ADMIN_BOOTSTRAP_ATTEMPTS: i32 = 5;
|
||||
fn map_auth_user_row(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
|
||||
let status = row.try_get::<String, _>("status")?;
|
||||
Ok(AuthUserRecord {
|
||||
@@ -16,8 +17,199 @@ fn map_auth_user_row(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn create_admin_bootstrap_contract(
|
||||
&self,
|
||||
request: CreateAdminBootstrapContractRequest<'_>,
|
||||
) -> Result<AdminBootstrapContractRecord, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query(
|
||||
"update admin_bootstrap_contracts
|
||||
set status = 'expired'
|
||||
where status = 'active'
|
||||
and expires_at <= now()",
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query(
|
||||
"insert into admin_bootstrap_contracts (
|
||||
id, token_hash, email, display_name, status, expires_at, created_at
|
||||
) values (
|
||||
$1, $2, $3, $4, 'active', $5::timestamptz, now()
|
||||
)
|
||||
returning id, email, display_name, status, expires_at, created_at, used_at",
|
||||
)
|
||||
.bind(request.id)
|
||||
.bind(request.token_hash)
|
||||
.bind(request.email)
|
||||
.bind(request.display_name)
|
||||
.bind(*request.expires_at)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
sqlx::Error::Database(db_error)
|
||||
if db_error.constraint() == Some("admin_bootstrap_contracts_single_active_idx") =>
|
||||
{
|
||||
RegistryError::AdminBootstrapUnavailable
|
||||
}
|
||||
other => RegistryError::Storage(other),
|
||||
})?;
|
||||
tx.commit().await?;
|
||||
Ok(AdminBootstrapContractRecord {
|
||||
id: row.try_get("id")?,
|
||||
email: row.try_get("email")?,
|
||||
display_name: row.try_get("display_name")?,
|
||||
status: row.try_get("status")?,
|
||||
expires_at: row.try_get("expires_at")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
used_at: row.try_get("used_at")?,
|
||||
})
|
||||
}
|
||||
pub async fn consume_admin_bootstrap_contract(
|
||||
&self,
|
||||
request: ConsumeAdminBootstrapContractRequest<'_>,
|
||||
) -> Result<UserId, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let existing_admins = sqlx::query_scalar::<_, i64>(
|
||||
"select count(*)::bigint
|
||||
from users
|
||||
where coalesce(password_hash, '') <> ''",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if existing_admins > 0 {
|
||||
return Err(RegistryError::AdminBootstrapRejected);
|
||||
}
|
||||
let contract = sqlx::query(
|
||||
"select id, email, display_name
|
||||
from admin_bootstrap_contracts
|
||||
where token_hash = $1
|
||||
and status = 'active'
|
||||
and expires_at > $2::timestamptz
|
||||
and attempts < $3
|
||||
for update",
|
||||
)
|
||||
.bind(request.token_hash)
|
||||
.bind(*request.now)
|
||||
.bind(MAX_ADMIN_BOOTSTRAP_ATTEMPTS)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(contract) = contract else {
|
||||
let _ = sqlx::query(
|
||||
"update admin_bootstrap_contracts
|
||||
set attempts = least(attempts + 1, 100),
|
||||
status = case
|
||||
when attempts + 1 >= $2 then 'revoked'
|
||||
else status
|
||||
end
|
||||
where token_hash = $1
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(request.token_hash)
|
||||
.bind(MAX_ADMIN_BOOTSTRAP_ATTEMPTS)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
return Err(RegistryError::AdminBootstrapRejected);
|
||||
};
|
||||
let user_id = format!("user_{}", uuid::Uuid::now_v7().simple());
|
||||
let email: String = contract.try_get("email")?;
|
||||
let display_name: String = contract.try_get("display_name")?;
|
||||
let user_row = sqlx::query(
|
||||
"insert into users (
|
||||
id, email, display_name, password_hash, status, created_at
|
||||
) values (
|
||||
$1, $2, $3, $4, 'active', now()
|
||||
)
|
||||
on conflict (email) do update
|
||||
set display_name = excluded.display_name,
|
||||
password_hash = excluded.password_hash,
|
||||
status = 'active'
|
||||
where users.password_hash is null
|
||||
returning id",
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&email)
|
||||
.bind(&display_name)
|
||||
.bind(request.password_hash)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(RegistryError::Storage)?;
|
||||
let Some(user_row) = user_row else {
|
||||
return Err(RegistryError::AdminBootstrapRejected);
|
||||
};
|
||||
let user_id: String = user_row.try_get("id")?;
|
||||
sqlx::query(
|
||||
"insert into memberships (
|
||||
workspace_id, user_id, role, created_at
|
||||
) values (
|
||||
'ws_default', $1, 'owner', now()
|
||||
)
|
||||
on conflict (workspace_id, user_id) do update
|
||||
set role = 'owner'",
|
||||
)
|
||||
.bind(&user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update admin_bootstrap_contracts
|
||||
set status = 'used',
|
||||
used_at = $2::timestamptz,
|
||||
used_by_user_id = $3
|
||||
where id = $1
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(contract.try_get::<String, _>("id")?)
|
||||
.bind(*request.now)
|
||||
.bind(&user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(UserId::new(user_id))
|
||||
}
|
||||
pub async fn admin_bootstrap_contract_is_consumable(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<bool, RegistryError> {
|
||||
let existing_admins = sqlx::query_scalar::<_, i64>(
|
||||
"select count(*)::bigint
|
||||
from users
|
||||
where coalesce(password_hash, '') <> ''",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
if existing_admins > 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"select exists(
|
||||
select 1
|
||||
from admin_bootstrap_contracts
|
||||
where token_hash = $1
|
||||
and status = 'active'
|
||||
and expires_at > $2::timestamptz
|
||||
and attempts < $3
|
||||
)",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.bind(*now)
|
||||
.bind(MAX_ADMIN_BOOTSTRAP_ATTEMPTS)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(exists)
|
||||
}
|
||||
pub async fn has_password_admin(&self) -> Result<bool, RegistryError> {
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"select exists(
|
||||
select 1 from users
|
||||
where coalesce(password_hash, '') <> ''
|
||||
)",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(exists)
|
||||
}
|
||||
pub async fn ensure_bootstrap_user(
|
||||
&self,
|
||||
email: &str,
|
||||
@@ -52,7 +244,6 @@ impl PostgresRegistry {
|
||||
{
|
||||
return Ok(UserId::new(id));
|
||||
}
|
||||
|
||||
let existing = sqlx::query_scalar::<_, String>(
|
||||
"select id
|
||||
from users
|
||||
@@ -62,10 +253,8 @@ impl PostgresRegistry {
|
||||
.bind(email)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(UserId::new(existing))
|
||||
}
|
||||
|
||||
pub async fn upsert_bootstrap_user(
|
||||
&self,
|
||||
email: &str,
|
||||
@@ -75,7 +264,6 @@ impl PostgresRegistry {
|
||||
self.ensure_bootstrap_user(email, display_name, password_hash)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ensure_membership(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -99,10 +287,8 @@ impl PostgresRegistry {
|
||||
.bind(serialize_enum_text(&role, "role")?)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_auth_user_by_email(
|
||||
&self,
|
||||
email: &str,
|
||||
@@ -122,10 +308,8 @@ impl PostgresRegistry {
|
||||
.bind(email)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.as_ref().map(map_auth_user_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn get_auth_user_by_id(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -145,10 +329,8 @@ impl PostgresRegistry {
|
||||
.bind(user_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.as_ref().map(map_auth_user_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn update_user_profile(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -173,7 +355,6 @@ impl PostgresRegistry {
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|error| map_user_update_error(error, user_id, email))?;
|
||||
|
||||
build_user(
|
||||
row.id,
|
||||
row.email,
|
||||
@@ -182,7 +363,6 @@ impl PostgresRegistry {
|
||||
row.created_at,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn update_user_password(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -197,16 +377,13 @@ impl PostgresRegistry {
|
||||
.bind(password_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::UserNotFound {
|
||||
user_id: user_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_user_password_and_revoke_other_sessions(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -223,13 +400,11 @@ impl PostgresRegistry {
|
||||
.bind(password_hash)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::UserNotFound {
|
||||
user_id: user_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked'
|
||||
@@ -241,17 +416,49 @@ impl PostgresRegistry {
|
||||
.bind(current_session_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_user_password_and_revoke_all_sessions(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
password_hash: &str,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"update users
|
||||
set password_hash = $2
|
||||
where id = $1",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.bind(password_hash)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::UserNotFound {
|
||||
user_id: user_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked',
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where user_id = $1
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn create_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
user_id: &UserId,
|
||||
current_workspace_id: Option<&WorkspaceId>,
|
||||
secret_hash: &str,
|
||||
csrf_hash: Option<&str>,
|
||||
expires_at: &OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
@@ -260,6 +467,7 @@ impl PostgresRegistry {
|
||||
user_id,
|
||||
current_workspace_id,
|
||||
secret_hash,
|
||||
csrf_hash,
|
||||
status,
|
||||
expires_at,
|
||||
last_seen_at,
|
||||
@@ -269,8 +477,9 @@ impl PostgresRegistry {
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
'active',
|
||||
$5::timestamptz,
|
||||
$6::timestamptz,
|
||||
now(),
|
||||
now()
|
||||
)",
|
||||
@@ -279,54 +488,55 @@ impl PostgresRegistry {
|
||||
.bind(user_id.as_str())
|
||||
.bind(current_workspace_id.map(|id| id.as_str()))
|
||||
.bind(secret_hash)
|
||||
.bind(csrf_hash)
|
||||
.bind(*expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
secret_hash: &str,
|
||||
) -> Result<Option<SessionRecord>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
s.id,
|
||||
s.user_id,
|
||||
s.current_workspace_id,
|
||||
s.csrf_hash,
|
||||
u.email,
|
||||
u.display_name,
|
||||
u.status,
|
||||
u.created_at as \"created_at!: OffsetDateTime\"
|
||||
u.created_at as created_at
|
||||
from user_sessions s
|
||||
join users u on u.id = s.user_id
|
||||
where s.id = $1
|
||||
and s.secret_hash = $2
|
||||
and s.status = 'active'
|
||||
and u.status = 'active'
|
||||
and s.expires_at > now()
|
||||
limit 1",
|
||||
session_id.as_str(),
|
||||
secret_hash,
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.bind(secret_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let user_id = UserId::new(row.user_id);
|
||||
let user_id = UserId::new(row.try_get::<String, _>("user_id")?);
|
||||
let user = User {
|
||||
id: user_id.clone(),
|
||||
email: row.email,
|
||||
display_name: row.display_name,
|
||||
status: deserialize_enum_text(&row.status, "status")?,
|
||||
created_at: row.created_at,
|
||||
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("created_at")?,
|
||||
};
|
||||
let memberships = self.list_workspaces_for_user(&user_id).await?;
|
||||
let stored_workspace_id = row.current_workspace_id.map(WorkspaceId::new);
|
||||
let stored_workspace_id = row
|
||||
.try_get::<Option<String>, _>("current_workspace_id")?
|
||||
.map(WorkspaceId::new);
|
||||
let default_workspace_id = memberships
|
||||
.iter()
|
||||
.find(|membership| membership.workspace.id.as_str() == "ws_default")
|
||||
@@ -344,15 +554,56 @@ impl PostgresRegistry {
|
||||
.map(|membership| membership.workspace.id.clone())
|
||||
})
|
||||
});
|
||||
|
||||
Ok(Some(SessionRecord {
|
||||
session_id: UserSessionId::new(row.id),
|
||||
session_id: UserSessionId::new(row.try_get::<String, _>("id")?),
|
||||
user,
|
||||
memberships,
|
||||
current_workspace_id,
|
||||
csrf_hash: row.try_get("csrf_hash")?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn verify_session_csrf(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
csrf_hash: &str,
|
||||
) -> Result<bool, RegistryError> {
|
||||
let allowed = sqlx::query_scalar::<_, bool>(
|
||||
"select exists(
|
||||
select 1
|
||||
from user_sessions
|
||||
where id = $1
|
||||
and csrf_hash = $2
|
||||
and status = 'active'
|
||||
and expires_at > now()
|
||||
)",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.bind(csrf_hash)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(allowed)
|
||||
}
|
||||
pub async fn update_user_session_csrf(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
csrf_hash: &str,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"update user_sessions
|
||||
set csrf_hash = $2
|
||||
where id = $1
|
||||
and status = 'active'
|
||||
and expires_at > now()",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.bind(csrf_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::AdminCsrfRejected);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn touch_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
@@ -369,26 +620,154 @@ impl PostgresRegistry {
|
||||
.bind(session_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke_user_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked'
|
||||
set status = 'revoked',
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where id = $1",
|
||||
)
|
||||
.bind(session_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke_all_user_sessions(&self, user_id: &UserId) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked',
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where user_id = $1
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn recover_admin_password(
|
||||
&self,
|
||||
request: RecoverAdminPasswordRequest<'_>,
|
||||
) -> Result<UserId, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let row = sqlx::query(
|
||||
"update users
|
||||
set password_hash = $2,
|
||||
status = 'active'
|
||||
where email = $1
|
||||
and coalesce(password_hash, '') <> ''
|
||||
returning id",
|
||||
)
|
||||
.bind(request.email)
|
||||
.bind(request.password_hash)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(RegistryError::AdminRecoveryRejected);
|
||||
};
|
||||
let user_id = UserId::new(row.try_get::<String, _>("id")?);
|
||||
sqlx::query(
|
||||
"update user_sessions
|
||||
set status = 'revoked',
|
||||
revoked_at = coalesce(revoked_at, now())
|
||||
where user_id = $1
|
||||
and status = 'active'",
|
||||
)
|
||||
.bind(user_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"insert into admin_security_audit_events (
|
||||
id, action, outcome, actor_user_id, session_id, request_id, trace_id, source, created_at
|
||||
) values (
|
||||
$1, 'recovery_completed', 'success', $2, null, null, null, 'local_cli', now()
|
||||
)",
|
||||
)
|
||||
.bind(request.audit_id)
|
||||
.bind(user_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(user_id)
|
||||
}
|
||||
pub async fn login_backoff_locked_until(
|
||||
&self,
|
||||
scope_hash: &str,
|
||||
) -> Result<Option<OffsetDateTime>, RegistryError> {
|
||||
let value = sqlx::query_scalar::<_, Option<OffsetDateTime>>(
|
||||
"select locked_until
|
||||
from admin_login_backoff
|
||||
where scope_hash = $1
|
||||
and locked_until > now()",
|
||||
)
|
||||
.bind(scope_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(value)
|
||||
}
|
||||
pub async fn record_login_failure(
|
||||
&self,
|
||||
scope_hash: &str,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<OffsetDateTime, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"insert into admin_login_backoff (
|
||||
scope_hash, failure_count, locked_until, last_attempt_at, updated_at
|
||||
) values (
|
||||
$1, 1, $2::timestamptz + interval '1 second', $2::timestamptz, $2::timestamptz
|
||||
)
|
||||
on conflict (scope_hash) do update
|
||||
set failure_count = least(admin_login_backoff.failure_count + 1, 1000),
|
||||
locked_until = $2::timestamptz + (
|
||||
least(300, power(2, least(admin_login_backoff.failure_count + 1, 8)))::int
|
||||
* interval '1 second'
|
||||
),
|
||||
last_attempt_at = $2::timestamptz,
|
||||
updated_at = $2::timestamptz
|
||||
returning locked_until",
|
||||
)
|
||||
.bind(scope_hash)
|
||||
.bind(*now)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
row.try_get("locked_until").map_err(RegistryError::Storage)
|
||||
}
|
||||
pub async fn reset_login_backoff(&self, scope_hash: &str) -> Result<(), RegistryError> {
|
||||
sqlx::query("delete from admin_login_backoff where scope_hash = $1")
|
||||
.bind(scope_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn record_admin_security_audit(
|
||||
&self,
|
||||
request: AdminSecurityAuditRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"insert into admin_security_audit_events (
|
||||
id, action, outcome, actor_user_id, session_id, request_id, trace_id, source, created_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, now()
|
||||
)",
|
||||
)
|
||||
.bind(request.id)
|
||||
.bind(request.action)
|
||||
.bind(request.outcome)
|
||||
.bind(request.actor_user_id.map(|id| id.as_str()))
|
||||
.bind(request.session_id.map(|id| id.as_str()))
|
||||
.bind(request.request_id)
|
||||
.bind(request.trace_id)
|
||||
.bind(request.source)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn set_user_session_current_workspace(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
@@ -403,10 +782,8 @@ impl PostgresRegistry {
|
||||
.bind(workspace_id.as_str())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn user_has_workspace_access(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -424,14 +801,39 @@ impl PostgresRegistry {
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.allowed)
|
||||
}
|
||||
|
||||
pub async fn save_auth_profile(
|
||||
&self,
|
||||
request: SaveAuthProfileRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let mut secret_ids = request.profile.config.secret_ids();
|
||||
secret_ids.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||
secret_ids.dedup_by(|left, right| left.as_str() == right.as_str());
|
||||
for secret_id in &secret_ids {
|
||||
super::secret::lock_secret_reference(&mut tx, request.workspace_id, secret_id).await?;
|
||||
let row = sqlx::query(
|
||||
"select status
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(RegistryError::SecretNotFound {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
if row.get::<String, _>("status") != "active" {
|
||||
return Err(RegistryError::SecretInactive {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
sqlx::query(
|
||||
"insert into auth_profiles (
|
||||
id,
|
||||
@@ -456,12 +858,11 @@ impl PostgresRegistry {
|
||||
.bind(Json(serialize_json_value(&request.profile.config)?))
|
||||
.bind(request.profile.created_at)
|
||||
.bind(request.profile.updated_at)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_auth_profile(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -483,7 +884,6 @@ impl PostgresRegistry {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
build_auth_profile(
|
||||
row.id,
|
||||
@@ -497,7 +897,6 @@ impl PostgresRegistry {
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn list_auth_profiles(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
@@ -518,7 +917,6 @@ impl PostgresRegistry {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
build_auth_profile(
|
||||
@@ -533,14 +931,12 @@ impl PostgresRegistry {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn list_auth_profiles_referencing_secret(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<Vec<AuthProfile>, RegistryError> {
|
||||
let profiles = self.list_auth_profiles(workspace_id).await?;
|
||||
|
||||
Ok(profiles
|
||||
.into_iter()
|
||||
.filter(|profile| {
|
||||
|
||||
@@ -0,0 +1,934 @@
|
||||
use super::*;
|
||||
|
||||
const MASTER_KEY_PAGE_LIMIT_MAX: i64 = 10_000;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn verify_or_register_master_key_identity(
|
||||
&self,
|
||||
candidate: MasterKeyIdentityCandidate<'_>,
|
||||
) -> Result<MasterKeyIdentityRecord, RegistryError> {
|
||||
validate_master_key_candidate(&candidate)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
lock_master_key_authority(&mut tx).await?;
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
epoch,
|
||||
fingerprint,
|
||||
cipher_contract,
|
||||
status,
|
||||
backup_ref,
|
||||
created_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
from master_key_identities
|
||||
where status = 'active'
|
||||
order by epoch desc
|
||||
limit 1
|
||||
for update",
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let record = if let Some(row) = row {
|
||||
let record = map_master_key_identity(row);
|
||||
if record.epoch != candidate.epoch
|
||||
|| record.fingerprint != candidate.fingerprint
|
||||
|| record.cipher_contract != candidate.cipher_contract
|
||||
{
|
||||
return Err(RegistryError::MasterKeyIdentityMismatch {
|
||||
epoch: record.epoch,
|
||||
});
|
||||
}
|
||||
record
|
||||
} else {
|
||||
sqlx::query(
|
||||
"insert into master_key_identities (
|
||||
epoch,
|
||||
fingerprint,
|
||||
cipher_contract,
|
||||
status,
|
||||
backup_ref,
|
||||
created_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
) values (
|
||||
$1, $2, $3, 'active', null, $4::timestamptz, $4::timestamptz, null
|
||||
)",
|
||||
)
|
||||
.bind(candidate.epoch)
|
||||
.bind(candidate.fingerprint)
|
||||
.bind(candidate.cipher_contract)
|
||||
.bind(candidate.observed_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
MasterKeyIdentityRecord {
|
||||
epoch: candidate.epoch,
|
||||
fingerprint: candidate.fingerprint.to_owned(),
|
||||
cipher_contract: candidate.cipher_contract.to_owned(),
|
||||
status: "active".to_owned(),
|
||||
backup_ref: None,
|
||||
created_at: *candidate.observed_at,
|
||||
activated_at: Some(*candidate.observed_at),
|
||||
retired_at: None,
|
||||
}
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn master_key_rotation_status(
|
||||
&self,
|
||||
) -> Result<MasterKeyRotationStatus, RegistryError> {
|
||||
let active_identity = self.active_master_key_identity().await?;
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
source_epoch,
|
||||
target_epoch,
|
||||
target_fingerprint,
|
||||
state,
|
||||
backup_ref,
|
||||
checkpoint_secret_id,
|
||||
total_secret_versions,
|
||||
processed_secret_versions,
|
||||
verified_secret_versions,
|
||||
failure_code,
|
||||
created_at,
|
||||
updated_at
|
||||
from master_key_rotations
|
||||
order by created_at desc, id asc",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let rotations = rows.into_iter().map(map_master_key_rotation).collect();
|
||||
Ok(MasterKeyRotationStatus {
|
||||
active_identity,
|
||||
rotations,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn active_master_key_identity(
|
||||
&self,
|
||||
) -> Result<Option<MasterKeyIdentityRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
epoch,
|
||||
fingerprint,
|
||||
cipher_contract,
|
||||
status,
|
||||
backup_ref,
|
||||
created_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
from master_key_identities
|
||||
where status = 'active'
|
||||
order by epoch desc
|
||||
limit 1",
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(map_master_key_identity))
|
||||
}
|
||||
|
||||
pub async fn master_key_fingerprint_exists(
|
||||
&self,
|
||||
fingerprint: &str,
|
||||
) -> Result<bool, RegistryError> {
|
||||
let valid_fingerprint = fingerprint.len() == 64
|
||||
&& fingerprint
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
|
||||
if !valid_fingerprint {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let count = sqlx::query_scalar::<_, i64>(
|
||||
"select count(*) from master_key_identities where fingerprint = $1",
|
||||
)
|
||||
.bind(fingerprint)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub async fn list_secret_versions_for_master_key_epoch(
|
||||
&self,
|
||||
epoch: i64,
|
||||
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
|
||||
if epoch < 1 {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
target_ciphertext,
|
||||
target_key_version,
|
||||
target_master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
from secret_versions
|
||||
where master_key_epoch = $1
|
||||
order by secret_id asc, version asc",
|
||||
)
|
||||
.bind(epoch)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(map_secret_version_record).collect()
|
||||
}
|
||||
|
||||
pub async fn list_secret_versions_for_master_key_epoch_page(
|
||||
&self,
|
||||
epoch: i64,
|
||||
after_secret_id: Option<&str>,
|
||||
after_version: Option<u32>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
|
||||
if epoch < 1 || !(1..=MASTER_KEY_PAGE_LIMIT_MAX).contains(&limit) {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let after_db_version = after_version.map(to_db_version);
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
target_ciphertext,
|
||||
target_key_version,
|
||||
target_master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
from secret_versions
|
||||
where master_key_epoch = $1
|
||||
and (
|
||||
$2::text is null
|
||||
or (secret_id, version) > ($2::text, $3::integer)
|
||||
)
|
||||
order by secret_id asc, version asc
|
||||
limit $4",
|
||||
)
|
||||
.bind(epoch)
|
||||
.bind(after_secret_id)
|
||||
.bind(after_db_version)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(map_secret_version_record).collect()
|
||||
}
|
||||
|
||||
pub async fn list_target_secret_versions_for_master_key_rotation(
|
||||
&self,
|
||||
target_epoch: i64,
|
||||
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
|
||||
if target_epoch < 1 {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
target_ciphertext,
|
||||
target_key_version,
|
||||
target_master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
from secret_versions
|
||||
where target_master_key_epoch = $1
|
||||
order by secret_id asc, version asc",
|
||||
)
|
||||
.bind(target_epoch)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(map_secret_version_record).collect()
|
||||
}
|
||||
|
||||
pub async fn list_target_secret_versions_for_master_key_rotation_page(
|
||||
&self,
|
||||
target_epoch: i64,
|
||||
after_secret_id: Option<&str>,
|
||||
after_version: Option<u32>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<SecretVersionRecord>, RegistryError> {
|
||||
if target_epoch < 1 || !(1..=MASTER_KEY_PAGE_LIMIT_MAX).contains(&limit) {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let after_db_version = after_version.map(to_db_version);
|
||||
let rows = sqlx::query(
|
||||
"select
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
target_ciphertext,
|
||||
target_key_version,
|
||||
target_master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
from secret_versions
|
||||
where target_master_key_epoch = $1
|
||||
and (
|
||||
$2::text is null
|
||||
or (secret_id, version) > ($2::text, $3::integer)
|
||||
)
|
||||
order by secret_id asc, version asc
|
||||
limit $4",
|
||||
)
|
||||
.bind(target_epoch)
|
||||
.bind(after_secret_id)
|
||||
.bind(after_db_version)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(map_secret_version_record).collect()
|
||||
}
|
||||
|
||||
pub async fn begin_master_key_rotation(
|
||||
&self,
|
||||
source_epoch: i64,
|
||||
target_epoch: i64,
|
||||
target_fingerprint: &str,
|
||||
backup_ref: Option<&str>,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
validate_rotation_request(source_epoch, target_epoch, target_fingerprint, backup_ref)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
lock_master_key_authority(&mut tx).await?;
|
||||
let active = select_active_master_key_identity_for_update(&mut tx).await?;
|
||||
let Some(active) = active else {
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
};
|
||||
if active.epoch != source_epoch || active.fingerprint == target_fingerprint {
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
|
||||
let existing_active = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
source_epoch,
|
||||
target_epoch,
|
||||
target_fingerprint,
|
||||
state,
|
||||
backup_ref,
|
||||
checkpoint_secret_id,
|
||||
total_secret_versions,
|
||||
processed_secret_versions,
|
||||
verified_secret_versions,
|
||||
failure_code,
|
||||
created_at,
|
||||
updated_at
|
||||
from master_key_rotations
|
||||
where state in ('running', 'verifying', 'verified')
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update",
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if let Some(row) = existing_active {
|
||||
let record = map_master_key_rotation(row);
|
||||
if record.source_epoch == source_epoch
|
||||
&& record.target_epoch == target_epoch
|
||||
&& record.target_fingerprint == target_fingerprint
|
||||
{
|
||||
tx.commit().await?;
|
||||
return Ok(record);
|
||||
}
|
||||
return Err(RegistryError::MasterKeyRotationInProgress);
|
||||
}
|
||||
|
||||
if sqlx::query_scalar::<_, i64>(
|
||||
"select count(*) from master_key_identities where fingerprint = $1",
|
||||
)
|
||||
.bind(target_fingerprint)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
> 0
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
|
||||
let total = sqlx::query_scalar::<_, i64>(
|
||||
"select count(*) from secret_versions where master_key_epoch = $1",
|
||||
)
|
||||
.bind(source_epoch)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let rotation_id = format!("master-key-e{source_epoch}-to-e{target_epoch}");
|
||||
let aborted = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
source_epoch,
|
||||
target_epoch,
|
||||
target_fingerprint,
|
||||
state,
|
||||
backup_ref,
|
||||
checkpoint_secret_id,
|
||||
total_secret_versions,
|
||||
processed_secret_versions,
|
||||
verified_secret_versions,
|
||||
failure_code,
|
||||
created_at,
|
||||
updated_at
|
||||
from master_key_rotations
|
||||
where id = $1 and state in ('aborted', 'failed')
|
||||
for update",
|
||||
)
|
||||
.bind(&rotation_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if let Some(row) = aborted {
|
||||
let record = map_master_key_rotation(row);
|
||||
if record.source_epoch != source_epoch
|
||||
|| record.target_epoch != target_epoch
|
||||
|| record.target_fingerprint != target_fingerprint
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
sqlx::query(
|
||||
"update secret_versions
|
||||
set target_ciphertext = null,
|
||||
target_key_version = null,
|
||||
target_master_key_epoch = null
|
||||
where target_master_key_epoch = $1",
|
||||
)
|
||||
.bind(target_epoch)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set state = 'running',
|
||||
backup_ref = $2,
|
||||
checkpoint_secret_id = null,
|
||||
total_secret_versions = $3,
|
||||
processed_secret_versions = 0,
|
||||
verified_secret_versions = 0,
|
||||
failure_code = null,
|
||||
updated_at = $4::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(&rotation_id)
|
||||
.bind(backup_ref)
|
||||
.bind(total)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let record = select_master_key_rotation_for_update(&mut tx, &rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
return Ok(record);
|
||||
}
|
||||
sqlx::query(
|
||||
"insert into master_key_rotations (
|
||||
id,
|
||||
source_epoch,
|
||||
target_epoch,
|
||||
target_fingerprint,
|
||||
state,
|
||||
backup_ref,
|
||||
checkpoint_secret_id,
|
||||
total_secret_versions,
|
||||
processed_secret_versions,
|
||||
verified_secret_versions,
|
||||
failure_code,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (
|
||||
$1, $2, $3, $4, 'running', $5, null, $6, 0, 0, null, $7::timestamptz, $7::timestamptz
|
||||
)",
|
||||
)
|
||||
.bind(&rotation_id)
|
||||
.bind(source_epoch)
|
||||
.bind(target_epoch)
|
||||
.bind(target_fingerprint)
|
||||
.bind(backup_ref)
|
||||
.bind(total)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let record = select_master_key_rotation_for_update(&mut tx, &rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn stage_master_key_rotation_ciphertext(
|
||||
&self,
|
||||
rotation_id: &str,
|
||||
secret_id: &SecretId,
|
||||
version: u32,
|
||||
source_epoch: i64,
|
||||
target_ciphertext: &str,
|
||||
target_key_version: &str,
|
||||
target_epoch: i64,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
if rotation.state != "running"
|
||||
|| rotation.source_epoch != source_epoch
|
||||
|| rotation.target_epoch != target_epoch
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
let result = sqlx::query(
|
||||
"update secret_versions
|
||||
set target_ciphertext = $4,
|
||||
target_key_version = $5,
|
||||
target_master_key_epoch = $6
|
||||
where secret_id = $1
|
||||
and version = $2
|
||||
and master_key_epoch = $3
|
||||
and (
|
||||
target_master_key_epoch is null
|
||||
or target_master_key_epoch = $6
|
||||
)
|
||||
and (
|
||||
target_ciphertext is null
|
||||
or target_ciphertext = $4
|
||||
)",
|
||||
)
|
||||
.bind(secret_id.as_str())
|
||||
.bind(to_db_version(version))
|
||||
.bind(source_epoch)
|
||||
.bind(target_ciphertext)
|
||||
.bind(target_key_version)
|
||||
.bind(target_epoch)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
let processed = count_staged_rotation_targets(&mut tx, source_epoch, target_epoch).await?;
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set checkpoint_secret_id = $2,
|
||||
processed_secret_versions = $3,
|
||||
updated_at = $4::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.bind(secret_id.as_str())
|
||||
.bind(processed)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn finish_master_key_rotation_batches(
|
||||
&self,
|
||||
rotation_id: &str,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
if rotation.state != "running"
|
||||
|| rotation.processed_secret_versions != rotation.total_secret_versions
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set state = 'verifying',
|
||||
updated_at = $2::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn verify_master_key_rotation(
|
||||
&self,
|
||||
rotation_id: &str,
|
||||
verified_secret_versions: i64,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
if rotation.state != "verifying"
|
||||
|| verified_secret_versions != rotation.total_secret_versions
|
||||
|| rotation.processed_secret_versions != rotation.total_secret_versions
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationVerificationFailed);
|
||||
}
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set state = 'verified',
|
||||
verified_secret_versions = $2,
|
||||
updated_at = $3::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.bind(verified_secret_versions)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn promote_master_key_rotation(
|
||||
&self,
|
||||
rotation_id: &str,
|
||||
target_identity: MasterKeyIdentityCandidate<'_>,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
validate_master_key_candidate(&target_identity)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
if rotation.state != "verified"
|
||||
|| rotation.target_epoch != target_identity.epoch
|
||||
|| rotation.target_fingerprint != target_identity.fingerprint
|
||||
|| rotation.verified_secret_versions != rotation.total_secret_versions
|
||||
{
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
let processed =
|
||||
count_staged_rotation_targets(&mut tx, rotation.source_epoch, rotation.target_epoch)
|
||||
.await?;
|
||||
if processed != rotation.total_secret_versions {
|
||||
return Err(RegistryError::MasterKeyRotationVerificationFailed);
|
||||
}
|
||||
sqlx::query(
|
||||
"update master_key_identities
|
||||
set status = 'retired',
|
||||
retired_at = $2::timestamptz
|
||||
where status = 'active' and epoch = $1",
|
||||
)
|
||||
.bind(rotation.source_epoch)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.and_then(|result| {
|
||||
if result.rows_affected() == 1 {
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(sqlx::Error::RowNotFound)
|
||||
}
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
sqlx::Error::RowNotFound => RegistryError::MasterKeyRotationConflict,
|
||||
other => RegistryError::Storage(other),
|
||||
})?;
|
||||
sqlx::query(
|
||||
"insert into master_key_identities (
|
||||
epoch,
|
||||
fingerprint,
|
||||
cipher_contract,
|
||||
status,
|
||||
backup_ref,
|
||||
created_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
) values (
|
||||
$1, $2, $3, 'active', $4, $5::timestamptz, $5::timestamptz, null
|
||||
)",
|
||||
)
|
||||
.bind(target_identity.epoch)
|
||||
.bind(target_identity.fingerprint)
|
||||
.bind(target_identity.cipher_contract)
|
||||
.bind(rotation.backup_ref.as_deref())
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update secret_versions
|
||||
set ciphertext = target_ciphertext,
|
||||
key_version = target_key_version,
|
||||
master_key_epoch = target_master_key_epoch,
|
||||
target_ciphertext = null,
|
||||
target_key_version = null,
|
||||
target_master_key_epoch = null
|
||||
where master_key_epoch = $1
|
||||
and target_master_key_epoch = $2",
|
||||
)
|
||||
.bind(rotation.source_epoch)
|
||||
.bind(rotation.target_epoch)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set state = 'promoted',
|
||||
updated_at = $2::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn abort_master_key_rotation(
|
||||
&self,
|
||||
rotation_id: &str,
|
||||
now: &OffsetDateTime,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let rotation = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
if rotation.state == "promoted" {
|
||||
return Err(RegistryError::MasterKeyRotationConflict);
|
||||
}
|
||||
sqlx::query(
|
||||
"update secret_versions
|
||||
set target_ciphertext = null,
|
||||
target_key_version = null,
|
||||
target_master_key_epoch = null
|
||||
where target_master_key_epoch = $1",
|
||||
)
|
||||
.bind(rotation.target_epoch)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"update master_key_rotations
|
||||
set state = 'aborted',
|
||||
updated_at = $2::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated = select_master_key_rotation_for_update(&mut tx, rotation_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(updated)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_master_key_candidate(
|
||||
candidate: &MasterKeyIdentityCandidate<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let valid_fingerprint = candidate.fingerprint.len() == 64
|
||||
&& candidate
|
||||
.fingerprint
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
|
||||
if candidate.epoch < 1
|
||||
|| !valid_fingerprint
|
||||
|| candidate.cipher_contract != crate::model::MASTER_KEY_CIPHER_CONTRACT
|
||||
{
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_master_key_identity(row: sqlx::postgres::PgRow) -> MasterKeyIdentityRecord {
|
||||
MasterKeyIdentityRecord {
|
||||
epoch: row.get::<i64, _>("epoch"),
|
||||
fingerprint: row.get::<String, _>("fingerprint"),
|
||||
cipher_contract: row.get::<String, _>("cipher_contract"),
|
||||
status: row.get::<String, _>("status"),
|
||||
backup_ref: row.get::<Option<String>, _>("backup_ref"),
|
||||
created_at: row.get("created_at"),
|
||||
activated_at: row.get("activated_at"),
|
||||
retired_at: row.get("retired_at"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_master_key_rotation(row: sqlx::postgres::PgRow) -> MasterKeyRotationRecord {
|
||||
MasterKeyRotationRecord {
|
||||
id: row.get::<String, _>("id"),
|
||||
source_epoch: row.get::<i64, _>("source_epoch"),
|
||||
target_epoch: row.get::<i64, _>("target_epoch"),
|
||||
target_fingerprint: row.get::<String, _>("target_fingerprint"),
|
||||
state: row.get::<String, _>("state"),
|
||||
backup_ref: row.get::<Option<String>, _>("backup_ref"),
|
||||
checkpoint_secret_id: row.get::<Option<String>, _>("checkpoint_secret_id"),
|
||||
total_secret_versions: row.get::<i64, _>("total_secret_versions"),
|
||||
processed_secret_versions: row.get::<i64, _>("processed_secret_versions"),
|
||||
verified_secret_versions: row.get::<i64, _>("verified_secret_versions"),
|
||||
failure_code: row.get::<Option<String>, _>("failure_code"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_secret_version_record(
|
||||
row: sqlx::postgres::PgRow,
|
||||
) -> Result<SecretVersionRecord, RegistryError> {
|
||||
Ok(SecretVersionRecord {
|
||||
secret_version: SecretVersion {
|
||||
secret_id: SecretId::new(row.get::<String, _>("secret_id")),
|
||||
version: from_db_version(row.get::<i32, _>("version"), "version")?,
|
||||
ciphertext: row.get::<String, _>("ciphertext"),
|
||||
key_version: row.get::<String, _>("key_version"),
|
||||
created_at: row.get("created_at"),
|
||||
created_by: row.get::<Option<String>, _>("created_by").map(UserId::new),
|
||||
},
|
||||
master_key_epoch: row.get::<i64, _>("master_key_epoch"),
|
||||
target_ciphertext: row.get::<Option<String>, _>("target_ciphertext"),
|
||||
target_key_version: row.get::<Option<String>, _>("target_key_version"),
|
||||
target_master_key_epoch: row.get::<Option<i64>, _>("target_master_key_epoch"),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_rotation_request(
|
||||
source_epoch: i64,
|
||||
target_epoch: i64,
|
||||
target_fingerprint: &str,
|
||||
backup_ref: Option<&str>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let candidate_observed_at = OffsetDateTime::UNIX_EPOCH;
|
||||
validate_master_key_candidate(&MasterKeyIdentityCandidate {
|
||||
epoch: target_epoch,
|
||||
fingerprint: target_fingerprint,
|
||||
cipher_contract: crate::model::MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &candidate_observed_at,
|
||||
})?;
|
||||
if source_epoch < 1 || target_epoch <= source_epoch {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
if let Some(value) = backup_ref {
|
||||
let invalid = value.is_empty()
|
||||
|| value.len() > 256
|
||||
|| value.bytes().any(|byte| byte.is_ascii_control())
|
||||
|| value.contains("://");
|
||||
if invalid {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_active_master_key_epoch(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
master_key_epoch: i64,
|
||||
) -> Result<(), RegistryError> {
|
||||
if master_key_epoch < 1 {
|
||||
return Err(RegistryError::InvalidMasterKeyIdentity);
|
||||
}
|
||||
let active = select_active_master_key_identity_for_update(transaction).await?;
|
||||
match active {
|
||||
Some(identity) if identity.epoch == master_key_epoch => Ok(()),
|
||||
Some(identity) => Err(RegistryError::MasterKeyIdentityMismatch {
|
||||
epoch: identity.epoch,
|
||||
}),
|
||||
None => Err(RegistryError::InvalidMasterKeyIdentity),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_no_active_master_key_rotation(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), RegistryError> {
|
||||
lock_master_key_authority(transaction).await?;
|
||||
let row = sqlx::query(
|
||||
"select id
|
||||
from master_key_rotations
|
||||
where state in ('running', 'verifying', 'verified')
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update",
|
||||
)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?;
|
||||
if row.is_some() {
|
||||
return Err(RegistryError::MasterKeyRotationInProgress);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn lock_master_key_authority(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"lock table
|
||||
master_key_identities,
|
||||
master_key_rotations
|
||||
in share row exclusive mode",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn select_active_master_key_identity_for_update(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<Option<MasterKeyIdentityRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
epoch,
|
||||
fingerprint,
|
||||
cipher_contract,
|
||||
status,
|
||||
backup_ref,
|
||||
created_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
from master_key_identities
|
||||
where status = 'active'
|
||||
order by epoch desc
|
||||
limit 1
|
||||
for update",
|
||||
)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?;
|
||||
Ok(row.map(map_master_key_identity))
|
||||
}
|
||||
|
||||
async fn select_master_key_rotation_for_update(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
rotation_id: &str,
|
||||
) -> Result<MasterKeyRotationRecord, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
source_epoch,
|
||||
target_epoch,
|
||||
target_fingerprint,
|
||||
state,
|
||||
backup_ref,
|
||||
checkpoint_secret_id,
|
||||
total_secret_versions,
|
||||
processed_secret_versions,
|
||||
verified_secret_versions,
|
||||
failure_code,
|
||||
created_at,
|
||||
updated_at
|
||||
from master_key_rotations
|
||||
where id = $1
|
||||
for update",
|
||||
)
|
||||
.bind(rotation_id)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?;
|
||||
row.map(map_master_key_rotation)
|
||||
.ok_or_else(|| RegistryError::MasterKeyRotationNotFound {
|
||||
rotation_id: rotation_id.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_staged_rotation_targets(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
source_epoch: i64,
|
||||
target_epoch: i64,
|
||||
) -> Result<i64, RegistryError> {
|
||||
let count = sqlx::query_scalar::<_, i64>(
|
||||
"select count(*)
|
||||
from secret_versions
|
||||
where master_key_epoch = $1
|
||||
and target_master_key_epoch = $2",
|
||||
)
|
||||
.bind(source_epoch)
|
||||
.bind(target_epoch)
|
||||
.fetch_one(&mut **transaction)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
mod agent;
|
||||
mod agent_catalog;
|
||||
mod api_key;
|
||||
mod approval;
|
||||
mod auth;
|
||||
mod connection;
|
||||
mod import_job;
|
||||
mod master_key;
|
||||
mod observability;
|
||||
mod onboarding;
|
||||
mod operation;
|
||||
mod operation_artifact;
|
||||
mod operation_published;
|
||||
mod pool_config;
|
||||
mod product_event;
|
||||
mod secret;
|
||||
mod upstream;
|
||||
mod workspace;
|
||||
@@ -17,8 +22,8 @@ use crank_core::{
|
||||
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, ApprovalRequestId,
|
||||
AuthProfile, HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId,
|
||||
MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
|
||||
PlatformApiKeyStatus, Secret, SecretId, SecretVersion, Target, UsageRollup, User, UserId,
|
||||
UserSessionId, Workspace, WorkspaceId,
|
||||
PlatformApiKeyStatus, ProductEventId, Secret, SecretId, SecretVersion, Target, UsageRollup,
|
||||
User, UserId, UserSessionId, Workspace, WorkspaceId,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde_json::Value;
|
||||
@@ -30,25 +35,34 @@ pub use pool_config::{PostgresPoolConfig, PostgresPoolConfigError};
|
||||
use crate::{
|
||||
error::RegistryError,
|
||||
model::{
|
||||
AgentSummary, AgentVersionRecord, AppliedImportOperation, ApplyImportJobRequest,
|
||||
ApprovalRequestRecord, AuthUserRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
CreateApprovalRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DecideApprovalRequest, DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest,
|
||||
FinishImportJobRequest, ImportConflictMode, ImportJob, ImportJobApplyResult, ImportJobId,
|
||||
ImportJobStatus, InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
|
||||
AdminBootstrapContractRecord, AdminSecurityAuditRequest, AgentSummary, AgentVersionRecord,
|
||||
AppendProductEventOutcome, AppendProductEventRequest, AppliedImportOperation,
|
||||
ApplyImportJobRequest, ApprovalRequestRecord, AuthUserRecord,
|
||||
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
|
||||
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
|
||||
DescriptorMetadata, ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest,
|
||||
ImportConflictMode, ImportJob, ImportJobApplyResult, ImportJobId, ImportJobStatus,
|
||||
InvitationRecord, InvocationHistoryLoss, InvocationHistoryLossCategory,
|
||||
InvocationHistoryWriteOutcome, InvocationLogRecord, InvocationRetentionOutcome,
|
||||
InvocationRetentionPolicy, InvocationRetentionStatus, ListApprovalRequestsQuery,
|
||||
ListInvocationLogsQuery, ListProductEventsQuery, MasterKeyIdentityCandidate,
|
||||
MasterKeyIdentityRecord, MasterKeyRotationRecord, MasterKeyRotationStatus,
|
||||
MembershipRecord, OnboardingMilestoneResult, OnboardingPresentationMilestone,
|
||||
OperationAgentRef, OperationSampleMetadata, OperationStateExpectation, OperationSummary,
|
||||
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, ProductEventRecord,
|
||||
PublishAgentRequest, PublishRequest, PublishedAgentCatalog, PublishedAgentTool,
|
||||
RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest,
|
||||
SaveAgentCatalogConfigRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
||||
SessionRecord, SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, YamlImportJob,
|
||||
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
RecordOnboardingCompletionRequest, RecordOnboardingMilestoneRequest, RegistryOperation,
|
||||
RotateSecretRequest, SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||
SkippedImportOperation, UpdateWorkspaceRequest, UsageAgentBreakdown,
|
||||
UsageOperationBreakdown, UsageOutcomeBreakdown, UsageQuery, UsageRollupRecord,
|
||||
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
|
||||
WorkspaceUpstream, YamlImportJob, YamlImportJobCompletion, YamlImportJobId,
|
||||
YamlImportJobStatus,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -106,12 +120,23 @@ async fn insert_version_row(
|
||||
generated_draft_json,
|
||||
config_export_json,
|
||||
wizard_state_json,
|
||||
name,
|
||||
display_name,
|
||||
category,
|
||||
protocol,
|
||||
security_level,
|
||||
snapshot_provenance,
|
||||
snapshot_observed_at,
|
||||
published_at,
|
||||
published_by,
|
||||
change_note,
|
||||
created_at,
|
||||
created_by
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||
$9, $10, $11, $12, $13, $14, $15, $16::timestamptz, $17
|
||||
$9, $10, $11, $12, $13, $14,
|
||||
$15, $16, $17, $18, $19, 'native_v4', $20::timestamptz,
|
||||
$21::timestamptz, $22, $23, $24::timestamptz, $25
|
||||
)",
|
||||
)
|
||||
.bind(snapshot.id.as_str())
|
||||
@@ -128,6 +153,17 @@ async fn insert_version_row(
|
||||
.bind(serialize_option_json_value(&snapshot.generated_draft)?.map(Json))
|
||||
.bind(serialize_option_json_value(&snapshot.config_export)?.map(Json))
|
||||
.bind(serialize_option_json_value(&snapshot.wizard_state)?.map(Json))
|
||||
.bind(&snapshot.name)
|
||||
.bind(&snapshot.display_name)
|
||||
.bind(&snapshot.category)
|
||||
.bind(serialize_enum_text(&snapshot.protocol, "protocol")?)
|
||||
.bind(serialize_enum_text(
|
||||
&snapshot.security_level,
|
||||
"security_level",
|
||||
)?)
|
||||
.bind(snapshot.updated_at)
|
||||
.bind(snapshot.published_at)
|
||||
.bind(Option::<&str>::None)
|
||||
.bind(change_note)
|
||||
.bind(snapshot.updated_at)
|
||||
.bind(created_by)
|
||||
@@ -222,10 +258,39 @@ async fn insert_agent_version_row(
|
||||
|
||||
async fn replace_agent_bindings_rows(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
version: u32,
|
||||
bindings: &[AgentOperationBinding],
|
||||
) -> Result<(), RegistryError> {
|
||||
let operation_ids = bindings
|
||||
.iter()
|
||||
.map(|binding| binding.operation_id.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for operation_id in operation_ids {
|
||||
let status = sqlx::query_scalar::<_, String>(
|
||||
"select status from operations
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(operation_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
match status.as_deref() {
|
||||
Some("archived") => {
|
||||
return Err(RegistryError::OperationArchived {
|
||||
operation_id: operation_id.to_owned(),
|
||||
});
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
return Err(RegistryError::OperationNotFound {
|
||||
operation_id: operation_id.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlx::query(
|
||||
"delete from agent_operation_bindings
|
||||
where agent_id = $1 and agent_version = $2",
|
||||
@@ -263,27 +328,6 @@ async fn replace_agent_bindings_rows(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_immutable_fields(
|
||||
summary: &OperationSummary,
|
||||
snapshot: &RegistryOperation,
|
||||
) -> Result<(), RegistryError> {
|
||||
if summary.name != snapshot.name {
|
||||
return Err(RegistryError::ImmutableOperationFieldChanged {
|
||||
operation_id: snapshot.id.as_str().to_owned(),
|
||||
field: "name",
|
||||
});
|
||||
}
|
||||
|
||||
if summary.protocol != snapshot.protocol {
|
||||
return Err(RegistryError::ImmutableOperationFieldChanged {
|
||||
operation_id: snapshot.id.as_str().to_owned(),
|
||||
field: "protocol",
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_user(
|
||||
id: String,
|
||||
email: String,
|
||||
@@ -324,7 +368,19 @@ fn map_invocation_log_record(row: &PgRow) -> Result<InvocationLogRecord, Registr
|
||||
agent_id: row
|
||||
.try_get::<Option<String>, _>("agent_id")?
|
||||
.map(AgentId::new),
|
||||
platform_api_key_id: row
|
||||
.try_get::<Option<String>, _>("platform_api_key_id")?
|
||||
.map(PlatformApiKeyId::new),
|
||||
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
|
||||
operation_version: row
|
||||
.try_get::<Option<i32>, _>("operation_version")?
|
||||
.map(|value| {
|
||||
u32::try_from(value).map_err(|_| RegistryError::InvalidNumericValue {
|
||||
field: "operation_version",
|
||||
value: i64::from(value),
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
source: deserialize_enum_text(&row.try_get::<String, _>("source")?, "source")?,
|
||||
level: deserialize_enum_text(&row.try_get::<String, _>("level")?, "level")?,
|
||||
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
||||
@@ -345,6 +401,22 @@ fn map_invocation_log_record(row: &PgRow) -> Result<InvocationLogRecord, Registr
|
||||
},
|
||||
duration_ms: to_u64(row.try_get::<i64, _>("duration_ms")?, "duration_ms")?,
|
||||
error_kind: row.try_get("error_kind")?,
|
||||
execution_stage: row
|
||||
.try_get::<Option<String>, _>("execution_stage")?
|
||||
.map(|value| deserialize_enum_text(&value, "execution_stage"))
|
||||
.transpose()?,
|
||||
execution_error_code: row
|
||||
.try_get::<Option<String>, _>("execution_error_code")?
|
||||
.map(|value| deserialize_enum_text(&value, "execution_error_code"))
|
||||
.transpose()?,
|
||||
retryability: row
|
||||
.try_get::<Option<String>, _>("retryability")?
|
||||
.map(|value| deserialize_enum_text(&value, "retryability"))
|
||||
.transpose()?,
|
||||
outcome_certainty: row
|
||||
.try_get::<Option<String>, _>("outcome_certainty")?
|
||||
.map(|value| deserialize_enum_text(&value, "outcome_certainty"))
|
||||
.transpose()?,
|
||||
request_preview: row.try_get::<Json<Value>, _>("request_preview_json")?.0,
|
||||
response_preview: row.try_get::<Json<Value>, _>("response_preview_json")?.0,
|
||||
created_at: row.try_get("created_at")?,
|
||||
@@ -423,6 +495,7 @@ fn build_agent_summary(
|
||||
status: String,
|
||||
current_draft_version: i32,
|
||||
latest_published_version: Option<i32>,
|
||||
catalog_revision: i64,
|
||||
created_at: OffsetDateTime,
|
||||
updated_at: OffsetDateTime,
|
||||
published_at: Option<OffsetDateTime>,
|
||||
@@ -438,6 +511,7 @@ fn build_agent_summary(
|
||||
latest_published_version: latest_published_version
|
||||
.map(|value| from_db_version(value, "latest_published_version"))
|
||||
.transpose()?,
|
||||
catalog_revision,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at,
|
||||
@@ -457,6 +531,7 @@ fn build_operation_summary(
|
||||
status: String,
|
||||
current_draft_version: i32,
|
||||
latest_published_version: Option<i32>,
|
||||
can_delete: bool,
|
||||
created_at: OffsetDateTime,
|
||||
updated_at: OffsetDateTime,
|
||||
published_at: Option<OffsetDateTime>,
|
||||
@@ -479,6 +554,7 @@ fn build_operation_summary(
|
||||
latest_published_version: latest_published_version
|
||||
.map(|value| from_db_version(value, "latest_published_version"))
|
||||
.transpose()?,
|
||||
can_delete,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at,
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
use super::*;
|
||||
|
||||
const PRESERVED_USAGE_WINDOW_DAYS: u16 = 90;
|
||||
|
||||
fn usage_outcome_group_case_sql(prefix: &str) -> String {
|
||||
debug_assert!(matches!(prefix, "" | "l."));
|
||||
format!(
|
||||
"case
|
||||
when {prefix}status = 'ok' then 'success'
|
||||
when {prefix}execution_error_code in (
|
||||
'upstream_auth_error',
|
||||
'upstream_not_found',
|
||||
'upstream_rate_limited',
|
||||
'upstream_server_error',
|
||||
'upstream_status_error',
|
||||
'upstream_timeout',
|
||||
'upstream_transport_error',
|
||||
'upstream_request_too_large',
|
||||
'upstream_response_too_large'
|
||||
) then 'upstream'
|
||||
when execution_error_code in (
|
||||
'authorization_denied',
|
||||
'auth_profile_not_found',
|
||||
'secret_not_found',
|
||||
'secret_invalid',
|
||||
'input_schema_invalid',
|
||||
'input_mapping_invalid',
|
||||
'outbound_target_rejected',
|
||||
'adapter_configuration_invalid',
|
||||
'confirmation_required',
|
||||
'confirmation_invalid',
|
||||
'idempotency_in_progress',
|
||||
'idempotency_conflict',
|
||||
'idempotency_outcome_unknown'
|
||||
) then 'client'
|
||||
when execution_error_code in (
|
||||
'prepared_request_invalid',
|
||||
'output_mapping_invalid',
|
||||
'output_schema_invalid'
|
||||
) then 'schema'
|
||||
when execution_error_code in (
|
||||
'execution_overloaded',
|
||||
'safety_store_unavailable',
|
||||
'protocol_unsupported',
|
||||
'execution_mode_unsupported',
|
||||
'persistence_unavailable',
|
||||
'runtime_internal'
|
||||
) then 'crank'
|
||||
else 'crank'
|
||||
end"
|
||||
)
|
||||
}
|
||||
|
||||
fn invocation_history_loss_category(error: &RegistryError) -> InvocationHistoryLossCategory {
|
||||
match error {
|
||||
RegistryError::Storage(error)
|
||||
@@ -19,12 +70,36 @@ impl PostgresRegistry {
|
||||
pub async fn delete_invocation_logs_before(
|
||||
&self,
|
||||
cutoff: OffsetDateTime,
|
||||
) -> Result<u64, RegistryError> {
|
||||
let result = sqlx::query("delete from invocation_logs where created_at < $1::timestamptz")
|
||||
.bind(cutoff)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
) -> Result<InvocationRetentionOutcome, RegistryError> {
|
||||
let usage_floor = OffsetDateTime::now_utc()
|
||||
- time::Duration::days(i64::from(PRESERVED_USAGE_WINDOW_DAYS));
|
||||
let effective_cutoff = cutoff.min(usage_floor);
|
||||
let result = sqlx::query(
|
||||
"delete from invocation_logs l
|
||||
where l.created_at < $1::timestamptz
|
||||
and not exists (
|
||||
select 1 from onboarding_selections s
|
||||
where s.invocation_log_id = l.id or s.test_log_id = l.id
|
||||
)",
|
||||
)
|
||||
.bind(effective_cutoff)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let deleted_records = result.rows_affected();
|
||||
Ok(InvocationRetentionOutcome {
|
||||
status: if deleted_records == 0 {
|
||||
InvocationRetentionStatus::Noop
|
||||
} else {
|
||||
InvocationRetentionStatus::Completed
|
||||
},
|
||||
deleted_records,
|
||||
policy: InvocationRetentionPolicy {
|
||||
requested_cutoff: cutoff,
|
||||
effective_cutoff,
|
||||
usage_preservation_floor: usage_floor,
|
||||
preserved_usage_window_days: PRESERVED_USAGE_WINDOW_DAYS,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_invocation_log(
|
||||
@@ -63,15 +138,77 @@ impl PostgresRegistry {
|
||||
.ok_or(RegistryError::InvalidCorrelationIdentity { field: "trace_id" })?;
|
||||
crank_core::TraceId::parse(trace_id)
|
||||
.map_err(|_| RegistryError::InvalidCorrelationIdentity { field: "trace_id" })?;
|
||||
let operation_version =
|
||||
request
|
||||
.log
|
||||
.operation_version
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "operation_version",
|
||||
})?;
|
||||
let _stage = request
|
||||
.log
|
||||
.execution_stage
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "execution_stage",
|
||||
})?;
|
||||
let _retryability =
|
||||
request
|
||||
.log
|
||||
.retryability
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "retryability",
|
||||
})?;
|
||||
let _certainty =
|
||||
request
|
||||
.log
|
||||
.outcome_certainty
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "outcome_certainty",
|
||||
})?;
|
||||
match request.log.status {
|
||||
crank_core::InvocationStatus::Ok if request.log.execution_error_code.is_some() => {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "execution_error_code",
|
||||
});
|
||||
}
|
||||
crank_core::InvocationStatus::Error if request.log.execution_error_code.is_none() => {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "execution_error_code",
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let request_preview = crank_core::sanitize_invocation_preview(&request.log.request_preview);
|
||||
let response_preview =
|
||||
crank_core::sanitize_invocation_preview(&request.log.response_preview);
|
||||
let operation_version =
|
||||
i32::try_from(operation_version).map_err(|_| RegistryError::InvalidNumericValue {
|
||||
field: "operation_version",
|
||||
value: i64::MAX,
|
||||
})?;
|
||||
let should_anchor_onboarding = request.log.source
|
||||
== crank_core::InvocationSource::AgentToolCall
|
||||
&& request.log.status == crank_core::InvocationStatus::Ok
|
||||
&& request.log.execution_stage == Some(crank_core::ExecutionStage::Runtime)
|
||||
&& request.log.outcome_certainty == Some(crank_core::OutcomeCertainty::Certain)
|
||||
&& request.log.execution_error_code.is_none()
|
||||
&& request.log.agent_id.is_some()
|
||||
&& request.log.platform_api_key_id.is_some();
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
if should_anchor_onboarding {
|
||||
sqlx::query("select id from workspaces where id = $1 for update")
|
||||
.bind(request.log.workspace_id.as_str())
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"insert into invocation_logs (
|
||||
id,
|
||||
workspace_id,
|
||||
agent_id,
|
||||
platform_api_key_id,
|
||||
operation_id,
|
||||
operation_version,
|
||||
source,
|
||||
level,
|
||||
status,
|
||||
@@ -82,17 +219,29 @@ impl PostgresRegistry {
|
||||
status_code,
|
||||
duration_ms,
|
||||
error_kind,
|
||||
execution_stage,
|
||||
execution_error_code,
|
||||
retryability,
|
||||
outcome_certainty,
|
||||
request_preview_json,
|
||||
response_preview_json,
|
||||
created_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::timestamptz
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23::timestamptz
|
||||
)",
|
||||
)
|
||||
.bind(request.log.id.as_str())
|
||||
.bind(request.log.workspace_id.as_str())
|
||||
.bind(request.log.agent_id.as_ref().map(|value| value.as_str()))
|
||||
.bind(
|
||||
request
|
||||
.log
|
||||
.platform_api_key_id
|
||||
.as_ref()
|
||||
.map(|value| value.as_str()),
|
||||
)
|
||||
.bind(request.log.operation_id.as_str())
|
||||
.bind(operation_version)
|
||||
.bind(serialize_enum_text(&request.log.source, "source")?)
|
||||
.bind(serialize_enum_text(&request.log.level, "level")?)
|
||||
.bind(serialize_enum_text(&request.log.status, "status")?)
|
||||
@@ -108,12 +257,157 @@ impl PostgresRegistry {
|
||||
}
|
||||
})?)
|
||||
.bind(&request.log.error_kind)
|
||||
.bind(
|
||||
request
|
||||
.log
|
||||
.execution_stage
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "execution_stage"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
request
|
||||
.log
|
||||
.execution_error_code
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "execution_error_code"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
request
|
||||
.log
|
||||
.retryability
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "retryability"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
request
|
||||
.log
|
||||
.outcome_certainty
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "outcome_certainty"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(Json(request_preview))
|
||||
.bind(Json(response_preview))
|
||||
.bind(request.log.created_at)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if should_anchor_onboarding {
|
||||
let agent_id =
|
||||
request
|
||||
.log
|
||||
.agent_id
|
||||
.as_ref()
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "onboarding.agent_id",
|
||||
})?;
|
||||
let key_id = request.log.platform_api_key_id.as_ref().ok_or(
|
||||
RegistryError::InvalidExecutionRecord {
|
||||
field: "onboarding.platform_api_key_id",
|
||||
},
|
||||
)?;
|
||||
let anchored = sqlx::query(
|
||||
"insert into onboarding_selections (
|
||||
workspace_id, operation_id, operation_version, agent_id, catalog_revision,
|
||||
platform_api_key_id, invocation_log_id, test_log_id, evidence_after, selected_at
|
||||
)
|
||||
select $1, $2, $3, a.id, pa.catalog_revision, k.id, $6,
|
||||
(
|
||||
select l.id from invocation_logs l
|
||||
where l.workspace_id = $1 and l.operation_id = $2
|
||||
and l.operation_version = $3 and l.source = 'admin_test_run'
|
||||
and l.status = 'ok' and l.execution_stage = 'runtime'
|
||||
and l.outcome_certainty = 'certain' and l.execution_error_code is null
|
||||
and l.created_at >= coalesce(
|
||||
(select s.evidence_after from onboarding_selections s
|
||||
where s.workspace_id = $1),
|
||||
'-infinity'::timestamptz
|
||||
)
|
||||
order by l.created_at desc, l.id desc limit 1
|
||||
),
|
||||
coalesce(
|
||||
(select s.evidence_after from onboarding_selections s
|
||||
where s.workspace_id = $1),
|
||||
'-infinity'::timestamptz
|
||||
),
|
||||
$7
|
||||
from agents a
|
||||
join published_agents pa on pa.agent_id = a.id
|
||||
join agent_operation_bindings b
|
||||
on b.agent_id = pa.agent_id and b.agent_version = pa.version
|
||||
and b.operation_id = $2 and b.operation_version = $3 and b.enabled
|
||||
join published_operations po on po.operation_id = b.operation_id and po.version = b.operation_version
|
||||
join operations o on o.id = po.operation_id and o.workspace_id = $1
|
||||
and o.status = 'published' and o.current_draft_version = po.version
|
||||
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id and k.id = $5
|
||||
where a.workspace_id = $1 and a.id = $4 and a.status = 'published'
|
||||
and k.key_kind = 'mcp_client' and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > $7)
|
||||
on conflict (workspace_id) do update
|
||||
set operation_id = excluded.operation_id,
|
||||
operation_version = excluded.operation_version,
|
||||
agent_id = excluded.agent_id,
|
||||
catalog_revision = excluded.catalog_revision,
|
||||
platform_api_key_id = excluded.platform_api_key_id,
|
||||
invocation_log_id = excluded.invocation_log_id,
|
||||
test_log_id = excluded.test_log_id,
|
||||
selected_at = excluded.selected_at
|
||||
where onboarding_selections.operation_id is null
|
||||
and excluded.selected_at >= onboarding_selections.evidence_after",
|
||||
)
|
||||
.bind(request.log.workspace_id.as_str())
|
||||
.bind(request.log.operation_id.as_str())
|
||||
.bind(operation_version)
|
||||
.bind(agent_id.as_str())
|
||||
.bind(key_id.as_str())
|
||||
.bind(request.log.id.as_str())
|
||||
.bind(request.log.created_at)
|
||||
.execute(&mut *transaction)
|
||||
.await?
|
||||
.rows_affected()
|
||||
> 0;
|
||||
if anchored {
|
||||
let projection = super::onboarding::get_onboarding_projection_in_transaction(
|
||||
&mut transaction,
|
||||
&request.log.workspace_id,
|
||||
)
|
||||
.await?;
|
||||
let eligible =
|
||||
super::product_event::get_product_event_by_idempotency_key_in_transaction(
|
||||
&mut transaction,
|
||||
&request.log.workspace_id,
|
||||
"onboarding:eligible:v1",
|
||||
)
|
||||
.await?;
|
||||
if let Some(eligible_since) = eligible
|
||||
.filter(|_| projection.completed)
|
||||
.and_then(|record| record.event.eligible_since)
|
||||
{
|
||||
let completed = crank_core::ProductEvent {
|
||||
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
|
||||
workspace_id: request.log.workspace_id.clone(),
|
||||
kind: crank_core::ProductEventKind::OnboardingCompleted,
|
||||
schema_version: crank_core::PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: true,
|
||||
eligible_since: Some(eligible_since),
|
||||
idempotency_key: "onboarding:completed:v1".to_owned(),
|
||||
occurred_at: request.log.created_at,
|
||||
};
|
||||
super::product_event::append_product_event_in_transaction(
|
||||
&mut transaction,
|
||||
&completed,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -121,12 +415,15 @@ impl PostgresRegistry {
|
||||
&self,
|
||||
query: ListInvocationLogsQuery<'_>,
|
||||
) -> Result<Vec<InvocationLogRecord>, RegistryError> {
|
||||
let rows = sqlx::query(
|
||||
let outcome_group_case = usage_outcome_group_case_sql("l.");
|
||||
let sql = format!(
|
||||
"select
|
||||
l.id,
|
||||
l.workspace_id,
|
||||
l.agent_id,
|
||||
l.platform_api_key_id,
|
||||
l.operation_id,
|
||||
l.operation_version,
|
||||
l.source,
|
||||
l.level,
|
||||
l.status,
|
||||
@@ -137,6 +434,10 @@ impl PostgresRegistry {
|
||||
l.status_code,
|
||||
l.duration_ms,
|
||||
l.error_kind,
|
||||
l.execution_stage,
|
||||
l.execution_error_code,
|
||||
l.retryability,
|
||||
l.outcome_certainty,
|
||||
l.request_preview_json,
|
||||
l.response_preview_json,
|
||||
l.created_at as created_at,
|
||||
@@ -149,42 +450,67 @@ impl PostgresRegistry {
|
||||
left join agents a on a.id = l.agent_id
|
||||
where l.workspace_id = $1
|
||||
and ($2::text is null or l.level = $2)
|
||||
and ($3::text is null or l.source = $3)
|
||||
and ($4::text is null or l.operation_id = $4)
|
||||
and ($5::text is null or l.agent_id = $5)
|
||||
and ($6::timestamptz is null or l.created_at >= $6::timestamptz)
|
||||
and ($3::text is null or l.status = $3)
|
||||
and ($4::text is null or l.source = $4)
|
||||
and ($5::text is null or l.operation_id = $5)
|
||||
and ($6::text is null or l.agent_id = $6)
|
||||
and ($7::text is null or ({outcome_group_case}) = $7)
|
||||
and ($8::timestamptz is null or l.created_at >= $8::timestamptz)
|
||||
and ($9::timestamptz is null or l.created_at < $9::timestamptz)
|
||||
and (
|
||||
$7::text is null
|
||||
or l.tool_name ilike '%' || $7 || '%'
|
||||
or l.message ilike '%' || $7 || '%'
|
||||
or o.name ilike '%' || $7 || '%'
|
||||
or o.display_name ilike '%' || $7 || '%'
|
||||
$10::timestamptz is null
|
||||
or (l.created_at, l.id) < ($10::timestamptz, $11::text)
|
||||
)
|
||||
order by l.created_at desc
|
||||
limit $8",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(
|
||||
query
|
||||
.level
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "level"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "source"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(query.operation_id.map(|value| value.as_str()))
|
||||
.bind(query.agent_id.map(|value| value.as_str()))
|
||||
.bind(query.created_after)
|
||||
.bind(query.search_text)
|
||||
.bind(i64::from(query.limit))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
and (
|
||||
$12::text is null
|
||||
or l.tool_name ilike '%' || $12 || '%'
|
||||
or l.message ilike '%' || $12 || '%'
|
||||
or o.name ilike '%' || $12 || '%'
|
||||
or o.display_name ilike '%' || $12 || '%'
|
||||
)
|
||||
order by l.created_at desc, l.id desc
|
||||
limit $13"
|
||||
);
|
||||
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(
|
||||
query
|
||||
.level
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "level"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
query
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "status"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "source"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(query.operation_id.map(|value| value.as_str()))
|
||||
.bind(query.agent_id.map(|value| value.as_str()))
|
||||
.bind(
|
||||
query
|
||||
.outcome_group
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "outcome_group"))
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(query.cursor_created_at)
|
||||
.bind(query.cursor_id.map(|value| value.as_str()))
|
||||
.bind(query.search_text)
|
||||
.bind(i64::from(query.limit))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.iter().map(map_invocation_log_record).collect()
|
||||
}
|
||||
@@ -199,7 +525,9 @@ impl PostgresRegistry {
|
||||
l.id,
|
||||
l.workspace_id,
|
||||
l.agent_id,
|
||||
l.platform_api_key_id,
|
||||
l.operation_id,
|
||||
l.operation_version,
|
||||
l.source,
|
||||
l.level,
|
||||
l.status,
|
||||
@@ -210,6 +538,10 @@ impl PostgresRegistry {
|
||||
l.status_code,
|
||||
l.duration_ms,
|
||||
l.error_kind,
|
||||
l.execution_stage,
|
||||
l.execution_error_code,
|
||||
l.retryability,
|
||||
l.outcome_certainty,
|
||||
l.request_preview_json,
|
||||
l.response_preview_json,
|
||||
l.created_at as created_at,
|
||||
@@ -245,10 +577,12 @@ impl PostgresRegistry {
|
||||
from invocation_logs
|
||||
where workspace_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and ($3::text is null or source = $3)",
|
||||
and created_at < $3::timestamptz
|
||||
and ($4::text is null or source = $4)",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -300,13 +634,15 @@ impl PostgresRegistry {
|
||||
from invocation_logs
|
||||
where workspace_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and ($3::text is null or source = $3)
|
||||
and created_at < $3::timestamptz
|
||||
and ($4::text is null or source = $4)
|
||||
group by 1
|
||||
order by 1 asc"
|
||||
);
|
||||
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -347,12 +683,14 @@ impl PostgresRegistry {
|
||||
join operations o on o.id = l.operation_id
|
||||
where l.workspace_id = $1
|
||||
and l.created_at >= $2::timestamptz
|
||||
and ($3::text is null or l.source = $3)
|
||||
and l.created_at < $3::timestamptz
|
||||
and ($4::text is null or l.source = $4)
|
||||
group by o.id, o.name, o.display_name, o.protocol
|
||||
order by calls_total desc, o.name asc",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -383,11 +721,13 @@ impl PostgresRegistry {
|
||||
where workspace_id = $1
|
||||
and operation_id = $2
|
||||
and created_at >= $3::timestamptz
|
||||
and ($4::text is null or source = $4)",
|
||||
and created_at < $4::timestamptz
|
||||
and ($5::text is null or source = $5)",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(operation_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -437,12 +777,14 @@ impl PostgresRegistry {
|
||||
join agents a on a.id = l.agent_id
|
||||
where l.workspace_id = $1
|
||||
and l.created_at >= $2::timestamptz
|
||||
and ($3::text is null or l.source = $3)
|
||||
and l.created_at < $3::timestamptz
|
||||
and ($4::text is null or l.source = $4)
|
||||
group by a.id, a.slug, a.display_name
|
||||
order by calls_total desc, a.slug asc",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -473,11 +815,13 @@ impl PostgresRegistry {
|
||||
where workspace_id = $1
|
||||
and agent_id = $2
|
||||
and created_at >= $3::timestamptz
|
||||
and ($4::text is null or source = $4)",
|
||||
and created_at < $4::timestamptz
|
||||
and ($5::text is null or source = $5)",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(agent_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
@@ -508,4 +852,78 @@ impl PostgresRegistry {
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_usage_outcomes(
|
||||
&self,
|
||||
query: UsageQuery<'_>,
|
||||
) -> Result<Vec<UsageOutcomeBreakdown>, RegistryError> {
|
||||
let outcome_group_case = usage_outcome_group_case_sql("");
|
||||
let sql = format!(
|
||||
"select
|
||||
{outcome_group_case} as outcome_group,
|
||||
execution_error_code,
|
||||
count(*)::bigint as calls_total,
|
||||
coalesce(percentile_cont(0.5) within group (order by duration_ms), 0)::bigint as p50_ms,
|
||||
coalesce(percentile_cont(0.95) within group (order by duration_ms), 0)::bigint as p95_ms,
|
||||
coalesce(percentile_cont(0.99) within group (order by duration_ms), 0)::bigint as p99_ms
|
||||
from invocation_logs
|
||||
where workspace_id = $1
|
||||
and created_at >= $2::timestamptz
|
||||
and created_at < $3::timestamptz
|
||||
and ($4::text is null or source = $4)
|
||||
group by 1, execution_error_code
|
||||
order by outcome_group asc, execution_error_code asc nulls first"
|
||||
);
|
||||
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(
|
||||
query
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|value| serialize_enum_text(value, "source"))
|
||||
.transpose()?,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
Ok(UsageOutcomeBreakdown {
|
||||
group: deserialize_enum_text(
|
||||
&row.try_get::<String, _>("outcome_group")?,
|
||||
"outcome_group",
|
||||
)?,
|
||||
execution_error_code: row
|
||||
.try_get::<Option<String>, _>("execution_error_code")?
|
||||
.map(|value| deserialize_enum_text(&value, "execution_error_code"))
|
||||
.transpose()?,
|
||||
calls_total: to_u64(row.try_get::<i64, _>("calls_total")?, "calls_total")?,
|
||||
p50_ms: to_u64(row.try_get::<i64, _>("p50_ms")?, "p50_ms")?,
|
||||
p95_ms: to_u64(row.try_get::<i64, _>("p95_ms")?, "p95_ms")?,
|
||||
p99_ms: to_u64(row.try_get::<i64, _>("p99_ms")?, "p99_ms")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crank_core::ExecutionErrorCode;
|
||||
|
||||
use super::usage_outcome_group_case_sql;
|
||||
|
||||
#[test]
|
||||
fn usage_outcome_group_sql_covers_every_execution_error_code() {
|
||||
let sql = usage_outcome_group_case_sql("");
|
||||
for code in ExecutionErrorCode::ALL {
|
||||
assert!(
|
||||
sql.contains(code.as_str()),
|
||||
"missing execution outcome grouping for {}",
|
||||
code.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
use crank_core::product_event::PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX;
|
||||
use crank_core::{
|
||||
AgentId, InvocationLogId, OnboardingProjection, OnboardingStep, OnboardingStepId, OperationId,
|
||||
PRODUCT_EVENT_SCHEMA_VERSION, PlatformApiKeyId, ProductEvent, ProductEventKind,
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::*;
|
||||
|
||||
const ONBOARDING_PROJECTION_SQL: &str = r#"
|
||||
with selection_state as (
|
||||
select operation_id, operation_version, agent_id, catalog_revision,
|
||||
platform_api_key_id, invocation_log_id, test_log_id, evidence_after, selected_at
|
||||
from onboarding_selections
|
||||
where workspace_id = $1
|
||||
), operation_candidates as (
|
||||
select o.id, o.current_draft_version, o.latest_published_version, o.updated_at,
|
||||
coalesce(ss.operation_version, o.latest_published_version, o.current_draft_version) as target_version,
|
||||
(o.status <> 'archived') as operation_active,
|
||||
exists (
|
||||
select 1 from invocation_logs l
|
||||
where l.workspace_id = $1 and l.operation_id = o.id
|
||||
and l.operation_version = coalesce(ss.operation_version, o.latest_published_version, o.current_draft_version)
|
||||
and (ss.test_log_id is null or l.id = ss.test_log_id)
|
||||
and l.created_at >= coalesce(ss.evidence_after, '-infinity'::timestamptz)
|
||||
and l.source = 'admin_test_run' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null
|
||||
) as tested,
|
||||
exists (
|
||||
select 1 from published_operations po
|
||||
where po.operation_id = o.id
|
||||
and po.version = coalesce(ss.operation_version, o.latest_published_version)
|
||||
) as published,
|
||||
exists (
|
||||
select 1 from agent_operation_bindings b
|
||||
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
|
||||
join agents a on a.id = pa.agent_id
|
||||
where b.operation_id = o.id
|
||||
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
|
||||
and (ss.agent_id is null or a.id = ss.agent_id)
|
||||
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
|
||||
and b.enabled and a.workspace_id = $1 and a.status = 'published'
|
||||
and o.status <> 'archived'
|
||||
) as published_agent,
|
||||
exists (
|
||||
select 1 from agent_operation_bindings b
|
||||
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
|
||||
join agents a on a.id = pa.agent_id
|
||||
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id
|
||||
where b.operation_id = o.id
|
||||
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
|
||||
and (ss.agent_id is null or a.id = ss.agent_id)
|
||||
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
|
||||
and (ss.platform_api_key_id is null or k.id = ss.platform_api_key_id)
|
||||
and b.enabled and a.workspace_id = $1 and a.status = 'published'
|
||||
and o.status <> 'archived'
|
||||
and k.key_kind = 'mcp_client' and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())
|
||||
) as active_key,
|
||||
exists (
|
||||
select 1 from agent_operation_bindings b
|
||||
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
|
||||
join agents a on a.id = pa.agent_id
|
||||
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id
|
||||
join invocation_logs l
|
||||
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
|
||||
and l.operation_id = o.id and l.operation_version = b.operation_version
|
||||
where b.operation_id = o.id
|
||||
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
|
||||
and (ss.agent_id is null or a.id = ss.agent_id)
|
||||
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
|
||||
and (ss.platform_api_key_id is null or k.id = ss.platform_api_key_id)
|
||||
and b.enabled and a.workspace_id = $1 and a.status = 'published'
|
||||
and o.status <> 'archived'
|
||||
and k.key_kind = 'mcp_client' and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())
|
||||
and l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null and l.created_at >= k.created_at
|
||||
and l.created_at >= pa.published_at
|
||||
and l.created_at >= coalesce(ss.evidence_after, '-infinity'::timestamptz)
|
||||
and (ss.invocation_log_id is null or l.id = ss.invocation_log_id)
|
||||
) as first_call_complete
|
||||
, exists (
|
||||
select 1 from agent_operation_bindings b
|
||||
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
|
||||
join agents a on a.id = pa.agent_id
|
||||
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = a.id
|
||||
join invocation_logs l
|
||||
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
|
||||
and l.operation_id = o.id and l.operation_version = b.operation_version
|
||||
where b.operation_id = o.id
|
||||
and b.operation_version = coalesce(ss.operation_version, o.latest_published_version)
|
||||
and (ss.agent_id is null or a.id = ss.agent_id)
|
||||
and (ss.catalog_revision is null or pa.catalog_revision = ss.catalog_revision)
|
||||
and (ss.platform_api_key_id is null or k.id = ss.platform_api_key_id)
|
||||
and b.enabled and a.workspace_id = $1
|
||||
and k.key_kind = 'mcp_client'
|
||||
and l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null and l.created_at >= k.created_at
|
||||
and l.created_at >= pa.published_at
|
||||
and l.created_at >= coalesce(ss.evidence_after, '-infinity'::timestamptz)
|
||||
and (ss.invocation_log_id is null or l.id = ss.invocation_log_id)
|
||||
) as historical_first_call
|
||||
from operations o
|
||||
left join selection_state ss on true
|
||||
where o.workspace_id = $1 and (ss.operation_id is null or o.id = ss.operation_id)
|
||||
), selected_operation as (
|
||||
select o.id, o.current_draft_version, o.latest_published_version, o.updated_at, o.target_version,
|
||||
o.operation_active
|
||||
from operation_candidates o
|
||||
order by o.historical_first_call desc, o.operation_active desc,
|
||||
o.first_call_complete desc, o.active_key desc, o.published_agent desc,
|
||||
o.published desc, o.tested desc, o.updated_at desc, o.id
|
||||
limit 1
|
||||
), operation_state as (
|
||||
select so.*,
|
||||
(so.operation_active and exists (
|
||||
select 1 from invocation_logs l
|
||||
where l.workspace_id = $1 and l.operation_id = so.id
|
||||
and l.operation_version = so.target_version
|
||||
and l.created_at >= coalesce((select evidence_after from selection_state), '-infinity'::timestamptz)
|
||||
and ((select test_log_id from selection_state) is null
|
||||
or l.id = (select test_log_id from selection_state))
|
||||
and l.source = 'admin_test_run' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null
|
||||
)) as tested,
|
||||
(so.operation_active and so.current_draft_version = so.target_version
|
||||
and po.operation_id is not null and po.version = so.target_version) as published,
|
||||
po.version as published_version
|
||||
from selected_operation so
|
||||
left join published_operations po on po.operation_id = so.id and po.version = so.target_version
|
||||
), agent_candidates as (
|
||||
select a.id, pa.catalog_revision, pa.published_at as agent_published_at,
|
||||
a.updated_at, b.operation_id, b.operation_version, os.operation_active,
|
||||
(os.published and a.status = 'published') as agent_active,
|
||||
exists (
|
||||
select 1 from platform_api_keys k
|
||||
where k.workspace_id = $1 and k.agent_id = a.id
|
||||
and os.operation_active and a.status = 'published'
|
||||
and k.key_kind = 'mcp_client' and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())
|
||||
) as active_key,
|
||||
exists (
|
||||
select 1 from platform_api_keys k
|
||||
join invocation_logs l
|
||||
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
|
||||
and l.operation_id = b.operation_id and l.operation_version = b.operation_version
|
||||
where k.workspace_id = $1 and k.agent_id = a.id
|
||||
and os.operation_active and a.status = 'published'
|
||||
and k.key_kind = 'mcp_client' and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())
|
||||
and l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null and l.created_at >= k.created_at
|
||||
and l.created_at >= pa.published_at
|
||||
) as first_call_complete
|
||||
, exists (
|
||||
select 1 from platform_api_keys k
|
||||
join invocation_logs l
|
||||
on l.workspace_id = $1 and l.agent_id = a.id and l.platform_api_key_id = k.id
|
||||
and l.operation_id = b.operation_id and l.operation_version = b.operation_version
|
||||
where k.workspace_id = $1 and k.agent_id = a.id
|
||||
and k.key_kind = 'mcp_client'
|
||||
and l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null and l.created_at >= k.created_at
|
||||
and l.created_at >= pa.published_at
|
||||
) as historical_first_call
|
||||
from operation_state os
|
||||
join agent_operation_bindings b
|
||||
on b.operation_id = os.id and b.operation_version = os.published_version and b.enabled
|
||||
join published_agents pa on pa.agent_id = b.agent_id and pa.version = b.agent_version
|
||||
join agents a on a.id = pa.agent_id
|
||||
where a.workspace_id = $1
|
||||
and ((select agent_id from selection_state) is null
|
||||
or a.id = (select agent_id from selection_state))
|
||||
and ((select catalog_revision from selection_state) is null
|
||||
or pa.catalog_revision = (select catalog_revision from selection_state))
|
||||
), selected_agent as (
|
||||
select id, catalog_revision, agent_published_at, updated_at, operation_id, operation_version,
|
||||
operation_active, agent_active
|
||||
from agent_candidates
|
||||
order by historical_first_call desc, agent_active desc,
|
||||
first_call_complete desc, active_key desc, updated_at desc, id
|
||||
limit 1
|
||||
), key_candidates as (
|
||||
select k.id, k.agent_id, k.last_used_at, k.created_at, k.expires_at, k.status,
|
||||
(sa.agent_active and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())) as key_active,
|
||||
exists (
|
||||
select 1 from invocation_logs l
|
||||
where l.workspace_id = $1 and l.agent_id = sa.id and l.platform_api_key_id = k.id
|
||||
and l.operation_id = sa.operation_id and l.operation_version = sa.operation_version
|
||||
and l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null and l.created_at >= k.created_at
|
||||
and l.created_at >= sa.agent_published_at
|
||||
) as first_call_complete
|
||||
from selected_agent sa
|
||||
join platform_api_keys k on k.workspace_id = $1 and k.agent_id = sa.id
|
||||
where k.key_kind = 'mcp_client'
|
||||
and ((select platform_api_key_id from selection_state) is null
|
||||
or k.id = (select platform_api_key_id from selection_state))
|
||||
), selected_key as (
|
||||
select id, agent_id, last_used_at, created_at, expires_at, status, key_active
|
||||
from key_candidates
|
||||
order by first_call_complete desc, key_active desc, (last_used_at is not null) desc, created_at desc, id
|
||||
limit 1
|
||||
), first_call as (
|
||||
select l.id, l.tool_name, l.created_at, l.request_id, l.trace_id
|
||||
from selected_agent sa
|
||||
join selected_key sk on sk.agent_id = sa.id
|
||||
join invocation_logs l
|
||||
on l.workspace_id = $1 and l.agent_id = sa.id and l.platform_api_key_id = sk.id
|
||||
and l.operation_id = sa.operation_id and l.operation_version = sa.operation_version
|
||||
where l.source = 'agent_tool_call' and l.status = 'ok'
|
||||
and sk.key_active
|
||||
and l.execution_stage = 'runtime' and l.outcome_certainty = 'certain'
|
||||
and l.execution_error_code is null
|
||||
and l.created_at >= sk.created_at
|
||||
and l.created_at >= sa.agent_published_at
|
||||
and l.created_at >= coalesce((select evidence_after from selection_state), '-infinity'::timestamptz)
|
||||
and ((select invocation_log_id from selection_state) is null
|
||||
or l.id = (select invocation_log_id from selection_state))
|
||||
order by l.created_at, l.id limit 1
|
||||
), event_state as (
|
||||
select count(*)::bigint as event_count, max(occurred_at) as last_event_at, max(id) as last_event_id,
|
||||
coalesce(bool_or(event_name = 'onboarding_completed'), false) as was_completed,
|
||||
min((properties_json ->> 'eligible_since')::timestamptz)
|
||||
filter (where event_name = 'onboarding_eligible') as eligible_since
|
||||
from product_events where workspace_id = $1
|
||||
)
|
||||
select os.id as operation_id, os.operation_active, os.tested, os.published, os.published_version,
|
||||
sa.id as agent_id, sa.agent_active, sa.catalog_revision, sk.id as platform_api_key_id,
|
||||
(sk.key_active and sk.last_used_at is not null) as connected, sk.key_active,
|
||||
fc.id as first_call_log_id, fc.tool_name as first_call_tool_name,
|
||||
fc.created_at as first_call_at, fc.request_id as first_call_request_id,
|
||||
fc.trace_id as first_call_trace_id, es.eligible_since, es.was_completed,
|
||||
(hashtextextended(concat_ws('|',
|
||||
coalesce(os.id, ''), coalesce(os.updated_at::text, ''), coalesce(os.tested::text, ''),
|
||||
coalesce(os.published_version::text, ''), coalesce(sa.id, ''),
|
||||
coalesce(sa.catalog_revision::text, ''), coalesce(sa.updated_at::text, ''),
|
||||
coalesce(sk.id, ''), coalesce(sk.status, ''), coalesce(sk.last_used_at::text, ''),
|
||||
coalesce(sk.expires_at::text, ''), coalesce(sk.key_active::text, ''),
|
||||
coalesce(fc.id, ''), coalesce(es.event_count::text, ''),
|
||||
coalesce(es.last_event_at::text, ''), coalesce(es.last_event_id, ''),
|
||||
coalesce((select evidence_after::text from selection_state), ''),
|
||||
coalesce((select selected_at::text from selection_state), '')
|
||||
), 0) & 9223372036854775807) as revision
|
||||
from event_state es
|
||||
left join operation_state os on true
|
||||
left join selected_agent sa on true
|
||||
left join selected_key sk on true
|
||||
left join first_call fc on true
|
||||
"#;
|
||||
|
||||
impl PostgresRegistry {
|
||||
/// Ensures the workspace enters the server-owned onboarding cohort exactly once.
|
||||
pub async fn ensure_onboarding_eligibility(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
occurred_at: OffsetDateTime,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
|
||||
let eligible = super::product_event::get_product_event_by_idempotency_key_in_transaction(
|
||||
&mut transaction,
|
||||
workspace_id,
|
||||
"onboarding:eligible:v1",
|
||||
)
|
||||
.await?;
|
||||
let eligible_since = if let Some(record) = eligible {
|
||||
record.event.eligible_since.unwrap_or(occurred_at)
|
||||
} else {
|
||||
let event = ProductEvent {
|
||||
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
|
||||
workspace_id: workspace_id.clone(),
|
||||
kind: ProductEventKind::OnboardingEligible,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: true,
|
||||
eligible_since: Some(occurred_at),
|
||||
idempotency_key: "onboarding:eligible:v1".to_owned(),
|
||||
occurred_at,
|
||||
};
|
||||
super::product_event::append_product_event_in_transaction(&mut transaction, &event)
|
||||
.await?;
|
||||
occurred_at
|
||||
};
|
||||
let mut projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
if projection.completed && !projection.was_completed {
|
||||
let event = ProductEvent {
|
||||
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
|
||||
workspace_id: workspace_id.clone(),
|
||||
kind: ProductEventKind::OnboardingCompleted,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: true,
|
||||
eligible_since: Some(eligible_since),
|
||||
idempotency_key: "onboarding:completed:v1".to_owned(),
|
||||
occurred_at: projection.first_call_at.unwrap_or(occurred_at),
|
||||
};
|
||||
super::product_event::append_product_event_in_transaction(&mut transaction, &event)
|
||||
.await?;
|
||||
projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(projection)
|
||||
}
|
||||
|
||||
/// Records the server-owned completion once the authoritative projection is terminal.
|
||||
pub async fn ensure_onboarding_completion(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
_occurred_at: OffsetDateTime,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
|
||||
let projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
if !projection.completed {
|
||||
transaction.commit().await?;
|
||||
return Ok(projection);
|
||||
}
|
||||
let Some(eligible_since) = projection.eligible_since else {
|
||||
transaction.commit().await?;
|
||||
return Ok(projection);
|
||||
};
|
||||
let event = ProductEvent {
|
||||
id: ProductEventId::new(format!("pe_{}", uuid::Uuid::now_v7().simple())),
|
||||
workspace_id: workspace_id.clone(),
|
||||
kind: ProductEventKind::OnboardingCompleted,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: true,
|
||||
eligible_since: Some(eligible_since),
|
||||
idempotency_key: "onboarding:completed:v1".to_owned(),
|
||||
occurred_at: projection
|
||||
.first_call_at
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "onboarding.first_call_at",
|
||||
})?,
|
||||
};
|
||||
super::product_event::append_product_event_in_transaction(&mut transaction, &event).await?;
|
||||
let projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(projection)
|
||||
}
|
||||
|
||||
pub async fn get_onboarding_projection(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let row = sqlx::query(ONBOARDING_PROJECTION_SQL)
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
map_projection(workspace_id, &row)
|
||||
}
|
||||
|
||||
pub async fn reset_onboarding_selection(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
expected_revision: i64,
|
||||
occurred_at: OffsetDateTime,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
|
||||
let current =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
if current.revision != expected_revision {
|
||||
return Err(RegistryError::OnboardingStaleRevision);
|
||||
}
|
||||
sqlx::query(
|
||||
"insert into onboarding_selections (
|
||||
workspace_id, operation_id, operation_version, agent_id, catalog_revision,
|
||||
platform_api_key_id, invocation_log_id, test_log_id, evidence_after, selected_at
|
||||
) values ($1, null, null, null, null, null, null, null, $2, null)
|
||||
on conflict (workspace_id) do update
|
||||
set operation_id = null, operation_version = null, agent_id = null,
|
||||
catalog_revision = null, platform_api_key_id = null,
|
||||
invocation_log_id = null, test_log_id = null,
|
||||
evidence_after = excluded.evidence_after, selected_at = null",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(occurred_at)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
let projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(projection)
|
||||
}
|
||||
|
||||
pub async fn record_onboarding_milestone(
|
||||
&self,
|
||||
request: RecordOnboardingMilestoneRequest<'_>,
|
||||
) -> Result<OnboardingMilestoneResult, RegistryError> {
|
||||
let kind = match request.milestone {
|
||||
OnboardingPresentationMilestone::Eligible => ProductEventKind::OnboardingEligible,
|
||||
OnboardingPresentationMilestone::Started => ProductEventKind::OnboardingStarted,
|
||||
OnboardingPresentationMilestone::Resumed => ProductEventKind::OnboardingResumed,
|
||||
OnboardingPresentationMilestone::Dismissed => ProductEventKind::OnboardingDismissed,
|
||||
OnboardingPresentationMilestone::Abandoned => ProductEventKind::OnboardingAbandoned,
|
||||
};
|
||||
self.record_onboarding_event(
|
||||
request.workspace_id,
|
||||
request.event_id,
|
||||
kind,
|
||||
request.idempotency_key,
|
||||
request.expected_revision,
|
||||
request.occurred_at,
|
||||
request.eligible_since,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_onboarding_completion(
|
||||
&self,
|
||||
request: RecordOnboardingCompletionRequest<'_>,
|
||||
) -> Result<OnboardingMilestoneResult, RegistryError> {
|
||||
self.record_onboarding_event(
|
||||
request.workspace_id,
|
||||
request.event_id,
|
||||
ProductEventKind::OnboardingCompleted,
|
||||
request.idempotency_key,
|
||||
request.expected_revision,
|
||||
request.occurred_at,
|
||||
Some(request.eligible_since),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn record_onboarding_event(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
event_id: &ProductEventId,
|
||||
kind: ProductEventKind,
|
||||
idempotency_key: &str,
|
||||
expected_revision: i64,
|
||||
occurred_at: OffsetDateTime,
|
||||
eligible_since: Option<OffsetDateTime>,
|
||||
) -> Result<OnboardingMilestoneResult, RegistryError> {
|
||||
if idempotency_key.starts_with(PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX) {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.reserved_idempotency_key",
|
||||
});
|
||||
}
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
lock_onboarding_workspace(&mut transaction, workspace_id).await?;
|
||||
let current =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
if kind == ProductEventKind::OnboardingCompleted && !current.completed {
|
||||
return Err(RegistryError::OnboardingIncomplete);
|
||||
}
|
||||
let event = ProductEvent {
|
||||
id: event_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
kind,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: eligible_since.is_some(),
|
||||
eligible_since,
|
||||
idempotency_key: idempotency_key.to_owned(),
|
||||
occurred_at,
|
||||
};
|
||||
if current.revision != expected_revision {
|
||||
let replay = super::product_event::get_product_event_by_idempotency_key_in_transaction(
|
||||
&mut transaction,
|
||||
workspace_id,
|
||||
idempotency_key,
|
||||
)
|
||||
.await?;
|
||||
if replay.is_some_and(|record| event.is_semantic_replay_of(&record.event)) {
|
||||
transaction.commit().await?;
|
||||
return Ok(OnboardingMilestoneResult {
|
||||
accepted: false,
|
||||
projection: current,
|
||||
});
|
||||
}
|
||||
return Err(RegistryError::OnboardingStaleRevision);
|
||||
}
|
||||
let outcome =
|
||||
super::product_event::append_product_event_in_transaction(&mut transaction, &event)
|
||||
.await?;
|
||||
let projection =
|
||||
get_onboarding_projection_in_transaction(&mut transaction, workspace_id).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(OnboardingMilestoneResult {
|
||||
accepted: outcome == AppendProductEventOutcome::Recorded,
|
||||
projection,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn lock_onboarding_workspace(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<(), RegistryError> {
|
||||
let present = sqlx::query("select id from workspaces where id = $1 for update")
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?
|
||||
.is_some();
|
||||
if !present {
|
||||
return Err(RegistryError::WorkspaceNotFound {
|
||||
workspace_id: workspace_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn get_onboarding_projection_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let row = sqlx::query(ONBOARDING_PROJECTION_SQL)
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_one(&mut **transaction)
|
||||
.await?;
|
||||
map_projection(workspace_id, &row)
|
||||
}
|
||||
|
||||
fn map_projection(
|
||||
workspace_id: &WorkspaceId,
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> Result<OnboardingProjection, RegistryError> {
|
||||
let operation_id = row
|
||||
.try_get::<Option<String>, _>("operation_id")?
|
||||
.map(OperationId::new);
|
||||
let operation_active = row
|
||||
.try_get::<Option<bool>, _>("operation_active")?
|
||||
.unwrap_or(false);
|
||||
let tested = row.try_get::<Option<bool>, _>("tested")?.unwrap_or(false);
|
||||
let published = row
|
||||
.try_get::<Option<bool>, _>("published")?
|
||||
.unwrap_or(false);
|
||||
let agent_id = row
|
||||
.try_get::<Option<String>, _>("agent_id")?
|
||||
.map(AgentId::new);
|
||||
let agent_active = row
|
||||
.try_get::<Option<bool>, _>("agent_active")?
|
||||
.unwrap_or(false);
|
||||
let key_id = row
|
||||
.try_get::<Option<String>, _>("platform_api_key_id")?
|
||||
.map(PlatformApiKeyId::new);
|
||||
let key_active = row
|
||||
.try_get::<Option<bool>, _>("key_active")?
|
||||
.unwrap_or(false);
|
||||
let connected = row
|
||||
.try_get::<Option<bool>, _>("connected")?
|
||||
.unwrap_or(false);
|
||||
let first_call_log_id = row
|
||||
.try_get::<Option<String>, _>("first_call_log_id")?
|
||||
.map(InvocationLogId::new);
|
||||
let flags = [
|
||||
operation_id.is_some() && operation_active,
|
||||
tested,
|
||||
published,
|
||||
agent_id.is_some() && agent_active,
|
||||
key_id.is_some() && key_active,
|
||||
connected,
|
||||
first_call_log_id.is_some(),
|
||||
];
|
||||
Ok(OnboardingProjection {
|
||||
workspace_id: workspace_id.clone(),
|
||||
revision: row.try_get("revision")?,
|
||||
completed: flags.into_iter().all(|flag| flag),
|
||||
was_completed: row.try_get("was_completed")?,
|
||||
steps: OnboardingStepId::ORDERED
|
||||
.into_iter()
|
||||
.zip(flags)
|
||||
.map(|(id, completed)| OnboardingStep { id, completed })
|
||||
.collect(),
|
||||
operation_id,
|
||||
operation_version: row
|
||||
.try_get::<Option<i32>, _>("published_version")?
|
||||
.map(|value| {
|
||||
u32::try_from(value).map_err(|_| RegistryError::InvalidNumericValue {
|
||||
field: "onboarding.operation_version",
|
||||
value: i64::from(value),
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
agent_id,
|
||||
catalog_revision: row.try_get("catalog_revision")?,
|
||||
platform_api_key_id: key_id,
|
||||
first_call_log_id,
|
||||
first_call_tool_name: row.try_get("first_call_tool_name")?,
|
||||
first_call_at: row.try_get("first_call_at")?,
|
||||
first_call_request_id: row.try_get("first_call_request_id")?,
|
||||
first_call_trace_id: row.try_get("first_call_trace_id")?,
|
||||
eligible_since: row.try_get("eligible_since")?,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn get_published_operation(
|
||||
&self,
|
||||
operation_id: &OperationId,
|
||||
) -> Result<Option<RegistryOperation>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
"select
|
||||
o.id,
|
||||
o.workspace_id,
|
||||
ov.name,
|
||||
ov.display_name,
|
||||
ov.category,
|
||||
ov.protocol,
|
||||
ov.security_level,
|
||||
ov.created_at as \"operation_created_at!: time::OffsetDateTime\",
|
||||
ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",
|
||||
ov.published_at as \"operation_published_at: time::OffsetDateTime\",
|
||||
ov.version,
|
||||
ov.status,
|
||||
ov.target_json,
|
||||
ov.input_schema_json,
|
||||
ov.output_schema_json,
|
||||
ov.input_mapping_json,
|
||||
ov.output_mapping_json,
|
||||
ov.execution_config_json,
|
||||
ov.tool_description_json,
|
||||
ov.samples_json,
|
||||
ov.generated_draft_json,
|
||||
ov.config_export_json,
|
||||
ov.wizard_state_json,
|
||||
ov.change_note,
|
||||
ov.created_at as \"created_at!: time::OffsetDateTime\",
|
||||
ov.created_by
|
||||
from published_operations po
|
||||
join operation_versions ov
|
||||
on ov.operation_id = po.operation_id and ov.version = po.version
|
||||
join operations o on o.id = po.operation_id
|
||||
where po.operation_id = $1",
|
||||
operation_id.as_str(),
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
build_operation_version_record(
|
||||
row.id,
|
||||
row.workspace_id,
|
||||
row.name,
|
||||
row.display_name,
|
||||
row.category,
|
||||
row.protocol,
|
||||
row.security_level,
|
||||
row.operation_created_at,
|
||||
row.operation_updated_at,
|
||||
row.operation_published_at,
|
||||
row.version,
|
||||
row.status,
|
||||
row.target_json,
|
||||
row.input_schema_json,
|
||||
row.output_schema_json,
|
||||
row.input_mapping_json,
|
||||
row.output_mapping_json,
|
||||
row.execution_config_json,
|
||||
row.tool_description_json,
|
||||
row.samples_json,
|
||||
row.generated_draft_json,
|
||||
row.config_export_json,
|
||||
row.wizard_state_json,
|
||||
row.change_note,
|
||||
row.created_at,
|
||||
row.created_by,
|
||||
)
|
||||
.map(|record| record.snapshot)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn list_published_operations(&self) -> Result<Vec<RegistryOperation>, RegistryError> {
|
||||
let rows = sqlx::query!(
|
||||
"select
|
||||
o.id,
|
||||
o.workspace_id,
|
||||
ov.name,
|
||||
ov.display_name,
|
||||
ov.category,
|
||||
ov.protocol,
|
||||
ov.security_level,
|
||||
ov.created_at as \"operation_created_at!: time::OffsetDateTime\",
|
||||
ov.created_at as \"operation_updated_at!: time::OffsetDateTime\",
|
||||
ov.published_at as \"operation_published_at: time::OffsetDateTime\",
|
||||
ov.version,
|
||||
ov.status,
|
||||
ov.target_json,
|
||||
ov.input_schema_json,
|
||||
ov.output_schema_json,
|
||||
ov.input_mapping_json,
|
||||
ov.output_mapping_json,
|
||||
ov.execution_config_json,
|
||||
ov.tool_description_json,
|
||||
ov.samples_json,
|
||||
ov.generated_draft_json,
|
||||
ov.config_export_json,
|
||||
ov.wizard_state_json,
|
||||
ov.change_note,
|
||||
ov.created_at as \"created_at!: time::OffsetDateTime\",
|
||||
ov.created_by
|
||||
from published_operations po
|
||||
join operation_versions ov
|
||||
on ov.operation_id = po.operation_id and ov.version = po.version
|
||||
join operations o on o.id = po.operation_id
|
||||
order by o.name asc",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
build_operation_version_record(
|
||||
row.id,
|
||||
row.workspace_id,
|
||||
row.name,
|
||||
row.display_name,
|
||||
row.category,
|
||||
row.protocol,
|
||||
row.security_level,
|
||||
row.operation_created_at,
|
||||
row.operation_updated_at,
|
||||
row.operation_published_at,
|
||||
row.version,
|
||||
row.status,
|
||||
row.target_json,
|
||||
row.input_schema_json,
|
||||
row.output_schema_json,
|
||||
row.input_mapping_json,
|
||||
row.output_mapping_json,
|
||||
row.execution_config_json,
|
||||
row.tool_description_json,
|
||||
row.samples_json,
|
||||
row.generated_draft_json,
|
||||
row.config_export_json,
|
||||
row.wizard_state_json,
|
||||
row.change_note,
|
||||
row.created_at,
|
||||
row.created_by,
|
||||
)
|
||||
.map(|record| record.snapshot)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use crank_core::{
|
||||
OnboardingMilestone, PRODUCT_EVENT_SCHEMA_VERSION, ProductEvent, ProductEventKind,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::{Postgres, Row, Transaction, types::Json};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn append_product_event(
|
||||
&self,
|
||||
request: AppendProductEventRequest<'_>,
|
||||
) -> Result<AppendProductEventOutcome, RegistryError> {
|
||||
if request
|
||||
.event
|
||||
.idempotency_key
|
||||
.starts_with(crank_core::product_event::PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX)
|
||||
{
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.reserved_idempotency_key",
|
||||
});
|
||||
}
|
||||
if !request.event.is_valid() {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event",
|
||||
});
|
||||
}
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let outcome = append_product_event_in_transaction(&mut transaction, request.event).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub async fn get_product_event_by_idempotency_key(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
idempotency_key: &str,
|
||||
) -> Result<Option<ProductEventRecord>, RegistryError> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let record = get_product_event_by_idempotency_key_in_transaction(
|
||||
&mut transaction,
|
||||
workspace_id,
|
||||
idempotency_key,
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn list_product_events(
|
||||
&self,
|
||||
query: ListProductEventsQuery<'_>,
|
||||
) -> Result<Vec<ProductEventRecord>, RegistryError> {
|
||||
let rows = sqlx::query(
|
||||
"select id, workspace_id, event_name, schema_version, occurred_at,
|
||||
idempotency_key, properties_json
|
||||
from product_events
|
||||
where workspace_id = $1
|
||||
and ($2::text is null or event_name = $2)
|
||||
and occurred_at >= $3 and occurred_at < $4
|
||||
order by occurred_at desc, id desc
|
||||
limit $5",
|
||||
)
|
||||
.bind(query.workspace_id.as_str())
|
||||
.bind(query.kind.map(ProductEventKind::as_str))
|
||||
.bind(query.created_after)
|
||||
.bind(query.created_before)
|
||||
.bind(i64::from(query.limit.min(1_000)))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(map_product_event).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn get_product_event_by_idempotency_key_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
idempotency_key: &str,
|
||||
) -> Result<Option<ProductEventRecord>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select id, workspace_id, event_name, schema_version, occurred_at,
|
||||
idempotency_key, properties_json
|
||||
from product_events
|
||||
where workspace_id = $1 and idempotency_key = $2",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(idempotency_key)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await?;
|
||||
row.map(map_product_event).transpose()
|
||||
}
|
||||
|
||||
pub(super) async fn append_product_event_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
event: &ProductEvent,
|
||||
) -> Result<AppendProductEventOutcome, RegistryError> {
|
||||
if !event.is_valid() {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event",
|
||||
});
|
||||
}
|
||||
let eligible_since = event
|
||||
.eligible_since
|
||||
.map(|value| value.format(&Rfc3339))
|
||||
.transpose()
|
||||
.map_err(|_| RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.eligible_since",
|
||||
})?;
|
||||
let properties = json!({
|
||||
"eligible": event.eligible,
|
||||
"eligible_since": eligible_since,
|
||||
"milestone": event.milestone,
|
||||
});
|
||||
let inserted = sqlx::query(
|
||||
"insert into product_events (
|
||||
id, workspace_id, event_name, schema_version, occurred_at,
|
||||
idempotency_key, properties_json
|
||||
) values ($1, $2, $3, $4, $5, $6, $7)
|
||||
on conflict (workspace_id, idempotency_key) do nothing",
|
||||
)
|
||||
.bind(event.id.as_str())
|
||||
.bind(event.workspace_id.as_str())
|
||||
.bind(event.kind.as_str())
|
||||
.bind(i32::from(event.schema_version))
|
||||
.bind(event.occurred_at)
|
||||
.bind(&event.idempotency_key)
|
||||
.bind(Json(properties))
|
||||
.execute(&mut **transaction)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if inserted == 0 {
|
||||
let recorded = sqlx::query(
|
||||
"select id, workspace_id, event_name, schema_version, occurred_at,
|
||||
idempotency_key, properties_json
|
||||
from product_events
|
||||
where workspace_id = $1 and idempotency_key = $2",
|
||||
)
|
||||
.bind(event.workspace_id.as_str())
|
||||
.bind(&event.idempotency_key)
|
||||
.fetch_one(&mut **transaction)
|
||||
.await?;
|
||||
let recorded = map_product_event(recorded)?;
|
||||
if event.is_semantic_replay_of(&recorded.event) {
|
||||
return Ok(AppendProductEventOutcome::Duplicate);
|
||||
}
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.idempotency_conflict",
|
||||
});
|
||||
}
|
||||
sqlx::query(
|
||||
"insert into product_event_daily_rollups (
|
||||
workspace_id, event_name, day, events_total, eligible_total
|
||||
) values ($1, $2, $3, 1, $4)
|
||||
on conflict (workspace_id, event_name, day) do update
|
||||
set events_total = product_event_daily_rollups.events_total + 1,
|
||||
eligible_total = product_event_daily_rollups.eligible_total + excluded.eligible_total",
|
||||
)
|
||||
.bind(event.workspace_id.as_str())
|
||||
.bind(event.kind.as_str())
|
||||
.bind(event.occurred_on_utc())
|
||||
.bind(i64::from(
|
||||
event.kind == ProductEventKind::OnboardingEligible,
|
||||
))
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(AppendProductEventOutcome::Recorded)
|
||||
}
|
||||
|
||||
fn map_product_event(row: sqlx::postgres::PgRow) -> Result<ProductEventRecord, RegistryError> {
|
||||
let event_name = row.try_get::<String, _>("event_name")?;
|
||||
let kind = ProductEventKind::ALL
|
||||
.into_iter()
|
||||
.find(|kind| kind.as_str() == event_name)
|
||||
.ok_or(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.event_name",
|
||||
})?;
|
||||
let properties = row.try_get::<serde_json::Value, _>("properties_json")?;
|
||||
let milestone = properties
|
||||
.get("milestone")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(|value| serde_json::from_value::<OnboardingMilestone>(value.clone()))
|
||||
.transpose()
|
||||
.map_err(|_| RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.milestone",
|
||||
})?;
|
||||
let eligible_since = properties
|
||||
.get("eligible_since")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.ok_or(())
|
||||
.and_then(|value| OffsetDateTime::parse(value, &Rfc3339).map_err(|_| ()))
|
||||
})
|
||||
.transpose()
|
||||
.map_err(|_| RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.eligible_since",
|
||||
})?;
|
||||
let schema_version = row.try_get::<i32, _>("schema_version")?;
|
||||
let schema_version =
|
||||
u16::try_from(schema_version).map_err(|_| RegistryError::InvalidNumericValue {
|
||||
field: "product_event.schema_version",
|
||||
value: i64::from(schema_version),
|
||||
})?;
|
||||
if schema_version != PRODUCT_EVENT_SCHEMA_VERSION {
|
||||
return Err(RegistryError::InvalidExecutionRecord {
|
||||
field: "product_event.schema_version",
|
||||
});
|
||||
}
|
||||
Ok(ProductEventRecord {
|
||||
event: ProductEvent {
|
||||
id: ProductEventId::new(row.try_get::<String, _>("id")?),
|
||||
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
||||
kind,
|
||||
schema_version,
|
||||
milestone,
|
||||
eligible: properties
|
||||
.get("eligible")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
eligible_since,
|
||||
idempotency_key: row.try_get("idempotency_key")?,
|
||||
occurred_at: row.try_get("occurred_at")?,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::master_key::{ensure_active_master_key_epoch, ensure_no_active_master_key_rotation};
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
@@ -90,34 +91,42 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<Option<SecretVersionRecord>, RegistryError> {
|
||||
let row = sqlx::query!(
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
sv.secret_id,
|
||||
sv.version,
|
||||
sv.ciphertext,
|
||||
sv.key_version,
|
||||
sv.created_at as \"created_at!: time::OffsetDateTime\",
|
||||
sv.master_key_epoch,
|
||||
sv.target_ciphertext,
|
||||
sv.target_key_version,
|
||||
sv.target_master_key_epoch,
|
||||
sv.created_at,
|
||||
sv.created_by
|
||||
from secrets s
|
||||
join secret_versions sv
|
||||
on sv.secret_id = s.id and sv.version = s.current_version
|
||||
where s.workspace_id = $1 and s.id = $2",
|
||||
workspace_id.as_str(),
|
||||
secret_id.as_str(),
|
||||
where s.workspace_id = $1 and s.id = $2 and s.status = 'active'",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| {
|
||||
Ok(SecretVersionRecord {
|
||||
secret_version: SecretVersion {
|
||||
secret_id: SecretId::new(row.secret_id),
|
||||
version: from_db_version(row.version, "version")?,
|
||||
ciphertext: row.ciphertext,
|
||||
key_version: row.key_version,
|
||||
created_at: row.created_at,
|
||||
created_by: row.created_by.map(UserId::new),
|
||||
secret_id: SecretId::new(row.get::<String, _>("secret_id")),
|
||||
version: from_db_version(row.get::<i32, _>("version"), "version")?,
|
||||
ciphertext: row.get::<String, _>("ciphertext"),
|
||||
key_version: row.get::<String, _>("key_version"),
|
||||
created_at: row.get("created_at"),
|
||||
created_by: row.get::<Option<String>, _>("created_by").map(UserId::new),
|
||||
},
|
||||
master_key_epoch: row.get::<i64, _>("master_key_epoch"),
|
||||
target_ciphertext: row.get::<Option<String>, _>("target_ciphertext"),
|
||||
target_key_version: row.get::<Option<String>, _>("target_key_version"),
|
||||
target_master_key_epoch: row.get::<Option<i64>, _>("target_master_key_epoch"),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
@@ -128,6 +137,8 @@ impl PostgresRegistry {
|
||||
request: CreateSecretRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
ensure_no_active_master_key_rotation(&mut tx).await?;
|
||||
ensure_active_master_key_epoch(&mut tx, request.master_key_epoch).await?;
|
||||
let result = sqlx::query(
|
||||
"insert into secrets (
|
||||
id,
|
||||
@@ -163,16 +174,18 @@ impl PostgresRegistry {
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
) values (
|
||||
$1, $2, $3, $4, $5::timestamptz, $6
|
||||
$1, $2, $3, $4, $5, $6::timestamptz, $7
|
||||
)",
|
||||
)
|
||||
.bind(request.secret.id.as_str())
|
||||
.bind(to_db_version(request.secret.current_version))
|
||||
.bind(request.ciphertext)
|
||||
.bind(request.key_version)
|
||||
.bind(request.master_key_epoch)
|
||||
.bind(request.secret.created_at)
|
||||
.bind(request.created_by.map(|value| value.as_str()))
|
||||
.execute(&mut *tx)
|
||||
@@ -196,31 +209,56 @@ impl PostgresRegistry {
|
||||
&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?;
|
||||
ensure_no_active_master_key_rotation(&mut tx).await?;
|
||||
ensure_active_master_key_epoch(&mut tx, request.master_key_epoch).await?;
|
||||
let row = sqlx::query(
|
||||
"select current_version, status
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(request.secret_id.as_str())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(RegistryError::SecretNotFound {
|
||||
secret_id: request.secret_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
let status: String = row.get("status");
|
||||
if status != "active" {
|
||||
return Err(RegistryError::SecretInactive {
|
||||
secret_id: request.secret_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
let current_version =
|
||||
from_db_version(row.get::<i32, _>("current_version"), "current_version")?;
|
||||
let next_version = current_version.checked_add(1).ok_or_else(|| {
|
||||
RegistryError::SecretConcurrentUpdate {
|
||||
secret_id: request.secret_id.as_str().to_owned(),
|
||||
}
|
||||
})?;
|
||||
|
||||
sqlx::query(
|
||||
"insert into secret_versions (
|
||||
secret_id,
|
||||
version,
|
||||
ciphertext,
|
||||
key_version,
|
||||
master_key_epoch,
|
||||
created_at,
|
||||
created_by
|
||||
) values (
|
||||
$1, $2, $3, $4, $5::timestamptz, $6
|
||||
$1, $2, $3, $4, $5, $6::timestamptz, $7
|
||||
)",
|
||||
)
|
||||
.bind(request.secret_id.as_str())
|
||||
.bind(to_db_version(next_version))
|
||||
.bind(request.ciphertext)
|
||||
.bind(request.key_version)
|
||||
.bind(request.master_key_epoch)
|
||||
.bind(request.created_at)
|
||||
.bind(request.created_by.map(|value| value.as_str()))
|
||||
.execute(&mut *tx)
|
||||
@@ -230,14 +268,31 @@ impl PostgresRegistry {
|
||||
"update secrets
|
||||
set current_version = $3,
|
||||
updated_at = $4::timestamptz
|
||||
where workspace_id = $1 and id = $2",
|
||||
where workspace_id = $1
|
||||
and id = $2
|
||||
and status = 'active'
|
||||
and current_version = $5",
|
||||
)
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(request.secret_id.as_str())
|
||||
.bind(to_db_version(next_version))
|
||||
.bind(request.updated_at)
|
||||
.bind(to_db_version(current_version))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
.await
|
||||
.and_then(|result| {
|
||||
if result.rows_affected() == 1 {
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(sqlx::Error::RowNotFound)
|
||||
}
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
sqlx::Error::RowNotFound => RegistryError::SecretConcurrentUpdate {
|
||||
secret_id: request.secret_id.as_str().to_owned(),
|
||||
},
|
||||
other => RegistryError::Storage(other),
|
||||
})?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -250,6 +305,10 @@ impl PostgresRegistry {
|
||||
created_at: *request.created_at,
|
||||
created_by: request.created_by.cloned(),
|
||||
},
|
||||
master_key_epoch: request.master_key_epoch,
|
||||
target_ciphertext: None,
|
||||
target_key_version: None,
|
||||
target_master_key_epoch: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -258,13 +317,24 @@ impl PostgresRegistry {
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<(), RegistryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
ensure_no_active_master_key_rotation(&mut tx).await?;
|
||||
lock_secret_reference(&mut tx, workspace_id, secret_id).await?;
|
||||
if let Some(auth_profile_id) =
|
||||
first_auth_profile_ref_in_tx(&mut tx, workspace_id, secret_id).await?
|
||||
{
|
||||
return Err(RegistryError::SecretReferencedByAuthProfile {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
auth_profile_id,
|
||||
});
|
||||
}
|
||||
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)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
@@ -273,6 +343,7 @@ impl PostgresRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -282,23 +353,30 @@ impl PostgresRegistry {
|
||||
secret_id: &SecretId,
|
||||
used_at: &OffsetDateTime,
|
||||
) -> Result<(), RegistryError> {
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"with target as (
|
||||
select id
|
||||
let row = sqlx::query(
|
||||
"with target as materialized (
|
||||
select id,
|
||||
last_used_at,
|
||||
status = 'active' as active
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2
|
||||
for update
|
||||
), updated as (
|
||||
update secrets
|
||||
update secrets as secret
|
||||
set last_used_at = $3::timestamptz
|
||||
where workspace_id = $1
|
||||
and id = $2
|
||||
from target
|
||||
where secret.workspace_id = $1
|
||||
and secret.id = target.id
|
||||
and target.active
|
||||
and (
|
||||
last_used_at is null
|
||||
or last_used_at < $3::timestamptz - interval '1 minute'
|
||||
target.last_used_at is null
|
||||
or target.last_used_at < $3::timestamptz - interval '1 minute'
|
||||
)
|
||||
returning id
|
||||
returning secret.id
|
||||
)
|
||||
select exists(select 1 from target)",
|
||||
select
|
||||
exists(select 1 from target) as exists,
|
||||
coalesce((select active from target), false) as active",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
@@ -306,12 +384,74 @@ impl PostgresRegistry {
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if !exists {
|
||||
if !row.get::<bool, _>("exists") {
|
||||
return Err(RegistryError::SecretNotFound {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !row.get::<bool, _>("active") {
|
||||
return Err(RegistryError::SecretInactive {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn lock_secret_reference(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"select id
|
||||
from secrets
|
||||
where workspace_id = $1 and id = $2
|
||||
for update",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.bind(secret_id.as_str())
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn first_auth_profile_ref_in_tx(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
workspace_id: &WorkspaceId,
|
||||
secret_id: &SecretId,
|
||||
) -> Result<Option<String>, RegistryError> {
|
||||
let rows = sqlx::query(
|
||||
"select id, name, kind, config_json, created_at, updated_at
|
||||
from auth_profiles
|
||||
where workspace_id = $1
|
||||
order by id asc
|
||||
for update",
|
||||
)
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_all(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
for row in rows {
|
||||
let profile = super::build_auth_profile(
|
||||
row.get("id"),
|
||||
workspace_id.as_str().to_owned(),
|
||||
row.get("name"),
|
||||
row.get("kind"),
|
||||
row.get("config_json"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
)?;
|
||||
if profile
|
||||
.config
|
||||
.secret_ids()
|
||||
.into_iter()
|
||||
.any(|candidate| candidate == secret_id)
|
||||
{
|
||||
return Ok(Some(profile.id.as_str().to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user