feat(registry): add workspace-scoped artifact metadata

This commit is contained in:
2026-08-27 02:18:09 +03:00
parent 497e1b740f
commit 2eb185b14a
29 changed files with 2264 additions and 181 deletions
+10
View File
@@ -148,4 +148,14 @@ pub enum RegistryError {
OnboardingStaleRevision,
#[error("onboarding domain steps are not complete")]
OnboardingIncomplete,
#[error("artifact source {source_id} was not found")]
SourceNotFound { source_id: String },
#[error("artifact source {source_id} conflicts with immutable metadata or lifecycle")]
SourceConflict { source_id: String },
#[error("artifact source is unavailable")]
SourceUnavailable,
#[error("artifact source integrity verification failed")]
SourceIntegrity,
#[error("artifact source metadata is invalid for field {field}")]
InvalidArtifactSource { field: &'static str },
}
+41 -35
View File
@@ -14,21 +14,23 @@ pub use migrations::{
pub mod records {
pub use crate::model::{
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,
AppliedImportOperation, ApprovalRequestRecord, ArtifactBlobLifecycle, ArtifactBlobRecord,
ArtifactDigest, ArtifactSourceCursor, ArtifactSourceId, ArtifactSourceLifecycle,
ArtifactSourcePage, ArtifactSourceRecord, ArtifactSourceSensitivity, 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, VerifiedArtifactSource,
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
}
@@ -37,11 +39,12 @@ pub mod requests {
AdminSecurityAuditRequest, AppendProductEventRequest, ApplyImportJobRequest,
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
ExpireApprovalRequest, FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode,
ImportOperationDraft, ListApprovalRequestsQuery, ListInvocationLogsQuery,
CreateArtifactSourceRequest, CreateImportJobRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
DecideApprovalRequest, DetachArtifactSourceRequest, ExpireApprovalRequest,
FinishApprovalRequest, FinishImportJobRequest, ImportConflictMode, ImportOperationDraft,
ListApprovalRequestsQuery, ListArtifactSourcesQuery, ListInvocationLogsQuery,
ListProductEventsQuery, MasterKeyIdentityCandidate, PublishAgentRequest, PublishRequest,
RecordOnboardingCompletionRequest, RecordOnboardingMilestoneRequest,
RecoverAdminPasswordRequest, RotateSecretRequest, SaveAgentBindingsRequest,
@@ -63,32 +66,35 @@ pub mod infrastructure {
pub use model::{
AdminBootstrapContractRecord, AdminSecurityAuditRequest, AgentStateExpectation, AgentSummary,
AgentVersionRecord, AppendProductEventOutcome, AppendProductEventRequest,
AppliedImportOperation, ApplyImportJobRequest, ApprovalRequestRecord, AuthUserRecord,
ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
AppliedImportOperation, ApplyImportJobRequest, ApprovalRequestRecord, ArtifactBlobLifecycle,
ArtifactBlobRecord, ArtifactDigest, ArtifactSourceCursor, ArtifactSourceId,
ArtifactSourceLifecycle, ArtifactSourcePage, ArtifactSourceRecord, ArtifactSourceSensitivity,
AuthUserRecord, ConsumeAdminBootstrapContractRequest, CreateAdminBootstrapContractRequest,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
CreateArtifactSourceRequest, CreateImportJobRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
DecideApprovalRequest, DescriptorKind, DescriptorMetadata, DetachArtifactSourceRequest,
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,
ListArtifactSourcesQuery, ListInvocationLogsQuery, ListProductEventsQuery,
MASTER_KEY_CIPHER_CONTRACT, MAX_ARTIFACT_SOURCE_PAGE_SIZE, 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,
UsageRollupRecord, UsageSummary, UsageTimelinePoint, VerifiedArtifactSource,
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
+2
View File
@@ -1,6 +1,7 @@
mod admin_auth_lifecycle_v8;
mod agent_catalog_lifecycle_v9;
mod approval_side_effects_v10;
mod artifact_metadata_v12;
mod authority;
mod baseline_v1;
mod execution_outcome_v5;
@@ -11,6 +12,7 @@ mod platform_key_name_reuse_v6;
mod schema_guard;
mod schema_guard_v10;
mod schema_guard_v11;
mod schema_guard_v12;
mod schema_guard_v7;
mod schema_guard_v8;
mod schema_guard_v9;
@@ -0,0 +1,44 @@
use sqlx::{Postgres, Transaction, query};
use super::authority::{MigrationDescriptor, MigrationError};
pub(super) const SOURCE: &str = include_str!("artifact_metadata_v12.sql");
pub(super) const SOURCE_SHA256: &str =
"052058da53cd861b2a1af81243b34cc35204d665a9276f8ece563e061f8ebbfe";
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.artifact_metadata",
Some(12),
"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(12),
"restore_known_good_backup",
)
})?;
Ok(())
}
@@ -0,0 +1,61 @@
create table artifact_blobs (
digest text primary key,
artifact_ref text not null unique,
size_bytes bigint not null,
storage_lifecycle text not null default 'available',
claim_token text null,
claim_expires_at timestamptz null,
created_at timestamptz not null,
updated_at timestamptz not null,
constraint artifact_blobs_digest_check check (digest ~ '^[0-9a-f]{64}$'),
constraint artifact_blobs_ref_check check (artifact_ref = 'sha256:' || digest),
constraint artifact_blobs_size_check check (size_bytes between 1 and 262144),
constraint artifact_blobs_lifecycle_check check (
storage_lifecycle in ('available', 'unavailable')
),
constraint artifact_blobs_claim_token_check check (
claim_token is null or octet_length(claim_token) between 1 and 128
),
constraint artifact_blobs_claim_shape_check check (
(claim_token is null and claim_expires_at is null)
or (claim_token is not null and claim_expires_at is not null)
),
constraint artifact_blobs_timestamps_check check (updated_at >= created_at)
);
create table artifact_sources (
workspace_id text not null references workspaces(id) on delete cascade,
source_id text not null,
blob_digest text not null references artifact_blobs(digest),
mime_type text not null,
sensitivity text not null,
lifecycle text not null default 'active',
created_at timestamptz not null,
updated_at timestamptz not null,
detached_at timestamptz null,
constraint artifact_sources_id_check check (
source_id ~ '^src_[A-Za-z0-9_-]{1,128}$'
),
constraint artifact_sources_mime_type_check check (
octet_length(mime_type) between 3 and 255
and mime_type ~ '^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$'
),
constraint artifact_sources_sensitivity_check check (
sensitivity in ('public', 'internal', 'secret')
),
constraint artifact_sources_lifecycle_check check (
lifecycle in ('active', 'detached')
),
constraint artifact_sources_state_check check (
(lifecycle = 'active' and detached_at is null)
or (lifecycle = 'detached' and detached_at is not null)
),
constraint artifact_sources_timestamps_check check (
updated_at >= created_at
and (detached_at is null or (detached_at >= created_at and updated_at >= detached_at))
),
primary key (workspace_id, source_id)
);
create index artifact_sources_workspace_created_idx
on artifact_sources(workspace_id, created_at, source_id);
+24 -131
View File
@@ -1,6 +1,7 @@
use super::admin_auth_lifecycle_v8;
use super::agent_catalog_lifecycle_v9;
use super::approval_side_effects_v10;
use super::artifact_metadata_v12;
use super::execution_outcome_v5;
use super::master_key_identity_v7;
use super::onboarding_product_events_v11;
@@ -11,12 +12,17 @@ use super::schema_guard::{
};
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;
mod contract;
#[cfg(test)]
use contract::baseline_source_digest;
use contract::validate_descriptors;
const MIGRATION_LOCK_ID: i64 = 0x4352_414E_4B4D_4947;
const CURRENT_VERSION: i64 = 11;
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const CURRENT_VERSION: i64 = 12;
const IMPLEMENTED_VERSIONS: &[i64] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
const BASELINE_SOURCE_SHA256: &str =
"eb1656fc5b4b5be9ee390d237d1d58e4b2274ae5ba9b7ba06a2f3f860dfda675";
const CONSOLIDATION_SOURCE: &str = include_str!("consolidation_v2.sql");
@@ -242,6 +248,12 @@ impl MigrationAuthority {
onboarding_product_events_v11::SOURCE_SHA256,
10,
),
expand_descriptor(
12,
"artifact-metadata-v12",
artifact_metadata_v12::SOURCE_SHA256,
11,
),
]
}
pub fn validate_sequence() -> Result<(), MigrationError> {
@@ -364,6 +376,9 @@ impl MigrationAuthority {
if from < 11 {
onboarding_product_events_v11::apply(&mut transaction, &Self::sequence()[10]).await?;
}
if from < 12 {
artifact_metadata_v12::apply(&mut transaction, &Self::sequence()[11]).await?;
}
transaction
.commit()
.await
@@ -374,134 +389,6 @@ 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");
let (_, baseline) = source
.split_once("// baseline-v1:start\n")
.expect("baseline start marker must exist");
let (baseline, _) = baseline
.split_once("// baseline-v1:end")
.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(
"invalid_contract",
"contract.sequence",
None,
"contact_operator",
));
}
for (index, descriptor) in descriptors.iter().enumerate() {
let expected_version = i64::try_from(index + 1).unwrap_or(i64::MAX);
let valid_name = !descriptor.name.is_empty()
&& descriptor.name.len() <= 128
&& descriptor
.name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
&& !descriptors[..index]
.iter()
.any(|prior| prior.name == descriptor.name);
let valid_source_digest = descriptor.source_digest.len() == 64
&& descriptor
.source_digest
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
let valid_checksum = if descriptor.version == 1 {
descriptor.checksum == BASELINE_CHECKSUM
} else {
descriptor.checksum == descriptor.source_digest
};
let valid_window = descriptor.readable_schema_min >= 1
&& descriptor.readable_schema_min <= descriptor.readable_schema_max
&& descriptor.readable_schema_max <= descriptor.version;
let valid_phase = match descriptor.phase {
"expand" => descriptor.backfill == BackfillPolicy::None,
"migrate" => matches!(
descriptor.backfill,
BackfillPolicy::Bounded {
max_batch_rows: 1..=10_000,
max_batch_ms: 1..=60_000,
resumable: true,
}
),
"contract" => {
descriptor.compatibility == "window-closed"
&& descriptor.contract_evidence.is_some_and(|evidence| {
!evidence.is_empty()
&& evidence.len() <= 256
&& !evidence.starts_with('/')
&& !evidence.contains("..")
})
&& descriptor.backfill == BackfillPolicy::None
}
_ => false,
};
let valid_compatibility = matches!(
descriptor.compatibility,
"legacy-baseline" | "n-minus-one-readable" | "window-open" | "window-closed"
);
let valid_owner = !descriptor.owner.is_empty()
&& descriptor.owner.len() <= 128
&& descriptor
.owner
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
if descriptor.version != expected_version
|| !valid_name
|| !valid_source_digest
|| !valid_checksum
|| !valid_phase
|| !valid_compatibility
|| !valid_owner
|| !valid_window
|| !descriptor.transactional
{
return Err(MigrationError::new(
"invalid_contract",
"contract.sequence",
Some(descriptor.version),
"contact_operator",
));
}
}
if descriptors.len() != usize::try_from(CURRENT_VERSION).unwrap_or_default()
|| descriptors
.iter()
.map(|descriptor| descriptor.version)
.ne(IMPLEMENTED_VERSIONS.iter().copied())
|| 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",
"contract.implementation",
Some(CURRENT_VERSION),
"contact_operator",
));
}
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?;
@@ -633,6 +520,12 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
CURRENT_VERSION,
)
.await?;
validate_required_relations(
connection,
owned_relations::ARTIFACT_METADATA,
CURRENT_VERSION,
)
.await?;
validate_schema_fingerprint(connection, CURRENT_VERSION).await?;
validate_legacy_audit(connection).await?;
Ok(MigrationPreflight::Current { version: current })
@@ -0,0 +1,140 @@
use sha2::{Digest, Sha256};
use super::*;
fn sha256_hex(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
#[cfg(test)]
pub(super) fn baseline_source_digest() -> String {
let source = include_str!("../baseline_v1.rs");
let (_, baseline) = source
.split_once("// baseline-v1:start\n")
.expect("baseline start marker must exist");
let (baseline, _) = baseline
.split_once("// baseline-v1:end")
.expect("baseline end marker must exist");
sha256_hex(baseline.as_bytes())
}
pub(super) fn validate_descriptors(
descriptors: &[MigrationDescriptor],
) -> Result<(), MigrationError> {
if descriptors.is_empty() || descriptors.len() > 1_024 {
return Err(MigrationError::new(
"invalid_contract",
"contract.sequence",
None,
"contact_operator",
));
}
for (index, descriptor) in descriptors.iter().enumerate() {
let expected_version = i64::try_from(index + 1).unwrap_or(i64::MAX);
let valid_name = !descriptor.name.is_empty()
&& descriptor.name.len() <= 128
&& descriptor
.name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
&& !descriptors[..index]
.iter()
.any(|prior| prior.name == descriptor.name);
let valid_source_digest = descriptor.source_digest.len() == 64
&& descriptor
.source_digest
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
let valid_checksum = if descriptor.version == 1 {
descriptor.checksum == BASELINE_CHECKSUM
} else {
descriptor.checksum == descriptor.source_digest
};
let valid_window = descriptor.readable_schema_min >= 1
&& descriptor.readable_schema_min <= descriptor.readable_schema_max
&& descriptor.readable_schema_max <= descriptor.version;
let valid_phase = match descriptor.phase {
"expand" => descriptor.backfill == BackfillPolicy::None,
"migrate" => matches!(
descriptor.backfill,
BackfillPolicy::Bounded {
max_batch_rows: 1..=10_000,
max_batch_ms: 1..=60_000,
resumable: true,
}
),
"contract" => {
descriptor.compatibility == "window-closed"
&& descriptor.contract_evidence.is_some_and(|evidence| {
!evidence.is_empty()
&& evidence.len() <= 256
&& !evidence.starts_with('/')
&& !evidence.contains("..")
})
&& descriptor.backfill == BackfillPolicy::None
}
_ => false,
};
let valid_compatibility = matches!(
descriptor.compatibility,
"legacy-baseline" | "n-minus-one-readable" | "window-open" | "window-closed"
);
let valid_owner = !descriptor.owner.is_empty()
&& descriptor.owner.len() <= 128
&& descriptor
.owner
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
if descriptor.version != expected_version
|| !valid_name
|| !valid_source_digest
|| !valid_checksum
|| !valid_phase
|| !valid_compatibility
|| !valid_owner
|| !valid_window
|| !descriptor.transactional
{
return Err(MigrationError::new(
"invalid_contract",
"contract.sequence",
Some(descriptor.version),
"contact_operator",
));
}
}
if descriptors.len() != usize::try_from(CURRENT_VERSION).unwrap_or_default()
|| descriptors
.iter()
.map(|descriptor| descriptor.version)
.ne(IMPLEMENTED_VERSIONS.iter().copied())
|| 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
|| sha256_hex(onboarding_product_events_v11::SOURCE.as_bytes())
!= onboarding_product_events_v11::SOURCE_SHA256
|| sha256_hex(artifact_metadata_v12::SOURCE.as_bytes())
!= artifact_metadata_v12::SOURCE_SHA256
{
return Err(MigrationError::new(
"invalid_contract",
"contract.implementation",
Some(CURRENT_VERSION),
"contact_operator",
));
}
Ok(())
}
@@ -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, 4, 5, 6, 7, 8, 9, 10, 11]
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
);
assert_eq!(first[0].checksum, "crank-community-baseline-v1");
assert_eq!(first[0].source_digest, BASELINE_SOURCE_SHA256);
@@ -31,6 +31,8 @@ pub(super) const ONBOARDING_PRODUCT_EVENTS: &[&str] = &[
"onboarding_selections",
];
pub(super) const ARTIFACT_METADATA: &[&str] = &["artifact_blobs", "artifact_sources"];
pub(super) const CONSOLIDATION: &[&str] = &[
"__crank_migrations",
"__crank_migration_legacy_audit",
@@ -35,6 +35,8 @@ pub(super) const OWNED_RELATIONS: &[&str] = &[
"approval_requests",
"invocation_logs",
"usage_rollups",
"artifact_blobs",
"artifact_sources",
];
const REQUIRED_COLUMNS: &[(&str, &[&str])] = &[
@@ -615,6 +617,13 @@ pub(super) async fn validate_schema_fingerprint(
} else {
super::schema_guard_v11::validate_v11_onboarding_product_events(connection).await?;
}
if current_version < 12 {
if !super::schema_guard_v12::validate_v12_absent(connection).await? {
return Err(schema_error(current_version));
}
} else {
super::schema_guard_v12::validate_v12_artifact_metadata(connection).await?;
}
Ok(())
}
@@ -0,0 +1,406 @@
use sqlx::{PgConnection, Row, query};
use super::authority::MigrationError;
use super::schema_guard::schema_error;
const OWNED_RELATIONS: &[(&str, &str)] = &[
("artifact_blobs", "r"),
("artifact_blobs_artifact_ref_key", "i"),
("artifact_blobs_pkey", "i"),
("artifact_sources", "r"),
("artifact_sources_pkey", "i"),
("artifact_sources_workspace_created_idx", "i"),
];
const BLOB_COLUMNS: &[(&str, &str, bool, Option<&str>)] = &[
("digest", "text", false, None),
("artifact_ref", "text", false, None),
("size_bytes", "bigint", false, None),
(
"storage_lifecycle",
"text",
false,
Some("'available'::text"),
),
("claim_token", "text", true, None),
("claim_expires_at", "timestamp with time zone", true, None),
("created_at", "timestamp with time zone", false, None),
("updated_at", "timestamp with time zone", false, None),
];
const SOURCE_COLUMNS: &[(&str, &str, bool, Option<&str>)] = &[
("workspace_id", "text", false, None),
("source_id", "text", false, None),
("blob_digest", "text", false, None),
("mime_type", "text", false, None),
("sensitivity", "text", false, None),
("lifecycle", "text", false, Some("'active'::text")),
("created_at", "timestamp with time zone", false, None),
("updated_at", "timestamp with time zone", false, None),
("detached_at", "timestamp with time zone", true, None),
];
const BLOB_CONSTRAINTS: &[(&str, &str, &str)] = &[
("artifact_blobs_pkey", "p", "PRIMARY KEY (digest)"),
(
"artifact_blobs_artifact_ref_key",
"u",
"UNIQUE (artifact_ref)",
),
(
"artifact_blobs_digest_check",
"c",
"CHECK (digest ~ '^[0-9a-f]{64}$'::text)",
),
(
"artifact_blobs_ref_check",
"c",
"CHECK (artifact_ref = ('sha256:'::text || digest))",
),
(
"artifact_blobs_size_check",
"c",
"CHECK (size_bytes >= 1 AND size_bytes <= 262144)",
),
(
"artifact_blobs_lifecycle_check",
"c",
"CHECK (storage_lifecycle = ANY (ARRAY['available'::text, 'unavailable'::text]))",
),
(
"artifact_blobs_claim_token_check",
"c",
"CHECK (claim_token IS NULL OR octet_length(claim_token) >= 1 AND octet_length(claim_token) <= 128)",
),
(
"artifact_blobs_claim_shape_check",
"c",
"CHECK (claim_token IS NULL AND claim_expires_at IS NULL OR claim_token IS NOT NULL AND claim_expires_at IS NOT NULL)",
),
(
"artifact_blobs_timestamps_check",
"c",
"CHECK (updated_at >= created_at)",
),
];
const SOURCE_CONSTRAINTS: &[(&str, &str, &str)] = &[
(
"artifact_sources_pkey",
"p",
"PRIMARY KEY (workspace_id, source_id)",
),
(
"artifact_sources_workspace_id_fkey",
"f",
"FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE",
),
(
"artifact_sources_blob_digest_fkey",
"f",
"FOREIGN KEY (blob_digest) REFERENCES artifact_blobs(digest)",
),
(
"artifact_sources_id_check",
"c",
"CHECK (source_id ~ '^src_[A-Za-z0-9_-]{1,128}$'::text)",
),
(
"artifact_sources_mime_type_check",
"c",
"CHECK (octet_length(mime_type) >= 3 AND octet_length(mime_type) <= 255 AND mime_type ~ '^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$'::text)",
),
(
"artifact_sources_sensitivity_check",
"c",
"CHECK (sensitivity = ANY (ARRAY['public'::text, 'internal'::text, 'secret'::text]))",
),
(
"artifact_sources_lifecycle_check",
"c",
"CHECK (lifecycle = ANY (ARRAY['active'::text, 'detached'::text]))",
),
(
"artifact_sources_state_check",
"c",
"CHECK (lifecycle = 'active'::text AND detached_at IS NULL OR lifecycle = 'detached'::text AND detached_at IS NOT NULL)",
),
(
"artifact_sources_timestamps_check",
"c",
"CHECK (updated_at >= created_at AND (detached_at IS NULL OR detached_at >= created_at AND updated_at >= detached_at))",
),
];
pub(super) async fn validate_v12_absent(
connection: &mut PgConnection,
) -> Result<bool, MigrationError> {
Ok(owned_relations(connection).await?.is_empty())
}
pub(super) async fn validate_v12_artifact_metadata(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
if owned_relations(connection).await? != OWNED_RELATIONS {
return Err(schema_error(12));
}
validate_table_properties(connection).await?;
validate_columns(connection, "artifact_blobs", BLOB_COLUMNS).await?;
validate_columns(connection, "artifact_sources", SOURCE_COLUMNS).await?;
validate_constraints(connection, "artifact_blobs", BLOB_CONSTRAINTS).await?;
validate_constraints(connection, "artifact_sources", SOURCE_CONSTRAINTS).await?;
validate_indexes(connection).await?;
validate_no_runtime_objects(connection).await
}
async fn validate_table_properties(connection: &mut PgConnection) -> Result<(), MigrationError> {
let rows = query(
"select c.relname, c.relpersistence::text as persistence,
c.relrowsecurity, c.relforcerowsecurity, c.reloptions
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = current_schema()
and c.relname in ('artifact_blobs', 'artifact_sources')
order by c.relname",
)
.fetch_all(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let valid = rows.len() == 2
&& rows.iter().all(|row| {
row.try_get::<String, _>("persistence").ok().as_deref() == Some("p")
&& row.try_get::<bool, _>("relrowsecurity").ok() == Some(false)
&& row.try_get::<bool, _>("relforcerowsecurity").ok() == Some(false)
&& row
.try_get::<Option<Vec<String>>, _>("reloptions")
.ok()
.flatten()
.is_none()
});
if valid { Ok(()) } else { Err(schema_error(12)) }
}
async fn owned_relations(
connection: &mut PgConnection,
) -> Result<Vec<(&'static str, &'static str)>, MigrationError> {
let rows = query(
"select c.relname, c.relkind::text as relkind
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = current_schema()
and c.relname like 'artifact\\_%' escape '\\'
order by c.relname",
)
.fetch_all(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
rows.into_iter()
.map(|row| {
let name = row
.try_get::<String, _>("relname")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let kind = row
.try_get::<String, _>("relkind")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let expected = OWNED_RELATIONS
.iter()
.copied()
.find(|(expected_name, expected_kind)| {
name == *expected_name && kind == *expected_kind
})
.ok_or_else(|| schema_error(12))?;
Ok(expected)
})
.collect()
}
async fn validate_columns(
connection: &mut PgConnection,
table: &str,
expected: &[(&str, &str, bool, Option<&str>)],
) -> Result<(), MigrationError> {
let rows = query(
"select column_name, data_type, is_nullable, column_default,
datetime_precision, collation_name
from information_schema.columns
where table_schema = current_schema() and table_name = $1
order by ordinal_position",
)
.bind(table)
.fetch_all(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if rows.len() != expected.len() {
return Err(schema_error(12));
}
for (row, (name, data_type, nullable, default)) in rows.iter().zip(expected) {
let actual_default = row
.try_get::<Option<String>, _>("column_default")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let datetime_precision = row
.try_get::<Option<i32>, _>("datetime_precision")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let collation = row
.try_get::<Option<String>, _>("collation_name")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if row.try_get::<String, _>("column_name").ok().as_deref() != Some(*name)
|| 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" })
|| actual_default.as_deref() != *default
|| (data_type == &"timestamp with time zone" && datetime_precision != Some(6))
|| collation.is_some()
{
return Err(schema_error(12));
}
}
Ok(())
}
async fn validate_constraints(
connection: &mut PgConnection,
table: &str,
expected: &[(&str, &str, &str)],
) -> Result<(), MigrationError> {
let rows = query(
"select c.conname, c.contype::text as contype, 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
order by c.conname",
)
.bind(table)
.fetch_all(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if rows.len() != expected.len() {
return Err(schema_error(12));
}
for row in rows {
let name = row
.try_get::<String, _>("conname")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let kind = row
.try_get::<String, _>("contype")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let definition = row
.try_get::<String, _>("definition")
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let Some((_, _, expected_definition)) =
expected.iter().find(|(expected_name, expected_kind, _)| {
name == *expected_name && kind == *expected_kind
})
else {
return Err(schema_error(12));
};
if canonical_definition(&definition) != canonical_definition(expected_definition) {
return Err(schema_error(12));
}
}
Ok(())
}
fn canonical_definition(value: &str) -> String {
let mut quoted = false;
value
.chars()
.filter_map(|character| {
if character == '\'' {
quoted = !quoted;
return Some(character);
}
if character.is_ascii_whitespace() {
None
} else if quoted {
Some(character)
} else {
Some(character.to_ascii_lowercase())
}
})
.collect()
}
async fn validate_indexes(connection: &mut PgConnection) -> Result<(), MigrationError> {
let rows = query(
"select idx.relname as index_name, t.relname as table_name, am.amname as access_method,
i.indisvalid, i.indisready, i.indisunique,
i.indnkeyatts, i.indnatts,
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 t.relname in ('artifact_blobs', 'artifact_sources')
order by idx.relname",
)
.fetch_all(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
let names = rows
.iter()
.filter_map(|row| row.try_get::<String, _>("index_name").ok())
.collect::<Vec<_>>();
if names
!= [
"artifact_blobs_artifact_ref_key",
"artifact_blobs_pkey",
"artifact_sources_pkey",
"artifact_sources_workspace_created_idx",
]
{
return Err(schema_error(12));
}
let row = rows.iter().find(|row| {
row.try_get::<String, _>("index_name").ok().as_deref()
== Some("artifact_sources_workspace_created_idx")
});
let valid = row.is_some_and(|row| {
row.try_get::<String, _>("table_name").ok().as_deref() == Some("artifact_sources")
&& 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::<i16, _>("indnkeyatts").ok() == Some(3)
&& row.try_get::<i16, _>("indnatts").ok() == Some(3)
&& row.try_get::<String, _>("first_column").ok().as_deref() == Some("workspace_id")
&& row.try_get::<String, _>("second_column").ok().as_deref() == Some("created_at")
&& row.try_get::<String, _>("third_column").ok().as_deref() == Some("source_id")
&& row
.try_get::<Option<String>, _>("predicate")
.ok()
.flatten()
.is_none()
});
if valid { Ok(()) } else { Err(schema_error(12)) }
}
async fn validate_no_runtime_objects(connection: &mut PgConnection) -> Result<(), MigrationError> {
let count: i64 = sqlx::query_scalar(
"select
(select count(*) from pg_catalog.pg_trigger tr
join pg_catalog.pg_class t on t.oid = tr.tgrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
where n.nspname = current_schema()
and t.relname in ('artifact_blobs', 'artifact_sources')
and not tr.tgisinternal)
+ (select count(*) from pg_catalog.pg_policy p
join pg_catalog.pg_class t on t.oid = p.polrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
where n.nspname = current_schema()
and t.relname in ('artifact_blobs', 'artifact_sources'))",
)
.fetch_one(connection)
.await
.map_err(|_| MigrationError::storage("preflight.schema"))?;
if count == 0 {
Ok(())
} else {
Err(schema_error(12))
}
}
+4
View File
@@ -43,6 +43,10 @@ macro_rules! define_registry_id {
};
}
mod artifact_source;
pub use artifact_source::*;
define_registry_id!(YamlImportJobId);
define_registry_id!(ImportJobId);
define_registry_id!(WorkspaceUpstreamId);
@@ -0,0 +1,106 @@
use crank_artifacts::{ArtifactRef, RegisteredArtifact};
use crank_core::WorkspaceId;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
define_registry_id!(ArtifactSourceId);
define_registry_id!(ArtifactDigest);
pub const MAX_ARTIFACT_SOURCE_PAGE_SIZE: u32 = 100;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactBlobLifecycle {
Available,
Unavailable,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactSourceSensitivity {
Public,
Internal,
Secret,
}
impl ArtifactSourceSensitivity {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Public => "public",
Self::Internal => "internal",
Self::Secret => "secret",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactSourceLifecycle {
Active,
Detached,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactBlobRecord {
pub digest: ArtifactDigest,
pub artifact_ref: ArtifactRef,
pub size_bytes: u64,
pub lifecycle: ArtifactBlobLifecycle,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactSourceRecord {
pub workspace_id: WorkspaceId,
pub source_id: ArtifactSourceId,
pub blob: ArtifactBlobRecord,
pub mime_type: String,
pub sensitivity: ArtifactSourceSensitivity,
pub lifecycle: ArtifactSourceLifecycle,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
pub detached_at: Option<OffsetDateTime>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactSourceCursor {
pub workspace_id: WorkspaceId,
pub created_at: OffsetDateTime,
pub source_id: ArtifactSourceId,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactSourcePage {
pub items: Vec<ArtifactSourceRecord>,
pub next_cursor: Option<ArtifactSourceCursor>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateArtifactSourceRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub source_id: &'a ArtifactSourceId,
pub artifact: &'a RegisteredArtifact,
pub mime_type: &'a str,
pub sensitivity: ArtifactSourceSensitivity,
pub created_at: OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListArtifactSourcesQuery<'a> {
pub workspace_id: &'a WorkspaceId,
pub cursor: Option<&'a ArtifactSourceCursor>,
pub limit: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DetachArtifactSourceRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub source_id: &'a ArtifactSourceId,
pub expected_updated_at: Option<OffsetDateTime>,
pub detached_at: OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifiedArtifactSource {
pub source: ArtifactSourceRecord,
pub bytes: Vec<u8>,
}
@@ -0,0 +1,507 @@
use crank_artifacts::{ArtifactError, ArtifactRef, ArtifactStore, MAX_ARTIFACT_BYTES};
use crank_core::WorkspaceId;
use sqlx::{Postgres, Row, Transaction, postgres::PgRow};
use crate::{
ArtifactBlobLifecycle, ArtifactBlobRecord, ArtifactDigest, ArtifactSourceCursor,
ArtifactSourceId, ArtifactSourceLifecycle, ArtifactSourcePage, ArtifactSourceRecord,
ArtifactSourceSensitivity, CreateArtifactSourceRequest, DetachArtifactSourceRequest,
ListArtifactSourcesQuery, MAX_ARTIFACT_SOURCE_PAGE_SIZE, RegistryError, VerifiedArtifactSource,
};
use super::PostgresRegistry;
const SOURCE_ID_PREFIX: &str = "src_";
const MAX_SOURCE_ID_SUFFIX_BYTES: usize = 128;
const MAX_MIME_TYPE_BYTES: usize = 255;
impl PostgresRegistry {
pub async fn create_artifact_source(
&self,
request: CreateArtifactSourceRequest<'_>,
) -> Result<ArtifactSourceRecord, RegistryError> {
validate_create_request(&request)?;
let mut transaction = self.pool().begin().await?;
require_workspace(&mut transaction, request.workspace_id).await?;
ensure_blob(&mut transaction, &request).await?;
sqlx::query(
"insert into artifact_sources (
workspace_id, source_id, blob_digest, mime_type, sensitivity,
lifecycle, created_at, updated_at, detached_at
) values ($1, $2, $3, $4, $5, 'active', $6, $6, null)
on conflict (workspace_id, source_id) do nothing",
)
.bind(request.workspace_id.as_str())
.bind(request.source_id.as_str())
.bind(request.artifact.artifact_ref().digest_hex())
.bind(request.mime_type)
.bind(request.sensitivity.as_str())
.bind(request.created_at)
.execute(&mut *transaction)
.await?;
let recorded =
get_source_in_transaction(&mut transaction, request.workspace_id, request.source_id)
.await?
.ok_or_else(|| source_not_found(request.source_id))?;
if !is_create_replay(&recorded, &request) {
return Err(source_conflict(request.source_id));
}
transaction.commit().await?;
Ok(recorded)
}
pub async fn get_artifact_source(
&self,
workspace_id: &WorkspaceId,
source_id: &ArtifactSourceId,
) -> Result<ArtifactSourceRecord, RegistryError> {
validate_source_id(source_id)?;
let row = sqlx::query(SOURCE_SELECT)
.bind(workspace_id.as_str())
.bind(source_id.as_str())
.fetch_optional(self.pool())
.await?;
row.map(map_artifact_source)
.transpose()?
.ok_or_else(|| source_not_found(source_id))
}
pub async fn list_artifact_sources(
&self,
query: ListArtifactSourcesQuery<'_>,
) -> Result<ArtifactSourcePage, RegistryError> {
if query.cursor.is_some_and(|cursor| {
cursor.workspace_id != *query.workspace_id
|| !valid_source_id(cursor.source_id.as_str())
}) {
return Err(RegistryError::InvalidArtifactSource { field: "cursor" });
}
let limit = query.limit.min(MAX_ARTIFACT_SOURCE_PAGE_SIZE);
if limit == 0 {
return Ok(ArtifactSourcePage {
items: Vec::new(),
next_cursor: None,
});
}
let cursor_created_at = query.cursor.map(|cursor| cursor.created_at);
let cursor_source_id = query.cursor.map(|cursor| cursor.source_id.as_str());
let fetch_limit = i64::from(limit) + 1;
let rows = sqlx::query(
"select
s.workspace_id, s.source_id, s.blob_digest, s.mime_type,
s.sensitivity, s.lifecycle as source_lifecycle,
s.created_at as source_created_at, s.updated_at as source_updated_at,
s.detached_at,
b.artifact_ref, b.size_bytes,
b.storage_lifecycle as blob_lifecycle
from artifact_sources s
join artifact_blobs b on b.digest = s.blob_digest
where s.workspace_id = $1
and (
$2::timestamptz is null
or (s.created_at, s.source_id) > ($2::timestamptz, $3::text)
)
order by s.created_at asc, s.source_id asc
limit $4",
)
.bind(query.workspace_id.as_str())
.bind(cursor_created_at)
.bind(cursor_source_id)
.bind(fetch_limit)
.fetch_all(self.pool())
.await?;
let mut items = rows
.into_iter()
.map(map_artifact_source)
.collect::<Result<Vec<_>, _>>()?;
let has_more = items.len() > limit as usize;
if has_more {
items.pop();
}
let next_cursor = has_more.then(|| {
let last = items
.last()
.expect("a non-zero bounded page with an extra row retains one item");
ArtifactSourceCursor {
workspace_id: query.workspace_id.clone(),
created_at: last.created_at,
source_id: last.source_id.clone(),
}
});
Ok(ArtifactSourcePage { items, next_cursor })
}
pub async fn detach_artifact_source(
&self,
request: DetachArtifactSourceRequest<'_>,
) -> Result<ArtifactSourceRecord, RegistryError> {
validate_source_id(request.source_id)?;
let mut transaction = self.pool().begin().await?;
let mut recorded =
get_source_in_transaction(&mut transaction, request.workspace_id, request.source_id)
.await?
.ok_or_else(|| source_not_found(request.source_id))?;
if recorded.lifecycle == ArtifactSourceLifecycle::Detached {
if recorded.detached_at == Some(request.detached_at)
&& request.expected_updated_at == Some(recorded.created_at)
{
transaction.commit().await?;
return Ok(recorded);
}
return Err(source_conflict(request.source_id));
}
if request.detached_at < recorded.created_at
|| request
.expected_updated_at
.is_some_and(|expected| expected != recorded.updated_at)
{
return Err(source_conflict(request.source_id));
}
let updated = sqlx::query(
"update artifact_sources
set lifecycle = 'detached', updated_at = $1, detached_at = $1
where workspace_id = $2 and source_id = $3
and lifecycle = 'active' and updated_at = $4",
)
.bind(request.detached_at)
.bind(request.workspace_id.as_str())
.bind(request.source_id.as_str())
.bind(recorded.updated_at)
.execute(&mut *transaction)
.await?
.rows_affected();
if updated != 1 {
return Err(source_conflict(request.source_id));
}
recorded.lifecycle = ArtifactSourceLifecycle::Detached;
recorded.updated_at = request.detached_at;
recorded.detached_at = Some(request.detached_at);
transaction.commit().await?;
Ok(recorded)
}
pub async fn read_artifact_source(
&self,
store: &ArtifactStore,
workspace_id: &WorkspaceId,
source_id: &ArtifactSourceId,
) -> Result<VerifiedArtifactSource, RegistryError> {
let source = self.get_artifact_source(workspace_id, source_id).await?;
if source.lifecycle != ArtifactSourceLifecycle::Active
|| source.blob.lifecycle != ArtifactBlobLifecycle::Available
{
return Err(RegistryError::SourceUnavailable);
}
let store = store.clone();
let artifact_ref = source.blob.artifact_ref.clone();
let bytes = tokio::task::spawn_blocking(move || store.read(&artifact_ref))
.await
.map_err(|_| RegistryError::SourceUnavailable)?
.map_err(map_artifact_read_error)?;
let actual_size = u64::try_from(bytes.len()).map_err(|_| RegistryError::SourceIntegrity)?;
if actual_size != source.blob.size_bytes {
return Err(RegistryError::SourceIntegrity);
}
Ok(VerifiedArtifactSource { source, bytes })
}
}
const SOURCE_SELECT: &str = "select
s.workspace_id, s.source_id, s.blob_digest, s.mime_type,
s.sensitivity, s.lifecycle as source_lifecycle,
s.created_at as source_created_at, s.updated_at as source_updated_at,
s.detached_at,
b.artifact_ref, b.size_bytes,
b.storage_lifecycle as blob_lifecycle
from artifact_sources s
join artifact_blobs b on b.digest = s.blob_digest
where s.workspace_id = $1 and s.source_id = $2";
async fn require_workspace(
transaction: &mut Transaction<'_, Postgres>,
workspace_id: &WorkspaceId,
) -> Result<(), RegistryError> {
let exists =
sqlx::query_scalar::<_, bool>("select exists(select 1 from workspaces where id = $1)")
.bind(workspace_id.as_str())
.fetch_one(&mut **transaction)
.await?;
if exists {
Ok(())
} else {
Err(RegistryError::WorkspaceNotFound {
workspace_id: workspace_id.as_str().to_owned(),
})
}
}
async fn ensure_blob(
transaction: &mut Transaction<'_, Postgres>,
request: &CreateArtifactSourceRequest<'_>,
) -> Result<(), RegistryError> {
let digest = request.artifact.artifact_ref().digest_hex();
let size_bytes = i64::try_from(request.artifact.size_bytes()).map_err(|_| {
RegistryError::InvalidArtifactSource {
field: "size_bytes",
}
})?;
sqlx::query(
"insert into artifact_blobs (
digest, artifact_ref, size_bytes, storage_lifecycle,
claim_token, claim_expires_at, created_at, updated_at
) values ($1, $2, $3, 'available', null, null, $4, $4)
on conflict (digest) do nothing",
)
.bind(digest)
.bind(request.artifact.artifact_ref().as_str())
.bind(size_bytes)
.bind(request.created_at)
.execute(&mut **transaction)
.await?;
let row = sqlx::query(
"select artifact_ref, size_bytes from artifact_blobs where digest = $1 for update",
)
.bind(digest)
.fetch_one(&mut **transaction)
.await?;
let recorded_ref = row.try_get::<String, _>("artifact_ref")?;
let recorded_size = row.try_get::<i64, _>("size_bytes")?;
if recorded_ref != request.artifact.artifact_ref().as_str() || recorded_size != size_bytes {
return Err(source_conflict(request.source_id));
}
Ok(())
}
async fn get_source_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
workspace_id: &WorkspaceId,
source_id: &ArtifactSourceId,
) -> Result<Option<ArtifactSourceRecord>, RegistryError> {
let row = sqlx::query(
"select
s.workspace_id, s.source_id, s.blob_digest, s.mime_type,
s.sensitivity, s.lifecycle as source_lifecycle,
s.created_at as source_created_at, s.updated_at as source_updated_at,
s.detached_at,
b.artifact_ref, b.size_bytes,
b.storage_lifecycle as blob_lifecycle
from artifact_sources s
join artifact_blobs b on b.digest = s.blob_digest
where s.workspace_id = $1 and s.source_id = $2
for update of s",
)
.bind(workspace_id.as_str())
.bind(source_id.as_str())
.fetch_optional(&mut **transaction)
.await?;
row.map(map_artifact_source).transpose()
}
fn map_artifact_source(row: PgRow) -> Result<ArtifactSourceRecord, RegistryError> {
let digest = row.try_get::<String, _>("blob_digest")?;
let artifact_ref_text = row.try_get::<String, _>("artifact_ref")?;
let artifact_ref = ArtifactRef::parse(&artifact_ref_text).map_err(|_| {
RegistryError::InvalidArtifactSource {
field: "artifact_ref",
}
})?;
if digest != artifact_ref.digest_hex() {
return Err(RegistryError::InvalidArtifactSource {
field: "blob_digest",
});
}
let size_bytes = row.try_get::<i64, _>("size_bytes")?;
let size_bytes =
u64::try_from(size_bytes).map_err(|_| RegistryError::InvalidArtifactSource {
field: "size_bytes",
})?;
let blob_lifecycle = match row.try_get::<String, _>("blob_lifecycle")?.as_str() {
"available" => ArtifactBlobLifecycle::Available,
"unavailable" => ArtifactBlobLifecycle::Unavailable,
_ => {
return Err(RegistryError::InvalidArtifactSource {
field: "storage_lifecycle",
});
}
};
let sensitivity = match row.try_get::<String, _>("sensitivity")?.as_str() {
"public" => ArtifactSourceSensitivity::Public,
"internal" => ArtifactSourceSensitivity::Internal,
"secret" => ArtifactSourceSensitivity::Secret,
_ => {
return Err(RegistryError::InvalidArtifactSource {
field: "sensitivity",
});
}
};
let lifecycle = match row.try_get::<String, _>("source_lifecycle")?.as_str() {
"active" => ArtifactSourceLifecycle::Active,
"detached" => ArtifactSourceLifecycle::Detached,
_ => {
return Err(RegistryError::InvalidArtifactSource { field: "lifecycle" });
}
};
let detached_at = row.try_get::<Option<time::OffsetDateTime>, _>("detached_at")?;
if (lifecycle == ArtifactSourceLifecycle::Active) != detached_at.is_none() {
return Err(RegistryError::InvalidArtifactSource {
field: "detached_at",
});
}
Ok(ArtifactSourceRecord {
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
source_id: ArtifactSourceId::new(row.try_get::<String, _>("source_id")?),
blob: ArtifactBlobRecord {
digest: ArtifactDigest::new(digest),
artifact_ref,
size_bytes,
lifecycle: blob_lifecycle,
},
mime_type: row.try_get("mime_type")?,
sensitivity,
lifecycle,
created_at: row.try_get("source_created_at")?,
updated_at: row.try_get("source_updated_at")?,
detached_at,
})
}
fn validate_create_request(request: &CreateArtifactSourceRequest<'_>) -> Result<(), RegistryError> {
if !valid_source_id(request.source_id.as_str()) {
return Err(RegistryError::InvalidArtifactSource { field: "source_id" });
}
if !valid_mime_type(request.mime_type) {
return Err(RegistryError::InvalidArtifactSource { field: "mime_type" });
}
if !valid_artifact_size(request.artifact.size_bytes()) {
return Err(RegistryError::InvalidArtifactSource {
field: "size_bytes",
});
}
Ok(())
}
fn valid_artifact_size(size_bytes: usize) -> bool {
(1..=MAX_ARTIFACT_BYTES).contains(&size_bytes)
}
fn validate_source_id(source_id: &ArtifactSourceId) -> Result<(), RegistryError> {
if valid_source_id(source_id.as_str()) {
Ok(())
} else {
Err(RegistryError::InvalidArtifactSource { field: "source_id" })
}
}
fn valid_mime_type(value: &str) -> bool {
if value.len() > MAX_MIME_TYPE_BYTES {
return false;
}
let Some((type_name, subtype)) = value.split_once('/') else {
return false;
};
!type_name.is_empty()
&& !subtype.is_empty()
&& !subtype.contains('/')
&& type_name.bytes().all(is_mime_token_byte)
&& subtype.bytes().all(is_mime_token_byte)
}
fn is_mime_token_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-'
)
}
fn valid_source_id(value: &str) -> bool {
let Some(suffix) = value.strip_prefix(SOURCE_ID_PREFIX) else {
return false;
};
!suffix.is_empty()
&& suffix.len() <= MAX_SOURCE_ID_SUFFIX_BYTES
&& suffix
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
}
fn is_create_replay(
recorded: &ArtifactSourceRecord,
request: &CreateArtifactSourceRequest<'_>,
) -> bool {
recorded.lifecycle == ArtifactSourceLifecycle::Active
&& recorded.blob.digest.as_str() == request.artifact.artifact_ref().digest_hex()
&& recorded.blob.artifact_ref == *request.artifact.artifact_ref()
&& recorded.blob.size_bytes == request.artifact.size_bytes() as u64
&& recorded.mime_type == request.mime_type
&& recorded.sensitivity == request.sensitivity
}
fn map_artifact_read_error(error: ArtifactError) -> RegistryError {
match error {
ArtifactError::NotFound | ArtifactError::UnsafeRoot | ArtifactError::Storage => {
RegistryError::SourceUnavailable
}
ArtifactError::Integrity
| ArtifactError::InvalidReference
| ArtifactError::EmptySource
| ArtifactError::SourceTooLarge => RegistryError::SourceIntegrity,
}
}
fn source_not_found(source_id: &ArtifactSourceId) -> RegistryError {
RegistryError::SourceNotFound {
source_id: source_id.as_str().to_owned(),
}
}
fn source_conflict(source_id: &ArtifactSourceId) -> RegistryError {
RegistryError::SourceConflict {
source_id: source_id.as_str().to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_bounded_source_identity_and_metadata() {
assert!(valid_source_id("src_openapi-1"));
assert!(valid_mime_type("application/yaml"));
assert!(valid_artifact_size(1));
assert!(valid_artifact_size(MAX_ARTIFACT_BYTES));
for invalid in ["source", "src_", "src_bad.value"] {
assert!(!valid_source_id(invalid));
}
for invalid in ["text/plain\n", "text", "text/plain/extra", "text/(plain)"] {
assert!(!valid_mime_type(invalid));
}
assert!(!valid_artifact_size(0));
assert!(!valid_artifact_size(MAX_ARTIFACT_BYTES + 1));
}
#[test]
fn artifact_failures_map_to_redacted_outcomes() {
assert!(matches!(
map_artifact_read_error(ArtifactError::NotFound),
RegistryError::SourceUnavailable
));
assert!(matches!(
map_artifact_read_error(ArtifactError::Integrity),
RegistryError::SourceIntegrity
));
}
}
@@ -2,6 +2,7 @@ mod agent;
mod agent_catalog;
mod api_key;
mod approval;
mod artifact_source;
mod auth;
mod connection;
mod import_job;