diff --git a/Cargo.lock b/Cargo.lock index c14ff6e16..737f8f009 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -866,6 +866,7 @@ dependencies = [ "nanoid", "ngrok", "oci-client", + "olpc-cjson", "prost 0.13.5", "prost-types", "rand 0.9.5", @@ -876,6 +877,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "tar", "tempfile", "tokio", "toml 0.9.12+spec-1.1.0", diff --git a/Cargo.toml b/Cargo.toml index 79032e5a3..d65e3014c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -169,6 +169,7 @@ serde_json = "1.0.140" serde_yaml = "0.9" snapbox = "0.6" tar = "0.4" +olpc-cjson = "0.1" toml = "0.9" temp-env = "0.3" tempfile = "3.10" diff --git a/crates/alien-aws-clients/src/aws/ecr.rs b/crates/alien-aws-clients/src/aws/ecr.rs index c2f346487..4f9ab51fa 100644 --- a/crates/alien-aws-clients/src/aws/ecr.rs +++ b/crates/alien-aws-clients/src/aws/ecr.rs @@ -44,6 +44,10 @@ pub trait EcrApi: Send + Sync + Debug { ) -> Result; async fn batch_get_image(&self, request: BatchGetImageRequest) -> Result; + async fn batch_delete_image( + &self, + request: BatchDeleteImageRequest, + ) -> Result; async fn get_download_url_for_layer( &self, request: GetDownloadUrlForLayerRequest, @@ -431,6 +435,23 @@ impl EcrApi for EcrClient { .await } + async fn batch_delete_image( + &self, + request: BatchDeleteImageRequest, + ) -> Result { + let body = serde_json::to_string(&request).into_alien_error().context( + ErrorData::SerializationError { + message: format!( + "Failed to serialize BatchDeleteImageRequest for repository '{}'", + request.repository_name + ), + }, + )?; + + self.post_json("BatchDeleteImage", body, &request.repository_name) + .await + } + async fn get_download_url_for_layer( &self, request: GetDownloadUrlForLayerRequest, @@ -765,6 +786,28 @@ pub struct BatchGetImageResponse { pub failures: Vec, } +// ------------------------------------------------------------------------- +// BatchDeleteImage +// ------------------------------------------------------------------------- + +#[derive(Serialize, Debug, Clone, Builder)] +#[serde(rename_all = "camelCase")] +pub struct BatchDeleteImageRequest { + pub repository_name: String, + pub image_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub registry_id: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct BatchDeleteImageResponse { + #[serde(default)] + pub image_ids: Vec, + #[serde(default)] + pub failures: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct ImageIdentifier { diff --git a/crates/alien-cloudformation/src/emitters/aws/artifact_registry.rs b/crates/alien-cloudformation/src/emitters/aws/artifact_registry.rs index 486789d17..79e68d946 100644 --- a/crates/alien-cloudformation/src/emitters/aws/artifact_registry.rs +++ b/crates/alien-cloudformation/src/emitters/aws/artifact_registry.rs @@ -7,7 +7,8 @@ use crate::{ }, template::{CfExpression, CfResource}, }; -use alien_core::{import::EmitContext, ArtifactRegistry, Result, ServiceAccount}; +use alien_core::{import::EmitContext, ArtifactRegistry, RemoteBindings, Result, ServiceAccount}; +use alien_error::AlienError; #[derive(Debug, Clone, Copy, Default)] pub struct AwsArtifactRegistryEmitter; @@ -51,9 +52,13 @@ impl CfEmitter for AwsArtifactRegistryEmitter { let mut pull_role = ecr_access_role(ctx, &pull_role_id, "registry-pull", false)?; let mut push_role = ecr_access_role(ctx, &push_role_id, "registry-push", true)?; pull_role.depends_on.push(repository_id.clone()); - push_role.depends_on.push(repository_id); + push_role.depends_on.push(repository_id.clone()); - Ok(vec![repository, pull_role, push_role]) + let mut resources = vec![repository, pull_role, push_role]; + if alien_core::remote_bindings::remote_binding_for_entry(ctx.resource).is_some() { + resources.push(remote_access_policy(ctx, &repository_id)?); + } + Ok(resources) } fn emit_import_ref(&self, ctx: &EmitContext<'_>) -> Result { @@ -170,8 +175,6 @@ fn ecr_policy_document(ctx: &EmitContext<'_>, push: bool) -> Result, push: bool) -> Result, repository_id: &str) -> Result { + let access_role_id = ctx + .stack + .resources() + .find_map(|(id, entry)| { + (entry.config.resource_type() == RemoteBindings::RESOURCE_TYPE) + .then(|| ctx.name_for(id)) + .flatten() + }) + .map(|id| format!("{id}Role")) + .ok_or_else(|| { + AlienError::new(alien_core::ErrorData::GenericError { + message: "remote ArtifactRegistry has no Remote Bindings identity".to_string(), + }) + })?; + let permission = alien_permissions::get_permission_set("artifact-registry/remote-read-write") + .and_then(|set| set.platforms.aws.as_ref()) + .ok_or_else(|| { + AlienError::new(alien_core::ErrorData::GenericError { + message: "artifact-registry/remote-read-write has no AWS permissions".to_string(), + }) + })?; + let actions = permission + .iter() + .flat_map(|entry| entry.grant.actions.iter().flatten()) + .cloned() + .collect::>(); + let mut policy = CfResource::new( + format!("{repository_id}RemoteRegistryPolicy"), + "AWS::IAM::Policy".to_string(), + ); + policy.properties.insert( + "PolicyName".to_string(), + CfExpression::sub(format!( + "${{AWS::StackName}}-{}-registry-access", + ctx.resource_id + )), + ); + policy.properties.insert( + "Roles".to_string(), + CfExpression::list([CfExpression::ref_(access_role_id)]), + ); + policy.properties.insert( + "PolicyDocument".to_string(), + CfExpression::object([ + ("Version", CfExpression::from("2012-10-17")), + ( + "Statement", + CfExpression::list([ + CfExpression::object([ + ("Effect", CfExpression::from("Allow")), + ("Action", CfExpression::from("ecr:GetAuthorizationToken")), + ("Resource", CfExpression::from("*")), + ]), + CfExpression::object([ + ("Effect", CfExpression::from("Allow")), + ( + "Action", + CfExpression::list( + actions + .into_iter() + .filter(|action| action != "ecr:GetAuthorizationToken") + .map(CfExpression::from), + ), + ), + ( + "Resource", + CfExpression::sub(format!("${{{repository_id}.Arn}}-*")), + ), + ]), + ]), + ), + ]), + ); + Ok(policy) +} + fn ecr_lifecycle_policy() -> CfExpression { CfExpression::object([( "rules", diff --git a/crates/alien-cloudformation/src/emitters/aws/remote_stack_management.rs b/crates/alien-cloudformation/src/emitters/aws/remote_stack_management.rs index 5355e71e3..c5f67538f 100644 --- a/crates/alien-cloudformation/src/emitters/aws/remote_stack_management.rs +++ b/crates/alien-cloudformation/src/emitters/aws/remote_stack_management.rs @@ -277,7 +277,12 @@ fn resource_scoped_aws_permission_context( .with_resource_id(resource_id.to_string()); context.resource_name = None; - if resource_entry.config.downcast_ref::().is_some() { + if resource_entry.config.downcast_ref::().is_some() + || resource_entry + .config + .downcast_ref::() + .is_some() + { return context.with_resource_name(format!("${{AWS::StackName}}-{resource_id}")); } @@ -295,7 +300,9 @@ fn resource_scoped_aws_permission_context( #[cfg(test)] mod tests { use super::*; - use alien_core::{Resource, ResourceEntry, ResourceLifecycle, Worker, WorkerCode}; + use alien_core::{ + ArtifactRegistry, Resource, ResourceEntry, ResourceLifecycle, Worker, WorkerCode, + }; fn live_worker_entry(id: &str) -> ResourceEntry { ResourceEntry { @@ -336,4 +343,21 @@ mod tests { Some("${AWS::StackName}-jobs") ); } + + #[test] + fn aws_remote_management_names_artifact_registry_child_prefix() { + let entry = ResourceEntry { + enabled_when: None, + config: Resource::new(ArtifactRegistry::new("images".to_string()).build()), + lifecycle: ResourceLifecycle::Frozen, + dependencies: Vec::new(), + remote_access: true, + }; + let context = + resource_scoped_aws_permission_context("images", &entry, &permission_context()); + assert_eq!( + context.resource_name.as_deref(), + Some("${AWS::StackName}-images") + ); + } } diff --git a/crates/alien-cloudformation/tests/generator/aws_compute_tests.rs b/crates/alien-cloudformation/tests/generator/aws_compute_tests.rs index bcb7b1efa..709cd9f32 100644 --- a/crates/alien-cloudformation/tests/generator/aws_compute_tests.rs +++ b/crates/alien-cloudformation/tests/generator/aws_compute_tests.rs @@ -11,8 +11,8 @@ use super::helpers::render_built_ins; use alien_cloudformation::{generate_cloudformation_template, CfRegistry, RegistrationMode}; use alien_core::{ ArtifactRegistry, Build, CapacityGroup, ComputeCluster, ErrorData, ManagementPermissions, - Network, NetworkSettings, PermissionProfile, Platform, RemoteStackManagement, - ResourceLifecycle, Stack, StackSettings, Worker, WorkerCode, + Network, NetworkSettings, PermissionProfile, Platform, RemoteBindings, RemoteStackManagement, + ResourceLifecycle, ResourceRef, Stack, StackSettings, Worker, WorkerCode, }; #[test] @@ -32,6 +32,32 @@ fn aws_artifact_registry_renders_ecr_repository() { insta::assert_snapshot!("aws_artifact_registry", yaml); } +#[test] +fn aws_remote_artifact_registry_policy_is_data_only() { + let mut stack = Stack::new("remote-registry".to_string()) + .add_with_remote_access( + ArtifactRegistry::new("registry".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add( + RemoteBindings::new("access".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .build(); + stack.resources.get_mut("registry").unwrap().dependencies = + vec![ResourceRef::new(RemoteBindings::RESOURCE_TYPE, "access")]; + let yaml = render_built_ins( + &stack, + StackSettings::default(), + RegistrationMode::OutputsFallback, + "aws_remote_artifact_registry", + ); + assert!(yaml.contains("ecr:GetAuthorizationToken")); + assert!(yaml.contains("ecr:PutImage")); + assert!(!yaml.contains("ecr:CreateRepository")); + assert!(!yaml.contains("ecr:DeleteRepository")); +} + #[test] fn aws_build_renders_codebuild_project() { let stack = Stack::new("acme-build".to_string()) diff --git a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_compute_tests__aws_artifact_registry.snap b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_compute_tests__aws_artifact_registry.snap index c56a51aa9..0aeb6ade6 100644 --- a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_compute_tests__aws_artifact_registry.snap +++ b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_compute_tests__aws_artifact_registry.snap @@ -183,8 +183,6 @@ Resources: - ecr:GetDownloadUrlForLayer - ecr:ListImages - ecr:CompleteLayerUpload - - ecr:CreateRepository - - ecr:DeleteRepository - ecr:InitiateLayerUpload - ecr:PutImage - ecr:UploadLayerPart diff --git a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_full_stack_tests__aws_full_stack.snap b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_full_stack_tests__aws_full_stack.snap index 857050c9b..dc0b43e97 100644 --- a/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_full_stack_tests__aws_full_stack.snap +++ b/crates/alien-cloudformation/tests/generator/snapshots/generator__generator__aws_full_stack_tests__aws_full_stack.snap @@ -1497,8 +1497,6 @@ Resources: - ecr:GetDownloadUrlForLayer - ecr:ListImages - ecr:CompleteLayerUpload - - ecr:CreateRepository - - ecr:DeleteRepository - ecr:InitiateLayerUpload - ecr:PutImage - ecr:UploadLayerPart diff --git a/crates/alien-core/src/remote_bindings.rs b/crates/alien-core/src/remote_bindings.rs index c42b1b419..277793884 100644 --- a/crates/alien-core/src/remote_bindings.rs +++ b/crates/alien-core/src/remote_bindings.rs @@ -5,6 +5,7 @@ pub enum RemoteBindingKind { Storage, Key, Ai, + ArtifactRegistry, } /// One resource type's provider-neutral Remote Bindings contract. @@ -51,6 +52,14 @@ const DEFINITIONS: &[RemoteBindingDefinition] = &[ setup_support_resource_types: &["azure_resource_group", "service_activation"], revision: 1, }, + RemoteBindingDefinition { + resource_type: "artifact-registry", + permission_set: "artifact-registry/remote-read-write", + kind: RemoteBindingKind::ArtifactRegistry, + description: "Pull and push OCI artifacts in this registry", + setup_support_resource_types: &["azure_resource_group", "service_activation"], + revision: 1, + }, ]; pub fn remote_binding_definition( diff --git a/crates/alien-gcp-clients/src/gcp/artifactregistry.rs b/crates/alien-gcp-clients/src/gcp/artifactregistry.rs index 4d7b04588..8efaededa 100644 --- a/crates/alien-gcp-clients/src/gcp/artifactregistry.rs +++ b/crates/alien-gcp-clients/src/gcp/artifactregistry.rs @@ -55,6 +55,15 @@ pub trait ArtifactRegistryApi: Send + Sync + Debug { repository_id: String, ) -> Result; + /// Deletes a package and all of its versions and tags. + async fn delete_package( + &self, + project_id: String, + location: String, + repository_id: String, + package_name: String, + ) -> Result; + /// Gets a repository. async fn get_repository( &self, @@ -189,6 +198,29 @@ impl ArtifactRegistryApi for ArtifactRegistryClient { .await } + async fn delete_package( + &self, + project_id: String, + location: String, + repository_id: String, + package_name: String, + ) -> Result { + let encoded_package = urlencoding::encode(&package_name); + let path = format!( + "projects/{project_id}/locations/{location}/repositories/{repository_id}/packages/{encoded_package}" + ); + + self.base + .execute_request( + Method::DELETE, + &path, + None, + Option::<()>::None, + &package_name, + ) + .await + } + /// Gets a repository. /// /// # Arguments diff --git a/crates/alien-manager/Cargo.toml b/crates/alien-manager/Cargo.toml index aa0a0fdd7..41aa6d09b 100644 --- a/crates/alien-manager/Cargo.toml +++ b/crates/alien-manager/Cargo.toml @@ -48,7 +48,7 @@ alien-azure-clients = { workspace = true } alien-gcp-clients = { workspace = true } # Web framework -axum = { workspace = true, features = ["tokio", "http2", "json", "query", "macros"] } +axum = { workspace = true, features = ["tokio", "http2", "json", "query", "macros", "original-uri"] } tower-http = { version = "0.7.0", features = ["cors"] } # Async runtime @@ -121,6 +121,8 @@ dockdash = { workspace = true, features = ["test-utils"] } sec = { workspace = true } oci-client = { workspace = true } sha2 = { workspace = true } +tar = { workspace = true } +olpc-cjson = { workspace = true } reqwest = { workspace = true } uuid = { workspace = true } bollard = { workspace = true } diff --git a/crates/alien-manager/src/builder.rs b/crates/alien-manager/src/builder.rs index 303cab5e6..0eacc8938 100644 --- a/crates/alien-manager/src/builder.rs +++ b/crates/alien-manager/src/builder.rs @@ -50,6 +50,8 @@ pub struct AlienManagerBuilder { /// proprietary controllers (`container`, `compute-cluster`) before /// passing it in here. import_registry: Option>, + customer_registry_broker: + Option>, } impl AlienManagerBuilder { @@ -73,6 +75,7 @@ impl AlienManagerBuilder { bindings_provider_override: None, target_bindings_providers_override: None, import_registry: None, + customer_registry_broker: None, } } @@ -179,6 +182,15 @@ impl AlienManagerBuilder { self } + /// Install a private authorization/routing adapter for customer OCI paths. + pub fn customer_registry_broker( + mut self, + broker: Arc, + ) -> Self { + self.customer_registry_broker = Some(broker); + self + } + /// Set up SQLite-backed standalone providers from a TOML config. /// /// This is the primary way to configure a standalone manager. It creates @@ -598,6 +610,8 @@ impl AlienManagerBuilder { /// or via a convenience method like `with_standalone_defaults()`). Missing /// providers produce a clear error. pub async fn build(self) -> crate::error::Result { + validate_customer_registry_base_url(&self.config, self.customer_registry_broker.is_some())?; + macro_rules! require_provider { ($field:expr, $name:literal) => { $field.ok_or_else(|| { @@ -647,11 +661,82 @@ impl AlienManagerBuilder { self.platform_routes, self.dev_status_tx, self.import_registry, + self.customer_registry_broker, ) .await } } +fn validate_customer_registry_base_url( + config: &ManagerConfig, + customer_registry_enabled: bool, +) -> crate::error::Result<()> { + if !customer_registry_enabled { + return Ok(()); + } + + let base_url = config.base_url.as_deref().ok_or_else(|| { + AlienError::new(ErrorData::ServerInitFailed { + reason: + "base_url must be explicitly configured when customer registry routing is enabled" + .to_string(), + }) + })?; + let parsed = reqwest::Url::parse(base_url).map_err(|_| { + AlienError::new(ErrorData::ServerInitFailed { + reason: "base_url must be an absolute HTTP or HTTPS URL when customer registry routing is enabled" + .to_string(), + }) + })?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || parsed.path() != "/" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(AlienError::new(ErrorData::ServerInitFailed { + reason: "base_url must be an origin-only HTTP or HTTPS URL when customer registry routing is enabled" + .to_string(), + })); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::validate_customer_registry_base_url; + use crate::config::ManagerConfig; + + #[test] + fn customer_registry_requires_an_explicit_origin() { + let config = ManagerConfig::default(); + assert!(validate_customer_registry_base_url(&config, false).is_ok()); + + let error = validate_customer_registry_base_url(&config, true) + .expect_err("customer registry must not advertise the localhost fallback"); + assert!(error + .to_string() + .contains("base_url must be explicitly configured")); + + let mut configured = config; + configured.base_url = Some("https://manager.example.com".to_string()); + validate_customer_registry_base_url(&configured, true) + .expect("an explicit HTTPS manager origin is valid"); + + configured.base_url = Some("https://manager.example.com/".to_string()); + validate_customer_registry_base_url(&configured, true) + .expect("a trailing root slash is still an origin-only URL"); + + configured.base_url = Some("https://manager.example.com/path".to_string()); + assert!(validate_customer_registry_base_url(&configured, true).is_err()); + + configured.base_url = Some("https://user@manager.example.com/path".to_string()); + assert!(validate_customer_registry_base_url(&configured, true).is_err()); + } +} + // --------------------------------------------------------------------------- // Binding → Platform helpers // --------------------------------------------------------------------------- @@ -701,6 +786,9 @@ async fn finalize( platform_routes: Option>, dev_status_tx: Option>, import_registry_override: Option>, + customer_registry_broker: Option< + Arc, + >, ) -> crate::error::Result { use alien_commands::server::CommandServer; @@ -728,10 +816,21 @@ async fn finalize( bindings_provider: server_bindings.bindings_provider.clone(), target_bindings_providers: server_bindings.target_bindings_providers.clone(), kv: server_bindings.kv.clone(), - http_client: reqwest::Client::new(), + // Registry credentials must never follow an upstream redirect to an + // unvalidated host. OCI Location headers are returned to the client + // and rewritten separately by the proxy. + http_client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| { + AlienError::new(ErrorData::ServerInitFailed { + reason: format!("Failed to build registry HTTP client: {error}"), + }) + })?, credential_cache: Arc::new(crate::routes::registry_proxy::CredentialCache::new()), pull_validation_cache: Arc::new(crate::routes::registry_proxy::PullValidationCache::new()), registry_routing_table: server_bindings.registry_routing_table.clone(), + customer_registry_broker, // Built-in importer registry covers every OSS `(ResourceType, // Platform)` pair across AWS / GCP / Azure (see // `alien_infra::ImporterRegistry::built_in`). Embedders that need diff --git a/crates/alien-manager/src/credential_materialization.rs b/crates/alien-manager/src/credential_materialization.rs index 662bd17ca..17685bfc0 100644 --- a/crates/alien-manager/src/credential_materialization.rs +++ b/crates/alien-manager/src/credential_materialization.rs @@ -20,6 +20,7 @@ const GCP_CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-pl pub(crate) const AZURE_STORAGE_SCOPE: &str = "https://storage.azure.com/.default"; pub(crate) const AZURE_KEY_VAULT_SCOPE: &str = "https://vault.azure.net/.default"; pub(crate) const AZURE_AI_SCOPE: &str = "https://cognitiveservices.azure.com/.default"; +pub(crate) const AZURE_MANAGEMENT_SCOPE: &str = "https://management.azure.com/.default"; const REMOTE_STORAGE_DURATION_SECONDS: i32 = 3600; const AZURE_MINT_SCOPES: [&str; 5] = [ "https://management.azure.com/.default", @@ -45,6 +46,9 @@ pub(crate) enum RemoteBindingCredentialScope { AwsAi, GcpAi, AzureAi, + AwsArtifactRegistry, + GcpArtifactRegistry, + AzureArtifactRegistry, } impl std::fmt::Debug for MaterializedCredentialLease { @@ -164,6 +168,7 @@ pub(crate) async fn materialize_remote_binding_lease( RemoteBindingCredentialScope::AzureBlob => AZURE_STORAGE_SCOPE, RemoteBindingCredentialScope::AzureKeyVault => AZURE_KEY_VAULT_SCOPE, RemoteBindingCredentialScope::AzureAi => AZURE_AI_SCOPE, + RemoteBindingCredentialScope::AzureArtifactRegistry => AZURE_MANAGEMENT_SCOPE, _ => { return Err(ErrorData::internal( "Remote Bindings credential scope does not match Azure", @@ -226,12 +231,15 @@ fn remote_binding_scope_platform(scope: &RemoteBindingCredentialScope) -> Platfo RemoteBindingCredentialScope::AwsS3 => Platform::Aws, RemoteBindingCredentialScope::AwsKms => Platform::Aws, RemoteBindingCredentialScope::AwsAi => Platform::Aws, + RemoteBindingCredentialScope::AwsArtifactRegistry => Platform::Aws, RemoteBindingCredentialScope::GcpGcs => Platform::Gcp, RemoteBindingCredentialScope::GcpCloudKms => Platform::Gcp, RemoteBindingCredentialScope::GcpAi => Platform::Gcp, + RemoteBindingCredentialScope::GcpArtifactRegistry => Platform::Gcp, RemoteBindingCredentialScope::AzureBlob => Platform::Azure, RemoteBindingCredentialScope::AzureKeyVault => Platform::Azure, RemoteBindingCredentialScope::AzureAi => Platform::Azure, + RemoteBindingCredentialScope::AzureArtifactRegistry => Platform::Azure, } } diff --git a/crates/alien-manager/src/routes/bindings.rs b/crates/alien-manager/src/routes/bindings.rs index 872f29df1..9942e5221 100644 --- a/crates/alien-manager/src/routes/bindings.rs +++ b/crates/alien-manager/src/routes/bindings.rs @@ -4,10 +4,14 @@ //! validates the authoritative stack state before it releases the resource's //! binding topology together with materialized, short-lived credentials. +use alien_bindings::{ + traits::ArtifactRegistry as ArtifactRegistryApi, BindingsProvider, BindingsProviderApi, +}; use alien_core::{ - Ai, AiBinding, AwsClientConfig, AwsCredentials, AzureClientConfig, AzureCredentials, - BindingValue, ClientConfig, DeploymentStatus, GcpClientConfig, GcpCredentials, Key, KeyBinding, - Platform, ResourceLifecycle, ResourceStatus, Storage, StorageBinding, + Ai, AiBinding, ArtifactRegistry, ArtifactRegistryBinding, AwsClientConfig, AwsCredentials, + AzureClientConfig, AzureCredentials, BindingValue, ClientConfig, DeploymentStatus, + GcpClientConfig, GcpCredentials, Key, KeyBinding, Platform, ResourceLifecycle, ResourceStatus, + Storage, StorageBinding, }; use alien_error::{Context, ContextError, IntoAlienError}; use axum::{ @@ -19,13 +23,17 @@ use axum::{ }; use chrono::{DateTime, SecondsFormat, Utc}; use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; use super::{auth, current_release_resource, load_current_release, AppState}; use crate::credential_materialization::{ materialize_remote_binding_lease, MaterializedCredentialLease, RemoteBindingCredentialScope, }; use crate::error::ErrorData; -use crate::traits::{deployment_status_from_record, DeploymentRecord, ReleaseStore}; +use crate::traits::{ + deployment_status_from_record, CredentialResolver, DeploymentRecord, DeploymentStore, + ReleaseStore, +}; /// The remote client refreshes five minutes before this server-provided hint. /// One hour matches the maximum supported lifetime for manager-minted cloud credentials. @@ -686,6 +694,11 @@ async fn resolve_binding( alien_core::remote_bindings::RemoteBindingKind::Ai => { remote_ai_binding(&deployment, &resource_id).map(ResolvedRemoteBinding::Ai) } + alien_core::remote_bindings::RemoteBindingKind::ArtifactRegistry => Err( + ErrorData::bad_request( + "Remote ArtifactRegistry credentials are available only to the Manager's local OCI operation broker", + ), + ), }; let binding = match binding { Ok(binding) => binding, @@ -910,22 +923,250 @@ async fn require_current_release_remote_access( "Resource '{resource_id}' is not enabled for remote access in the deployment's current release" ))); } - if definition.kind == alien_core::remote_bindings::RemoteBindingKind::Key - && stack - .resources - .values() - .filter(|entry| entry.remote_access) - .count() - != 1 + if matches!( + definition.kind, + alien_core::remote_bindings::RemoteBindingKind::Key + | alien_core::remote_bindings::RemoteBindingKind::ArtifactRegistry + ) && stack + .resources + .values() + .filter(|entry| entry.remote_access) + .count() + != 1 { return Err(ErrorData::bad_request( - "A remotely published Key must be the deployment's only remoteAccess resource", + "A remotely published Key or ArtifactRegistry must be the deployment's only remoteAccess resource", )); } Ok(definition.kind) } +/// Resolve a remote ArtifactRegistry entirely inside the Manager process. +/// +/// This is deliberately not an HTTP response type: the returned provider owns +/// the short-lived Access-identity lease and provider credentials never cross +/// the Manager boundary. The expected Release prevents a stale control-plane +/// snapshot from silently resolving a newer resource definition. +#[derive(Clone)] +pub struct LocalArtifactRegistryResolver { + deployment_store: Arc, + release_store: Arc, + credential_resolver: Arc, +} + +impl LocalArtifactRegistryResolver { + pub fn new( + deployment_store: Arc, + release_store: Arc, + credential_resolver: Arc, + ) -> Self { + Self { + deployment_store, + release_store, + credential_resolver, + } + } + + pub async fn resolve( + &self, + deployment_id: &str, + resource_id: &str, + expected_release_id: &str, + ) -> Result, alien_error::AlienError> { + let deployment = self + .deployment_store + .get_deployment(&crate::auth::Subject::system(), deployment_id) + .await + .context(ErrorData::InternalError { + message: "Failed to load Container Registry deployment".to_string(), + })? + .ok_or_else(|| ErrorData::not_found_deployment(deployment_id))?; + if deployment.current_release_id.as_deref() != Some(expected_release_id) { + return Err(ErrorData::bad_request( + "Container Registry route references a stale deployment Release", + )); + } + if !deployment_status_allows_remote_bindings(deployment_status_from_record( + &deployment.status, + )) { + return Err(ErrorData::bad_request(format!( + "Deployment is not operational for remote bindings (status '{}')", + deployment.status + ))); + } + let kind = require_current_release_remote_access( + self.release_store.as_ref(), + &deployment, + resource_id, + ) + .await?; + if kind != alien_core::remote_bindings::RemoteBindingKind::ArtifactRegistry { + return Err(ErrorData::bad_request(format!( + "Resource '{resource_id}' is not an ArtifactRegistry" + ))); + } + require_setup_owned_remote_binding(&deployment, resource_id)?; + let binding = remote_artifact_registry_binding(&deployment, resource_id)?; + let scope = match deployment.platform { + Platform::Aws => RemoteBindingCredentialScope::AwsArtifactRegistry, + Platform::Gcp => RemoteBindingCredentialScope::GcpArtifactRegistry, + Platform::Azure => RemoteBindingCredentialScope::AzureArtifactRegistry, + other => { + return Err(ErrorData::bad_request(format!( + "Remote ArtifactRegistry is not supported for deployment platform '{other}'" + ))) + } + }; + let source = self + .credential_resolver + .resolve_remote_storage_source(&deployment, resource_id) + .await + .context(ErrorData::RemoteCredentialHandoffFailed { + deployment_id: deployment.id.clone(), + platform: deployment.platform, + })?; + let lease = materialize_remote_binding_lease(source, scope).await?; + let binding = + serde_json::to_value(binding) + .into_alien_error() + .context(ErrorData::InternalError { + message: "Failed to encode remote ArtifactRegistry binding".to_string(), + })?; + let provider = BindingsProvider::new( + lease.client_config, + HashMap::from([(resource_id.to_string(), binding)]), + ) + .context(ErrorData::InternalError { + message: "Failed to initialize remote ArtifactRegistry binding".to_string(), + })?; + provider + .load_artifact_registry(resource_id) + .await + .context(ErrorData::InternalError { + message: "Failed to load remote ArtifactRegistry binding".to_string(), + }) + } + + /// Resolve the separate Management identity for explicit child repository + /// lifecycle operations. OCI data requests must use [`Self::resolve`] + /// instead, which obtains the narrower Access identity. + pub async fn resolve_management( + &self, + deployment_id: &str, + resource_id: &str, + expected_release_id: &str, + ) -> Result, alien_error::AlienError> { + let deployment = self + .deployment_store + .get_deployment(&crate::auth::Subject::system(), deployment_id) + .await + .context(ErrorData::InternalError { + message: "Failed to load Container Registry deployment".to_string(), + })? + .ok_or_else(|| ErrorData::not_found_deployment(deployment_id))?; + if deployment.current_release_id.as_deref() != Some(expected_release_id) { + return Err(ErrorData::bad_request( + "Container Registry route references a stale deployment Release", + )); + } + let kind = require_current_release_remote_access( + self.release_store.as_ref(), + &deployment, + resource_id, + ) + .await?; + if kind != alien_core::remote_bindings::RemoteBindingKind::ArtifactRegistry { + return Err(ErrorData::bad_request(format!( + "Resource '{resource_id}' is not an ArtifactRegistry" + ))); + } + require_setup_owned_remote_binding(&deployment, resource_id)?; + let binding = remote_artifact_registry_binding(&deployment, resource_id)?; + let client_config = self + .credential_resolver + .resolve(&deployment) + .await + .context(ErrorData::RemoteCredentialHandoffFailed { + deployment_id: deployment.id.clone(), + platform: deployment.platform, + })?; + let binding = + serde_json::to_value(binding) + .into_alien_error() + .context(ErrorData::InternalError { + message: "Failed to encode remote ArtifactRegistry binding".to_string(), + })?; + let provider = BindingsProvider::new( + client_config, + HashMap::from([(resource_id.to_string(), binding)]), + ) + .context(ErrorData::InternalError { + message: "Failed to initialize managed ArtifactRegistry binding".to_string(), + })?; + provider + .load_artifact_registry(resource_id) + .await + .context(ErrorData::InternalError { + message: "Failed to load managed ArtifactRegistry binding".to_string(), + }) + } +} + +fn remote_artifact_registry_binding( + deployment: &DeploymentRecord, + resource_id: &str, +) -> Result> { + let stack_state = deployment.stack_state.as_ref().ok_or_else(|| { + ErrorData::bad_request("Deployment has no stack state (not yet provisioned)") + })?; + let resource = stack_state.resource(resource_id).ok_or_else(|| { + ErrorData::bad_request(format!( + "Resource '{resource_id}' does not exist in stack state" + )) + })?; + if resource.resource_type != ArtifactRegistry::RESOURCE_TYPE.as_ref() { + return Err(ErrorData::bad_request(format!( + "Resource '{resource_id}' is not an ArtifactRegistry" + ))); + } + if resource.lifecycle != Some(ResourceLifecycle::Frozen) { + return Err(ErrorData::bad_request(format!( + "ArtifactRegistry resource '{resource_id}' is not Frozen" + ))); + } + if resource.status != ResourceStatus::Running { + return Err(ErrorData::bad_request(format!( + "ArtifactRegistry resource '{resource_id}' is not running" + ))); + } + let binding = resource.remote_binding_params.clone().ok_or_else(|| { + ErrorData::bad_request(format!( + "ArtifactRegistry resource '{resource_id}' is not enabled for remote access" + )) + })?; + let binding: ArtifactRegistryBinding = serde_json::from_value(binding) + .into_alien_error() + .context(ErrorData::BadRequest { + reason: format!( + "ArtifactRegistry resource '{resource_id}' has an invalid remote binding" + ), + })?; + let matches_platform = matches!( + (deployment.platform, &binding), + (Platform::Aws, ArtifactRegistryBinding::Ecr(_)) + | (Platform::Gcp, ArtifactRegistryBinding::Gar(_)) + | (Platform::Azure, ArtifactRegistryBinding::Acr(_)) + ); + if !matches_platform { + return Err(ErrorData::bad_request(format!( + "ArtifactRegistry resource '{resource_id}' binding does not match deployment platform '{}'", + deployment.platform + ))); + } + Ok(binding) +} + fn remote_storage_binding( deployment: &DeploymentRecord, resource_id: &str, diff --git a/crates/alien-manager/src/routes/mod.rs b/crates/alien-manager/src/routes/mod.rs index ae8549c7a..0d1cdfc69 100644 --- a/crates/alien-manager/src/routes/mod.rs +++ b/crates/alien-manager/src/routes/mod.rs @@ -70,6 +70,8 @@ pub struct AppState { pub pull_validation_cache: Arc, /// Routing table mapping repo path prefixes to upstream registries. pub registry_routing_table: Arc, + /// Optional private adapter for the disjoint `customer/` OCI namespace. + pub customer_registry_broker: Option>, /// Registry of per-`(ResourceType, Platform)` importers used by the /// stack-import endpoint to translate setup-artifact payloads /// (CFN Custom Resource, Terraform provider, Helm chart) into typed diff --git a/crates/alien-manager/src/routes/registry_proxy.rs b/crates/alien-manager/src/routes/registry_proxy.rs index f31e90a7e..3c60d97b4 100644 --- a/crates/alien-manager/src/routes/registry_proxy.rs +++ b/crates/alien-manager/src/routes/registry_proxy.rs @@ -20,16 +20,18 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; +use async_trait::async_trait; use axum::body::Body; -use axum::extract::{Path, Query, State}; +use axum::extract::{OriginalUri, Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::Router; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use hmac::{Hmac, Mac}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sha2::Sha256; +use tokio::sync::OwnedSemaphorePermit; use tracing::{debug, warn}; use url::Url; @@ -51,6 +53,82 @@ const UPLOAD_SESSION_REPO_PARAM: &str = "_alien_repo"; const UPLOAD_SESSION_EXPIRES_PARAM: &str = "_alien_exp"; const UPLOAD_SESSION_SIGNATURE_PARAM: &str = "_alien_sig"; const UPLOAD_SESSION_SIGNING_CONTEXT: &[u8] = b"registry-upload-session-signing"; +const CUSTOMER_UPLOAD_SESSION_PREFIX: &str = "customer-v1:"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CustomerRegistryOperation { + Pull, + Push, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CustomerRegistryAccessError { + Unauthorized, + Denied, + NotFound, + Unavailable, + RateLimited, +} + +/// Opaque admission permits held until the proxied response body finishes. +pub struct CustomerRegistryAdmission { + _permits: Vec, +} + +impl CustomerRegistryAdmission { + pub fn new(permits: Vec) -> Self { + Self { _permits: permits } + } +} + +#[derive(Clone)] +pub struct CustomerRegistryTarget { + pub route_id: String, + pub credential_id: String, + pub desired_revision: u64, + pub resource_revision: String, + pub logical_repository: String, + pub external_repository: String, + pub upstream_repository: String, + pub artifact_registry: Arc, + pub admission: Option>, +} + +/// Private embedders may authorize the reserved `customer/` namespace and +/// return an in-memory upstream. Implementations must retain only bounded, +/// short-lived cloud credentials and must not return them to the OCI client. +#[async_trait] +pub trait CustomerRegistryBroker: Send + Sync { + async fn authenticate_probe( + &self, + authorization: Option<&str>, + ) -> Result<(), CustomerRegistryAccessError>; + + async fn authorize( + &self, + authorization: Option<&str>, + repository: &str, + operation: CustomerRegistryOperation, + ) -> Result; + + /// Resolve a still-active route for a Manager-signed upload-session URL. + /// The session HMAC authenticates the request; this call rechecks current + /// route/repository revocation without accepting a client credential. + async fn resolve_signed_session( + &self, + repository: &str, + credential_id: &str, + ) -> Result; + + /// Record one successful manifest operation. Private adapters may use a + /// matching push+pull digest as their end-to-end readiness signal. + async fn record_manifest_success( + &self, + target: &CustomerRegistryTarget, + operation: CustomerRegistryOperation, + digest: &str, + ); +} // --------------------------------------------------------------------------- // Registry routing table @@ -144,6 +222,12 @@ impl RegistryRoutingTable { fn validate_unique_prefixes(routes: &[RegistryRoute]) -> Result<(), String> { let mut seen: HashMap<&str, &RegistryRoute> = HashMap::new(); for route in routes { + if route.prefix == "customer" || route.prefix.starts_with("customer/") { + return Err(format!( + "Artifact registry prefix '{}' overlaps the reserved customer namespace", + route.prefix + )); + } if let Some(existing) = seen.insert(route.prefix.as_str(), route) { let prefix = if route.prefix.is_empty() { "" @@ -396,10 +480,40 @@ fn oci_error(status: StatusCode, code: &'static str, message: impl Into) /// `GET /v2/` — OCI Distribution spec requires this endpoint to exist. async fn version_check(State(state): State, headers: HeaderMap) -> Response { - if let Err(e) = super::auth::require_auth(&state, &headers).await { - return oci_error(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", e.to_string()); + let customer_error = if basic_authorization(&headers).is_some() { + if let Some(broker) = &state.customer_registry_broker { + match broker + .authenticate_probe( + headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + ) + .await + { + Ok(()) => return (StatusCode::OK, "{}").into_response(), + Err(error) => Some(error), + } + } else { + None + } + } else { + None + }; + // Docker uses Basic auth for both ordinary Deployment-token pulls and the + // reserved customer registry. The version probe has no repository path to + // distinguish them, so accept either credential system here. Repository + // requests remain authorized by their exact namespace-specific path. + if super::auth::require_auth(&state, &headers).await.is_ok() { + return (StatusCode::OK, "{}").into_response(); } - (StatusCode::OK, "{}").into_response() + if let Some(error) = customer_error { + return customer_registry_error(error); + } + oci_error( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "Registry authentication failed", + ) } // --------------------------------------------------------------------------- @@ -430,6 +544,7 @@ async fn proxy_push( headers: HeaderMap, method: axum::http::Method, Path(path): Path, + OriginalUri(original_uri): OriginalUri, Query(query): Query>, body: Body, ) -> Response { @@ -440,7 +555,7 @@ async fn proxy_push( // calling the verifier so the path-component of the HMAC matches the // one used when signing. let full_path = format!("/v2/{}", oci_path_str); - let signed_session_repo = if is_oci_upload_session_path(&full_path) + let signed_session = if is_oci_upload_session_path(&full_path) && query.contains_key(UPLOAD_SESSION_VERSION_PARAM) { match verify_upload_session_auth(&state.config.response_signing_key, &full_path, &query) { @@ -451,23 +566,116 @@ async fn proxy_push( None }; - let repo_name = if let Some(ref repo) = signed_session_repo { + let unsigned_repo_name = extract_repo_name(&path); + let repo_name = if let Some(ref session) = signed_session { // Signed-URL bypass: the path's repo is implied by the signature, // not by Bearer auth. Trust the signature's repo. - repo.clone() + session.repository.clone() + } else if is_customer_repository(&unsigned_repo_name) { + // The private customer broker owns authentication for its reserved + // namespace. An OCI Basic credential is intentionally not an Alien + // API token and must never be sent through the internal token store. + unsigned_repo_name } else { let subject = match super::auth::require_auth(&state, &headers).await { Ok(s) => s, Err(e) => return oci_error(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", e.to_string()), }; - let repo_name = extract_repo_name(&path); - if let Err(e) = require_push_auth(&state, &subject, &repo_name) { + if let Err(e) = require_push_auth(&state, &subject, &unsigned_repo_name) { return e; } - repo_name + unsigned_repo_name }; - let upstream_query = if signed_session_repo.is_some() { + if is_customer_repository(&repo_name) { + if let Err(response) = validate_customer_request_target(&original_uri, &headers) { + return response; + } + let Some(broker) = &state.customer_registry_broker else { + return customer_registry_error(CustomerRegistryAccessError::NotFound); + }; + let target = if let Some(session) = &signed_session { + let Some(credential_id) = session.customer_credential_id() else { + return invalid_upload_session_auth(); + }; + broker + .resolve_signed_session(&repo_name, credential_id) + .await + } else { + broker + .authorize( + headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + &repo_name, + CustomerRegistryOperation::Push, + ) + .await + }; + let target = match target { + Ok(target) => target, + Err(error) => return customer_registry_error(error), + }; + if let Some(session) = &signed_session { + if !session.matches(&target) { + return invalid_upload_session_auth(); + } + } + let mut upstream_query = if signed_session.is_some() { + strip_upload_session_auth_params(&query) + } else { + query + }; + if signed_session.is_some() + && (upstream_query.contains_key("mount") || upstream_query.contains_key("from")) + { + return customer_registry_error(CustomerRegistryAccessError::Denied); + } + if let Err(error) = authorize_and_rewrite_customer_mount( + broker, + headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + &method, + &oci_path_str, + &target, + &mut upstream_query, + ) + .await + { + return customer_registry_error(error); + } + let qs = query_string(&upstream_query); + let oci_path = format!("{}{}", oci_path_str, qs); + if signed_session.is_some() { + // Axum strips the nested `/v2/` route prefix before this handler. + // The signed path may be provider-internal rather than the logical + // repository (GAR uses `pkg` for an upload session). Its HMAC and + // embedded customer identity bind it to this exact target, while + // raw forwarding remains pinned to the configured endpoint. + let upstream_path = format!("/v2/{oci_path}"); + return forward_customer_to_upstream_raw( + &state, + &method, + &upstream_path, + &headers, + Some(body), + &target, + ) + .await; + } + return forward_customer_to_upstream( + &state, + &method, + &oci_path, + &headers, + Some(body), + &target, + ) + .await; + } + + let upstream_query = if signed_session.is_some() { strip_upload_session_auth_params(&query) } else { query @@ -529,19 +737,54 @@ async fn proxy_upload_session( Query(query): Query>, body: Body, ) -> Response { - let subject = match super::auth::require_auth(&state, &headers).await { - Ok(s) => s, - Err(e) => return oci_error(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", e.to_string()), - }; - - let repo_name = match verify_upload_session_auth( + let session = match verify_upload_session_auth( &state.config.response_signing_key, original_uri.path(), &query, ) { - Ok(repo_name) => repo_name, + Ok(session) => session, Err(e) => return e, }; + let repo_name = session.repository.clone(); + + if is_customer_repository(&repo_name) { + if let Err(response) = validate_customer_request_target(&original_uri, &headers) { + return response; + } + let Some(broker) = &state.customer_registry_broker else { + return customer_registry_error(CustomerRegistryAccessError::NotFound); + }; + let Some(credential_id) = session.customer_credential_id() else { + return invalid_upload_session_auth(); + }; + let target = match broker + .resolve_signed_session(&repo_name, credential_id) + .await + { + Ok(target) => target, + Err(error) => return customer_registry_error(error), + }; + if !session.matches(&target) { + return invalid_upload_session_auth(); + } + let upstream_query = strip_upload_session_auth_params(&query); + let qs = query_string(&upstream_query); + let full_path = format!("{}{}", original_uri.path(), qs); + return forward_customer_to_upstream_raw( + &state, + &method, + &full_path, + &headers, + Some(body), + &target, + ) + .await; + } + + let subject = match super::auth::require_auth(&state, &headers).await { + Ok(s) => s, + Err(e) => return oci_error(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", e.to_string()), + }; if let Err(e) = require_push_auth(&state, &subject, &repo_name) { return e; @@ -573,19 +816,271 @@ async fn proxy_pull( headers: HeaderMap, method: axum::http::Method, Path(path): Path, + OriginalUri(original_uri): OriginalUri, + Query(query): Query>, ) -> Response { + let oci_path_str = path.trim_start_matches('/'); + let repo_name = extract_repo_name(oci_path_str); + if is_customer_repository(&repo_name) { + if let Err(response) = validate_customer_request_target(&original_uri, &headers) { + return response; + } + let Some(broker) = &state.customer_registry_broker else { + return customer_registry_error(CustomerRegistryAccessError::NotFound); + }; + let target = match broker + .authorize( + headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + &repo_name, + CustomerRegistryOperation::Pull, + ) + .await + { + Ok(target) => target, + Err(error) => return customer_registry_error(error), + }; + let oci_path = format!("{}{}", oci_path_str, query_string(&query)); + return forward_customer_to_upstream(&state, &method, &oci_path, &headers, None, &target) + .await; + } let subject = match super::auth::require_auth(&state, &headers).await { Ok(s) => s, Err(e) => return oci_error(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", e.to_string()), }; - let oci_path_str = path.trim_start_matches('/'); - let repo_name = extract_repo_name(oci_path_str); if let Err(e) = validate_pull_access(&state, &subject, &repo_name).await { return e; } - forward_to_upstream(&state, &method, oci_path_str, &headers, None, None).await + let oci_path = format!("{}{}", oci_path_str, query_string(&query)); + forward_to_upstream(&state, &method, &oci_path, &headers, None, None).await +} + +fn is_customer_repository(repository: &str) -> bool { + repository == "customer" || repository.starts_with("customer/") +} + +fn basic_authorization(headers: &HeaderMap) -> Option<&str> { + headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .filter(|value| value.starts_with("Basic ")) +} + +fn customer_registry_error(error: CustomerRegistryAccessError) -> Response { + match error { + CustomerRegistryAccessError::Unauthorized => oci_error( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "Invalid container registry credential", + ), + CustomerRegistryAccessError::Denied => oci_error( + StatusCode::FORBIDDEN, + "DENIED", + "Container registry access denied", + ), + CustomerRegistryAccessError::NotFound => oci_error( + StatusCode::NOT_FOUND, + "NAME_UNKNOWN", + "Container repository not found", + ), + CustomerRegistryAccessError::Unavailable => oci_error( + StatusCode::SERVICE_UNAVAILABLE, + "UNAVAILABLE", + "Container registry route is unavailable", + ), + CustomerRegistryAccessError::RateLimited => oci_error( + StatusCode::TOO_MANY_REQUESTS, + "TOOMANYREQUESTS", + "Container registry concurrency limit reached", + ), + } +} + +fn validate_customer_request_target( + uri: &axum::http::Uri, + headers: &HeaderMap, +) -> Result<(), Response> { + const MAX_PATH_BYTES: usize = 4_096; + const MAX_QUERY_BYTES: usize = 8_192; + const MAX_HEADER_BYTES: usize = 32_768; + + let path = uri.path(); + let query = uri.query().unwrap_or_default(); + let lower_target = uri + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or(path) + .to_ascii_lowercase(); + if path.len() > MAX_PATH_BYTES + || query.len() > MAX_QUERY_BYTES + || path.contains('\\') + || path.contains("//") + || lower_target.contains("%2f") + || lower_target.contains("%5c") + || lower_target.contains("%00") + || lower_target.contains("%2e") + || lower_target.contains("%25") + || path + .split('/') + .any(|component| component == "." || component == "..") + { + return Err(customer_registry_error(CustomerRegistryAccessError::Denied)); + } + + let header_bytes = headers + .iter() + .map(|(name, value)| name.as_str().len() + value.as_bytes().len()) + .sum::(); + if header_bytes > MAX_HEADER_BYTES + || (headers.contains_key("content-length") && headers.contains_key("transfer-encoding")) + { + return Err(customer_registry_error(CustomerRegistryAccessError::Denied)); + } + + let mut keys = std::collections::HashSet::new(); + for parameter in query.split('&').filter(|parameter| !parameter.is_empty()) { + let raw_key = parameter.split_once('=').map_or(parameter, |(key, _)| key); + let key = urlencoding::decode(raw_key) + .map_err(|_| customer_registry_error(CustomerRegistryAccessError::Denied))?; + if !keys.insert(key.into_owned()) { + return Err(customer_registry_error(CustomerRegistryAccessError::Denied)); + } + } + Ok(()) +} + +async fn forward_customer_to_upstream( + state: &AppState, + method: &axum::http::Method, + oci_path: &str, + original_headers: &HeaderMap, + body: Option, + target: &CustomerRegistryTarget, +) -> Response { + let Some(rewritten) = rewrite_oci_repository( + oci_path, + &target.external_repository, + &target.upstream_repository, + ) else { + return customer_registry_error(CustomerRegistryAccessError::Denied); + }; + let operation = if *method == axum::http::Method::GET || *method == axum::http::Method::HEAD { + CustomerRegistryOperation::Pull + } else { + CustomerRegistryOperation::Push + }; + let upload_session_identity = customer_upload_session_identity(target); + let response = forward_with_artifact_registry( + state, + method, + &rewritten, + original_headers, + body, + Some(&upload_session_identity), + target.artifact_registry.clone(), + &target.upstream_repository, + target.admission.clone(), + ) + .await; + if response.status().is_success() && rewritten.contains("/manifests/") { + if let (Some(digest), Some(broker)) = ( + response + .headers() + .get("docker-content-digest") + .and_then(|value| value.to_str().ok()) + .filter(|value| valid_manifest_digest(value)), + &state.customer_registry_broker, + ) { + broker + .record_manifest_success(target, operation, digest) + .await; + } + } + response +} + +async fn forward_customer_to_upstream_raw( + state: &AppState, + method: &axum::http::Method, + raw_path: &str, + original_headers: &HeaderMap, + body: Option, + target: &CustomerRegistryTarget, +) -> Response { + let upload_session_identity = customer_upload_session_identity(target); + forward_raw_with_artifact_registry( + state, + method, + raw_path, + original_headers, + body, + Some(&upload_session_identity), + target.artifact_registry.clone(), + &target.upstream_repository, + target.admission.clone(), + ) + .await +} + +fn rewrite_oci_repository(path: &str, external: &str, upstream: &str) -> Option { + let repository = extract_repo_name(path); + if repository != external || !path.starts_with(external) { + return None; + } + Some(format!("{}{}", upstream, &path[external.len()..])) +} + +fn valid_manifest_digest(digest: &str) -> bool { + digest.len() == 71 + && digest.starts_with("sha256:") + && digest[7..].bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +async fn authorize_and_rewrite_customer_mount( + broker: &Arc, + authorization: Option<&str>, + method: &axum::http::Method, + path: &str, + destination: &CustomerRegistryTarget, + query: &mut HashMap, +) -> Result<(), CustomerRegistryAccessError> { + let mount = query.get("mount"); + let source = query.get("from"); + if mount.is_none() && source.is_none() { + return Ok(()); + } + if *method != axum::http::Method::POST + || !path.ends_with("/blobs/uploads/") + || !mount.is_some_and(|digest| valid_manifest_digest(digest)) + { + return Err(CustomerRegistryAccessError::Denied); + } + let source = source.ok_or(CustomerRegistryAccessError::Denied)?; + let external_source = if is_customer_repository(source) { + source.clone() + } else { + format!("customer/{}/{}", destination.route_id, source) + }; + let source = broker + .authorize( + authorization, + &external_source, + CustomerRegistryOperation::Pull, + ) + .await?; + if source.route_id != destination.route_id + || source.credential_id != destination.credential_id + || source.resource_revision != destination.resource_revision + || source.artifact_registry.registry_endpoint() + != destination.artifact_registry.registry_endpoint() + { + return Err(CustomerRegistryAccessError::Denied); + } + query.insert("from".to_string(), source.upstream_repository); + Ok(()) } // --------------------------------------------------------------------------- @@ -609,6 +1104,32 @@ async fn forward_to_upstream( Err(e) => return e, }; + forward_with_artifact_registry( + state, + method, + oci_path, + original_headers, + body, + upload_session_repo, + artifact_registry, + &repo_name, + None, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn forward_with_artifact_registry( + state: &AppState, + method: &axum::http::Method, + oci_path: &str, + original_headers: &HeaderMap, + body: Option, + upload_session_repo: Option<&str>, + artifact_registry: Arc, + upstream_repository: &str, + admission: Option>, +) -> Response { let upstream_endpoint = artifact_registry.registry_endpoint(); let permissions = if *method == axum::http::Method::GET || *method == axum::http::Method::HEAD { @@ -620,7 +1141,10 @@ async fn forward_to_upstream( // Check credential cache before calling generate_credentials(). // Include the registry endpoint in the cache key to prevent cross-registry // credential contamination when multiple registries are configured. - let cache_key = format!("{}:{}:{:?}", upstream_endpoint, repo_name, permissions); + let cache_key = format!( + "{}:{}:{:?}", + upstream_endpoint, upstream_repository, permissions + ); let creds = if let Some(cached) = state.credential_cache.get(&cache_key) { cached } else { @@ -631,7 +1155,7 @@ async fn forward_to_upstream( cached } else { let fresh = match artifact_registry - .generate_credentials(&repo_name, permissions, Some(3600)) + .generate_credentials(upstream_repository, permissions, Some(3600)) .await { Ok(c) => c, @@ -677,6 +1201,7 @@ async fn forward_to_upstream( original_headers, body, upload_session_repo, + admission, ) .await } @@ -699,6 +1224,32 @@ async fn forward_to_upstream_raw( Err(e) => return e, }; + forward_raw_with_artifact_registry( + state, + method, + raw_path, + original_headers, + body, + upload_session_repo, + artifact_registry, + "", + None, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn forward_raw_with_artifact_registry( + state: &AppState, + method: &axum::http::Method, + raw_path: &str, + original_headers: &HeaderMap, + body: Option, + upload_session_repo: Option<&str>, + artifact_registry: Arc, + upstream_repository: &str, + admission: Option>, +) -> Response { let upstream_endpoint = artifact_registry.registry_endpoint(); if upstream_endpoint.is_empty() { return oci_error( @@ -710,7 +1261,10 @@ async fn forward_to_upstream_raw( // Use PushPull permissions — upload session paths are always push operations. let permissions = ArtifactRegistryPermissions::PushPull; - let cache_key = format!("upload-session:{}:{:?}", upstream_endpoint, permissions); + let cache_key = format!( + "upload-session:{}:{}:{:?}", + upstream_endpoint, upstream_repository, permissions + ); let creds = if let Some(cached) = state.credential_cache.get(&cache_key) { cached } else { @@ -721,7 +1275,7 @@ async fn forward_to_upstream_raw( cached } else { let fresh = match artifact_registry - .generate_credentials("", permissions, Some(3600)) + .generate_credentials(upstream_repository, permissions, Some(3600)) .await { Ok(c) => c, @@ -761,6 +1315,7 @@ async fn forward_to_upstream_raw( original_headers, body, upload_session_repo, + admission, ) .await } @@ -776,14 +1331,25 @@ async fn forward_request( original_headers: &HeaderMap, body: Option, upload_session_repo: Option<&str>, + admission: Option>, ) -> Response { - debug!(%method, %upstream_url, "Forwarding to upstream"); + debug!(%method, "Forwarding registry request to upstream"); // Use shared HTTP client from AppState. let mut req = state.http_client.request(method.clone(), upstream_url); // Forward relevant request headers. - for key in &["content-type", "content-length", "accept"] { + for key in &[ + "content-type", + "content-length", + "content-range", + "accept", + "range", + "if-match", + "if-none-match", + "if-modified-since", + "if-unmodified-since", + ] { if let Some(val) = original_headers.get(*key) { req = req.header(*key, val); } @@ -812,7 +1378,10 @@ async fn forward_request( let resp = match req.send().await { Ok(r) => r, Err(e) => { - warn!(error = %e, %upstream_url, "Upstream request failed"); + warn!( + category = upstream_request_error_category(&e), + "Upstream registry request failed" + ); return oci_error( StatusCode::BAD_GATEWAY, "INTERNAL_ERROR", @@ -825,10 +1394,14 @@ async fn forward_request( let status = resp.status(); let resp_headers = resp.headers().clone(); - debug!(%method, %upstream_url, upstream_status = %status.as_u16(), "Upstream response"); + debug!(%method, upstream_status = %status.as_u16(), "Upstream registry response"); // Stream the response body instead of buffering. - let resp_body = Body::from_stream(resp.bytes_stream()); + use futures::StreamExt as _; + let resp_body = Body::from_stream(resp.bytes_stream().map(move |chunk| { + let _admission = &admission; + chunk + })); let mut response = ( StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), resp_body, @@ -836,13 +1409,15 @@ async fn forward_request( .into_response(); let upstream_host = upstream_endpoint.trim_end_matches('/'); - let proxy_base = proxy_base_url(original_headers, &state.config.base_url()); + let configured_base_url = state.config.base_url(); + let proxy_base = + upload_session_proxy_base(original_headers, &configured_base_url, upload_session_repo); let proxy_host = proxy_base.trim_end_matches('/'); for (key, value) in &resp_headers { if key == "location" { if let Ok(location) = value.to_str() { - debug!(raw_location = %location, "Rewriting Location header"); + debug!("Rewriting upstream registry Location header"); if location.starts_with('/') { // Relative URL (e.g., GAR's /artifacts-uploads/...). // Rewrite to go through the proxy so credentials are injected. @@ -874,13 +1449,16 @@ async fn forward_request( continue; } } - // Other absolute URLs — pass through unchanged. - response.headers_mut().insert(key, value.clone()); - continue; + // A foreign redirect can contain a provider-signed URL. Never + // expose that credential to the OCI client. + return oci_error( + StatusCode::BAD_GATEWAY, + "INTERNAL_ERROR", + "Upstream registry returned an unsupported redirect", + ); } } - // Skip hop-by-hop headers. - if key != "transfer-encoding" && key != "connection" { + if safe_registry_response_header(key.as_str()) { response.headers_mut().insert(key, value.clone()); } } @@ -888,6 +1466,52 @@ async fn forward_request( response } +fn upload_session_proxy_base( + original_headers: &HeaderMap, + configured_base_url: &str, + upload_session_repo: Option<&str>, +) -> String { + if upload_session_repo + .is_some_and(|repository| repository.starts_with(CUSTOMER_UPLOAD_SESSION_PREFIX)) + { + configured_base_url.to_string() + } else { + proxy_base_url(original_headers, configured_base_url) + } +} + +fn safe_registry_response_header(name: &str) -> bool { + matches!( + name, + "content-type" + | "content-length" + | "content-range" + | "accept-ranges" + | "etag" + | "last-modified" + | "docker-content-digest" + | "docker-upload-uuid" + | "docker-distribution-api-version" + | "link" + | "retry-after" + | "oci-filters-applied" + ) || name.starts_with("ratelimit-") +} + +fn upstream_request_error_category(error: &reqwest::Error) -> &'static str { + if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connect" + } else if error.is_body() { + "body" + } else if error.is_request() { + "request" + } else { + "other" + } +} + // --------------------------------------------------------------------------- // GAR upload session auth // --------------------------------------------------------------------------- @@ -939,11 +1563,81 @@ fn rewrite_location_with_upload_session_auth( Ok(url.to_string()) } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CustomerUploadSessionContext { + repository: String, + route_id: String, + credential_id: String, + desired_revision: u64, + resource_revision: String, + upstream_repository: String, + upstream_endpoint: String, +} + +struct VerifiedUploadSession { + repository: String, + customer: Option, +} + +impl VerifiedUploadSession { + fn customer_credential_id(&self) -> Option<&str> { + self.customer + .as_ref() + .map(|context| context.credential_id.as_str()) + } + + fn matches(&self, target: &CustomerRegistryTarget) -> bool { + self.customer.as_ref().is_none_or(|context| { + context.repository == target.external_repository + && context.route_id == target.route_id + && context.credential_id == target.credential_id + && context.desired_revision == target.desired_revision + && context.resource_revision == target.resource_revision + && context.upstream_repository == target.upstream_repository + && context.upstream_endpoint == target.artifact_registry.registry_endpoint() + }) + } +} + +fn customer_upload_session_identity(target: &CustomerRegistryTarget) -> String { + let context = CustomerUploadSessionContext { + repository: target.external_repository.clone(), + route_id: target.route_id.clone(), + credential_id: target.credential_id.clone(), + desired_revision: target.desired_revision, + resource_revision: target.resource_revision.clone(), + upstream_repository: target.upstream_repository.clone(), + upstream_endpoint: target.artifact_registry.registry_endpoint(), + }; + let encoded = serde_json::to_vec(&context).expect("customer upload session context serializes"); + format!( + "{}{}", + CUSTOMER_UPLOAD_SESSION_PREFIX, + URL_SAFE_NO_PAD.encode(encoded) + ) +} + +fn parse_upload_session_identity(identity: &str) -> Option { + let Some(encoded) = identity.strip_prefix(CUSTOMER_UPLOAD_SESSION_PREFIX) else { + return Some(VerifiedUploadSession { + repository: identity.to_string(), + customer: None, + }); + }; + let encoded = URL_SAFE_NO_PAD.decode(encoded).ok()?; + let customer = serde_json::from_slice::(&encoded).ok()?; + Some(VerifiedUploadSession { + repository: customer.repository.clone(), + customer: Some(customer), + }) +} + fn verify_upload_session_auth( signing_key: &[u8], upload_path: &str, query: &HashMap, -) -> Result { +) -> Result { if signing_key.is_empty() { return Err(oci_error( StatusCode::INTERNAL_SERVER_ERROR, @@ -981,7 +1675,7 @@ fn verify_upload_session_auth( return Err(invalid_upload_session_auth()); } - Ok(repo_name.clone()) + parse_upload_session_identity(repo_name).ok_or_else(invalid_upload_session_auth) } fn invalid_upload_session_auth() -> Response { @@ -1445,10 +2139,132 @@ async fn load_artifact_registry_for_repo( #[cfg(test)] mod tests { use super::*; + use alien_bindings::error::Result as BindingResult; + use alien_bindings::traits::{ + ArtifactRegistryCredentials, ArtifactRegistryPermissions, CrossAccountAccess, + CrossAccountPermissions, RepositoryResponse, + }; use alien_core::image_rewrite::strip_registry_host; use alien_core::{Daemon, DaemonCode, ResourceLifecycle, Stack}; + use async_trait::async_trait; use std::sync::atomic::{AtomicUsize, Ordering}; + #[derive(Debug)] + struct SessionTestRegistry(&'static str); + + impl alien_bindings::traits::Binding for SessionTestRegistry {} + + #[async_trait] + impl ArtifactRegistry for SessionTestRegistry { + fn registry_endpoint(&self) -> String { + self.0.to_string() + } + + async fn create_repository(&self, _: &str) -> BindingResult { + unimplemented!() + } + + async fn get_repository(&self, _: &str) -> BindingResult { + unimplemented!() + } + + async fn add_cross_account_access( + &self, + _: &str, + _: CrossAccountAccess, + ) -> BindingResult<()> { + unimplemented!() + } + + async fn remove_cross_account_access( + &self, + _: &str, + _: CrossAccountAccess, + ) -> BindingResult<()> { + unimplemented!() + } + + async fn get_cross_account_access( + &self, + _: &str, + ) -> BindingResult { + unimplemented!() + } + + async fn generate_credentials( + &self, + _: &str, + _: ArtifactRegistryPermissions, + _: Option, + ) -> BindingResult { + unimplemented!() + } + + async fn delete_repository(&self, _: &str) -> BindingResult<()> { + unimplemented!() + } + } + + fn customer_target() -> CustomerRegistryTarget { + CustomerRegistryTarget { + route_id: "rgw_route".to_string(), + credential_id: "rgc_credential".to_string(), + desired_revision: 7, + resource_revision: "rel_resource".to_string(), + logical_repository: "api".to_string(), + external_repository: "customer/rgw_route/api".to_string(), + upstream_repository: "upstream-api".to_string(), + artifact_registry: Arc::new(SessionTestRegistry("https://registry.example")), + admission: None, + } + } + + struct SessionTestBroker; + + #[async_trait] + impl CustomerRegistryBroker for SessionTestBroker { + async fn authenticate_probe( + &self, + _: Option<&str>, + ) -> Result<(), CustomerRegistryAccessError> { + Ok(()) + } + + async fn authorize( + &self, + _: Option<&str>, + repository: &str, + _: CustomerRegistryOperation, + ) -> Result { + let parts = repository.split('/').collect::>(); + if parts.len() != 3 || parts[0] != "customer" { + return Err(CustomerRegistryAccessError::NotFound); + } + let mut target = customer_target(); + target.route_id = parts[1].to_string(); + target.logical_repository = parts[2].to_string(); + target.external_repository = repository.to_string(); + target.upstream_repository = format!("upstream-{}", parts[2]); + Ok(target) + } + + async fn resolve_signed_session( + &self, + _: &str, + _: &str, + ) -> Result { + unimplemented!() + } + + async fn record_manifest_success( + &self, + _: &CustomerRegistryTarget, + _: CustomerRegistryOperation, + _: &str, + ) { + } + } + #[test] fn test_strip_registry_host_gar() { assert_eq!( @@ -1555,6 +2371,27 @@ mod tests { ); } + #[test] + fn customer_upload_continuations_ignore_forwarded_origin() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-host", "attacker.example".parse().unwrap()); + headers.insert("x-forwarded-proto", "https".parse().unwrap()); + let identity = customer_upload_session_identity(&customer_target()); + + assert_eq!( + upload_session_proxy_base(&headers, "https://manager.example.com", Some(&identity)), + "https://manager.example.com" + ); + assert_eq!( + upload_session_proxy_base( + &headers, + "https://manager.example.com", + Some("ordinary/repository"), + ), + "https://attacker.example" + ); + } + #[tokio::test] async fn credential_cache_serializes_generation_for_same_key() { let cache = Arc::new(CredentialCache::new()); @@ -1757,6 +2594,124 @@ mod tests { assert!(error.contains("Duplicate artifact registry prefix 'artifacts'")); } + #[test] + fn registry_routing_table_reserves_customer_namespace_for_private_adapter() { + let result = RegistryRoutingTable::new(vec![registry_route( + "customer/internal", + Platform::Aws, + "aws", + )]); + let Err(error) = result else { + panic!("customer namespace must be reserved"); + }; + assert!(error.contains("reserved customer namespace")); + } + + #[test] + fn customer_repository_rewrite_changes_only_the_parsed_repository() { + assert_eq!( + rewrite_oci_repository( + "customer/route_1/team/api/manifests/latest", + "customer/route_1/team/api", + "upstream/team-api" + ), + Some("upstream/team-api/manifests/latest".to_string()) + ); + assert_eq!( + rewrite_oci_repository( + "customer/route_2/team/api/manifests/latest", + "customer/route_1/team/api", + "upstream/team-api" + ), + None + ); + } + + #[tokio::test] + async fn customer_cross_repository_mount_authorizes_and_rewrites_the_source() { + let broker: Arc = Arc::new(SessionTestBroker); + let destination = customer_target(); + let mut query = HashMap::from([ + ("mount".to_string(), format!("sha256:{}", "a".repeat(64))), + ("from".to_string(), "source".to_string()), + ]); + authorize_and_rewrite_customer_mount( + &broker, + Some("Basic unused"), + &axum::http::Method::POST, + "customer/rgw_route/api/blobs/uploads/", + &destination, + &mut query, + ) + .await + .expect("same-route source is authorized"); + assert_eq!( + query.get("from").map(String::as_str), + Some("upstream-source") + ); + + query.insert("from".to_string(), "customer/rgw_other/source".to_string()); + assert_eq!( + authorize_and_rewrite_customer_mount( + &broker, + Some("Basic unused"), + &axum::http::Method::POST, + "customer/rgw_route/api/blobs/uploads/", + &destination, + &mut query, + ) + .await, + Err(CustomerRegistryAccessError::Denied) + ); + } + + #[test] + fn customer_request_target_rejects_ambiguous_paths_queries_and_framing() { + let headers = HeaderMap::new(); + assert!(validate_customer_request_target( + &"/v2/customer/rgw/api/manifests/latest".parse().unwrap(), + &headers, + ) + .is_ok()); + for target in [ + "/v2/customer/rgw/api%2Fescape/manifests/latest", + "/v2/customer/rgw/%2e%2e/manifests/latest", + "/v2/customer/rgw/api/manifests/latest?from=a&from=b", + ] { + assert!(validate_customer_request_target(&target.parse().unwrap(), &headers).is_err()); + } + let mut conflicting = HeaderMap::new(); + conflicting.insert("content-length", "1".parse().unwrap()); + conflicting.insert("transfer-encoding", "chunked".parse().unwrap()); + assert!(validate_customer_request_target( + &"/v2/customer/rgw/api/blobs/uploads/".parse().unwrap(), + &conflicting, + ) + .is_err()); + } + + #[test] + fn registry_response_header_allowlist_preserves_only_oci_metadata() { + for header in [ + "content-range", + "docker-content-digest", + "link", + "oci-filters-applied", + "ratelimit-remaining", + "retry-after", + ] { + assert!(safe_registry_response_header(header)); + } + for header in [ + "authorization", + "set-cookie", + "www-authenticate", + "x-provider-token", + ] { + assert!(!safe_registry_response_header(header)); + } + } + // ----------------------------------------------------------------------- // project_id_after_prefix — the algorithm behind // RegistryRoutingTable::project_id_for_repo. Tests target the free @@ -1909,6 +2864,53 @@ mod tests { .is_err()); } + #[test] + fn customer_upload_session_is_bound_to_credential_route_revision_and_upstream() { + let signing_key = b"test-registry-upload-session-key"; + let path = "/v2/customer/rgw_route/api/blobs/uploads/session-1"; + let target = customer_target(); + let identity = customer_upload_session_identity(&target); + let expires_at = chrono::Utc::now().timestamp() + UPLOAD_SESSION_TTL_SECONDS; + let signature = sign_upload_session(signing_key, path, &identity, expires_at); + let query = HashMap::from([ + ( + UPLOAD_SESSION_VERSION_PARAM.to_string(), + UPLOAD_SESSION_VERSION.to_string(), + ), + (UPLOAD_SESSION_REPO_PARAM.to_string(), identity), + ( + UPLOAD_SESSION_EXPIRES_PARAM.to_string(), + expires_at.to_string(), + ), + (UPLOAD_SESSION_SIGNATURE_PARAM.to_string(), signature), + ]); + + let session = verify_upload_session_auth(signing_key, path, &query) + .expect("valid customer session should verify"); + assert_eq!(session.customer_credential_id(), Some("rgc_credential")); + assert!(session.matches(&target)); + + let mut changed = customer_target(); + changed.credential_id = "rgc_rotated".to_string(); + assert!(!session.matches(&changed)); + + let mut changed = customer_target(); + changed.desired_revision += 1; + assert!(!session.matches(&changed)); + + let mut changed = customer_target(); + changed.resource_revision = "rel_replaced".to_string(); + assert!(!session.matches(&changed)); + + let mut changed = customer_target(); + changed.upstream_repository = "other-upstream".to_string(); + assert!(!session.matches(&changed)); + + let mut changed = customer_target(); + changed.artifact_registry = Arc::new(SessionTestRegistry("https://other.example")); + assert!(!session.matches(&changed)); + } + #[test] fn gar_upload_session_auth_rejects_expired_token() { let signing_key = b"test-registry-upload-session-key"; diff --git a/crates/alien-manager/tests/credentials_mint.rs b/crates/alien-manager/tests/credentials_mint.rs index 19f616b43..2b47baf6f 100644 --- a/crates/alien-manager/tests/credentials_mint.rs +++ b/crates/alien-manager/tests/credentials_mint.rs @@ -486,6 +486,7 @@ async fn build( registry_routing_table: Arc::new( RegistryRoutingTable::new(vec![]).expect("empty routing table is unambiguous"), ), + customer_registry_broker: None, import_registry: Arc::new(alien_infra::ImporterRegistry::built_in()), }; diff --git a/crates/alien-manager/tests/credentials_mint_bootstrap.rs b/crates/alien-manager/tests/credentials_mint_bootstrap.rs index 1b5c6bdff..134ed8541 100644 --- a/crates/alien-manager/tests/credentials_mint_bootstrap.rs +++ b/crates/alien-manager/tests/credentials_mint_bootstrap.rs @@ -192,6 +192,7 @@ async fn build() -> Fixture { registry_routing_table: Arc::new( RegistryRoutingTable::new(vec![]).expect("empty routing table is unambiguous"), ), + customer_registry_broker: None, import_registry: Arc::new(alien_infra::ImporterRegistry::built_in()), }; diff --git a/crates/alien-manager/tests/registry_proxy_cloud_test.rs b/crates/alien-manager/tests/registry_proxy_cloud_test.rs index ec6fe0ca6..de4b43001 100644 --- a/crates/alien-manager/tests/registry_proxy_cloud_test.rs +++ b/crates/alien-manager/tests/registry_proxy_cloud_test.rs @@ -14,16 +14,28 @@ //! Runs in `cloud-tests.yml` CI workflow. use std::collections::HashMap; +use std::io::Read as _; use std::net::TcpListener; use std::sync::{Arc, OnceLock}; use std::time::Duration; +use alien_aws_clients::ecr::{BatchDeleteImageRequest, EcrApi, EcrClient, ImageIdentifier}; +use alien_aws_clients::{AwsClientConfig, AwsCredentialProvider, AwsCredentials}; +use alien_bindings::BindingsProviderApi; use alien_core::{ DeploymentModel, DeploymentState, DeploymentStatus, Platform, ReadinessProbe, ReleaseInfo, Stack, StackSettings, Worker, WorkerCode, }; +use alien_gcp_clients::{ + ArtifactRegistryApi as GcpArtifactRegistryApi, ArtifactRegistryClient, GcpClientConfig, + GcpCredentials, +}; use alien_manager::auth::{Role, Scope, Subject, SubjectKind}; use alien_manager::config::ManagerConfig; +use alien_manager::routes::registry_proxy::{ + CustomerRegistryAccessError, CustomerRegistryBroker, CustomerRegistryOperation, + CustomerRegistryTarget, +}; use alien_manager::stores::sqlite::{ SqliteDatabase, SqliteDeploymentStore, SqliteReleaseStore, SqliteTokenStore, }; @@ -32,6 +44,8 @@ use alien_manager::traits::{ DeploymentStore, ReconcileData, ReleaseStore, TokenStore, TokenType, }; use alien_manager::AlienManagerBuilder; +use async_trait::async_trait; +use futures_util::FutureExt as _; use sha2::{Digest, Sha256}; use tracing::info; @@ -123,6 +137,7 @@ async fn build_test_image( registry_host: &str, repo_name: &str, tag: &str, + layer_size_mb: usize, ) -> (String, std::path::PathBuf) { let image_name = format!("{}/{}:{}", registry_host, repo_name, tag); let temp_dir = tempfile::tempdir().unwrap(); @@ -137,8 +152,7 @@ async fn build_test_image( uuid::Uuid::new_v4() ) .into_bytes(); - // Pad to ~400MB to reproduce large E2E image layers - large_content.resize(400 * 1024 * 1024, b'x'); + large_content.resize(layer_size_mb * 1024 * 1024, b'x'); let layer = dockdash::Layer::builder() .unwrap() .data("app/test.bin", &large_content, None) @@ -162,6 +176,56 @@ async fn build_test_image( (image_name, output_file) } +fn pushed_manifest_digest(path: &std::path::Path) -> String { + let file = std::fs::File::open(path).expect("OCI archive should exist"); + let mut archive = tar::Archive::new(file); + let mut files = HashMap::new(); + for entry in archive.entries().expect("OCI archive should be readable") { + let mut entry = entry.expect("OCI archive entry should be readable"); + let entry_path = entry + .path() + .expect("OCI entry path should be valid") + .to_string_lossy() + .to_string(); + let mut bytes = Vec::new(); + entry + .read_to_end(&mut bytes) + .expect("OCI archive entry should be readable"); + files.insert(entry_path, bytes); + } + let index: serde_json::Value = serde_json::from_slice( + files + .get("index.json") + .expect("OCI archive should contain index.json"), + ) + .expect("OCI index should be JSON"); + let descriptor_digest = index["manifests"][0]["digest"] + .as_str() + .expect("OCI index should identify its manifest digest"); + let manifest_path = descriptor_digest + .strip_prefix("sha256:") + .map(|digest| format!("blobs/sha256/{digest}")) + .expect("OCI manifest should use sha256"); + // dockdash parses the OCI-layout manifest into oci-client and serializes + // that typed value for the registry PUT. Reproduce that exact conversion + // instead of hashing source JSON whitespace or field order. + let manifest: oci_client::manifest::OciImageManifest = serde_json::from_slice( + files + .get(&manifest_path) + .expect("OCI archive should contain its manifest blob"), + ) + .expect("OCI manifest should match the client schema"); + let manifest = oci_client::manifest::OciManifest::Image(manifest); + let mut pushed_bytes = Vec::new(); + let mut serializer = serde_json::Serializer::with_formatter( + &mut pushed_bytes, + olpc_cjson::CanonicalFormatter::new(), + ); + serde::Serialize::serialize(&manifest, &mut serializer) + .expect("OCI manifest should serialize canonically"); + format!("sha256:{:x}", Sha256::digest(&pushed_bytes)) +} + // --------------------------------------------------------------------------- // Test harness: start manager + push + create deployment + pull // --------------------------------------------------------------------------- @@ -175,6 +239,59 @@ struct CloudProxyTest { _state_dir: tempfile::TempDir, } +struct StaticCustomerBroker { + authorization: String, + target: CustomerRegistryTarget, +} + +#[async_trait] +impl CustomerRegistryBroker for StaticCustomerBroker { + async fn authenticate_probe( + &self, + authorization: Option<&str>, + ) -> Result<(), CustomerRegistryAccessError> { + if authorization == Some(self.authorization.as_str()) { + Ok(()) + } else { + Err(CustomerRegistryAccessError::Unauthorized) + } + } + + async fn authorize( + &self, + authorization: Option<&str>, + repository: &str, + _operation: CustomerRegistryOperation, + ) -> Result { + self.authenticate_probe(authorization).await?; + if repository != self.target.external_repository { + return Err(CustomerRegistryAccessError::NotFound); + } + Ok(self.target.clone()) + } + + async fn resolve_signed_session( + &self, + repository: &str, + credential_id: &str, + ) -> Result { + if repository != self.target.external_repository + || credential_id != self.target.credential_id + { + return Err(CustomerRegistryAccessError::Denied); + } + Ok(self.target.clone()) + } + + async fn record_manifest_success( + &self, + _target: &CustomerRegistryTarget, + _operation: CustomerRegistryOperation, + _digest: &str, + ) { + } +} + impl CloudProxyTest { /// Start a manager with the given cloud binding and create test data. /// If `base_url_override` is set, the manager uses it as its public base URL @@ -191,6 +308,26 @@ impl CloudProxyTest { binding_json, env_vars, None::<(String, u16)>, + None, + ) + .await + } + + async fn start_customer( + platform: Platform, + binding_env_var: &str, + binding_json: &str, + env_vars: HashMap, + upstream_repository: String, + password: String, + ) -> Self { + Self::start_with_base_url( + platform, + binding_env_var, + binding_json, + env_vars, + None::<(String, u16)>, + Some((upstream_repository, password)), ) .await } @@ -201,6 +338,7 @@ impl CloudProxyTest { binding_json: &str, env_vars: HashMap, base_url_override: Option<(String, u16)>, + customer: Option<(String, String)>, ) -> Self { let port = base_url_override .as_ref() @@ -290,15 +428,39 @@ impl CloudProxyTest { }; let toml_config = alien_manager::standalone_config::ManagerTomlConfig::default(); - let server = AlienManagerBuilder::new(config) + let customer_broker = if let Some((upstream_repository, password)) = customer { + let artifact_registry = bindings_provider + .load_artifact_registry("artifacts") + .await + .expect("Failed to load customer ArtifactRegistry binding"); + Some(Arc::new(StaticCustomerBroker { + authorization: format!("Basic {}", base64_encode(&format!("alien:{password}"))), + target: CustomerRegistryTarget { + route_id: "cloudroute".to_string(), + credential_id: "cloudcredential".to_string(), + desired_revision: 1, + resource_revision: "cloud-resource-v1".to_string(), + logical_repository: "application".to_string(), + external_repository: "customer/cloudroute/application".to_string(), + upstream_repository, + artifact_registry, + admission: None, + }, + }) as Arc) + } else { + None + }; + + let mut builder = AlienManagerBuilder::new(config) .token_store(token_store.clone()) .bindings_provider(bindings_provider) .with_standalone_defaults(&toml_config) .await - .unwrap() - .build() - .await .unwrap(); + if let Some(broker) = customer_broker { + builder = builder.customer_registry_broker(broker); + } + let server = builder.build().await.unwrap(); let addr: std::net::SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); let server_handle = tokio::spawn(async move { @@ -385,7 +547,7 @@ impl CloudProxyTest { let manager_host = self.manager_url.trim_start_matches("http://"); // Build and push through proxy - let (image_name, _tar_path) = build_test_image(manager_host, repo_name, &tag).await; + let (image_name, _tar_path) = build_test_image(manager_host, repo_name, &tag, 400).await; let image = dockdash::Image::from_tarball(&_tar_path).unwrap(); image @@ -507,6 +669,151 @@ impl CloudProxyTest { info!(%repo_name, %tag, "Pull through proxy with deployment token succeeded"); } + + async fn customer_push_and_pull(&self, password: &str, tag: &str) { + let repository = "customer/cloudroute/application"; + println!("Customer registry test tag: {tag}"); + let manager_host = self.manager_url.trim_start_matches("http://"); + let client = reqwest::Client::new(); + let denied = client + .get(format!( + "{}/v2/{repository}/manifests/missing", + self.manager_url + )) + .basic_auth("alien", Some("wrong-password")) + .send() + .await + .expect("Denied customer request should complete"); + assert_eq!(denied.status(), reqwest::StatusCode::UNAUTHORIZED); + let wrong_route = client + .get(format!( + "{}/v2/customer/other-route/application/manifests/missing", + self.manager_url + )) + .basic_auth("alien", Some(password)) + .send() + .await + .expect("Wrong-route customer request should complete"); + assert_eq!(wrong_route.status(), reqwest::StatusCode::NOT_FOUND); + + let (image_name, tar_path) = build_test_image(manager_host, repository, &tag, 64).await; + let source_manifest_digest = pushed_manifest_digest(&tar_path); + let image = dockdash::Image::from_tarball(&tar_path).unwrap(); + image + .push( + &image_name, + &dockdash::PushOptions { + auth: dockdash::RegistryAuth::Basic("alien".to_string(), password.to_string()), + protocol: dockdash::ClientProtocol::Http, + ..Default::default() + }, + ) + .await + .expect("Customer push through proxy should succeed"); + + let response = client + .get(format!( + "{}/v2/{repository}/manifests/{tag}", + self.manager_url + )) + .basic_auth("alien", Some(password)) + .header( + "Accept", + "application/vnd.oci.image.manifest.v1+json, \ + application/vnd.docker.distribution.manifest.v2+json", + ) + .send() + .await + .expect("Customer manifest request should complete"); + assert!( + response.status().is_success(), + "Customer manifest pull should succeed: {}", + response.status() + ); + let advertised_digest = response + .headers() + .get("docker-content-digest") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let manifest = response.bytes().await.unwrap(); + let computed_digest = format!("sha256:{:x}", Sha256::digest(&manifest)); + assert_eq!(source_manifest_digest, computed_digest); + if let Some(advertised_digest) = advertised_digest { + assert_eq!(advertised_digest, computed_digest); + } + serde_json::from_slice::(&manifest) + .expect("Pulled customer manifest should be valid JSON"); + info!(%repository, %tag, %computed_digest, "Customer push and pull succeeded"); + } +} + +async fn delete_ecr_test_image( + region: String, + access_key: String, + secret_key: String, + account_id: String, + repository: &str, + tag: &str, +) { + let config = AwsClientConfig { + account_id, + region, + credentials: AwsCredentials::AccessKeys { + access_key_id: access_key, + secret_access_key: secret_key, + session_token: None, + }, + service_overrides: None, + }; + let client = EcrClient::new( + reqwest::Client::new(), + AwsCredentialProvider::from_config_sync(config), + ); + let response = client + .batch_delete_image( + BatchDeleteImageRequest::builder() + .repository_name(repository.to_string()) + .image_ids(vec![ImageIdentifier { + image_tag: Some(tag.to_string()), + image_digest: None, + }]) + .build(), + ) + .await + .expect("AWS customer registry test image should be deleted"); + assert!( + response.failures.is_empty(), + "AWS customer registry cleanup failed: {:?}", + response.failures + ); +} + +async fn delete_gar_test_package( + service_account_key: String, + project_id: String, + region: String, + repository: &str, + package: &str, +) { + let config = GcpClientConfig { + project_id: project_id.clone(), + region: region.clone(), + credentials: GcpCredentials::ServiceAccountKey { + json: service_account_key, + }, + service_overrides: None, + project_number: None, + }; + let client = ArtifactRegistryClient::new(reqwest::Client::new(), config); + client + .delete_package( + project_id, + region, + repository.to_string(), + package.to_string(), + ) + .await + .expect("GCP customer registry test package deletion should start"); } fn base64_encode(input: &str) -> String { @@ -556,6 +863,58 @@ async fn test_proxy_push_pull_ecr() { test.push_and_pull("alien-e2e", &db_path).await; } +#[tokio::test] +async fn test_customer_proxy_push_pull_ecr() { + let _guard = cloud_proxy_env_lock().await; + load_test_env(); + + let region = require_env!("AWS_MANAGEMENT_REGION"); + let access_key = require_env!("AWS_MANAGEMENT_ACCESS_KEY_ID"); + let secret_key = require_env!("AWS_MANAGEMENT_SECRET_ACCESS_KEY"); + let account_id = require_env!("AWS_MANAGEMENT_ACCOUNT_ID"); + let push_role = require_env!("E2E_AWS_AR_PUSH_ROLE_ARN"); + let pull_role = require_env!("E2E_AWS_AR_PULL_ROLE_ARN"); + let binding = serde_json::json!({ + "service": "ecr", + "repositoryPrefix": "alien-e2e", + "pullRoleArn": pull_role, + "pushRoleArn": push_role, + }); + let env_vars = HashMap::from([ + ("AWS_REGION".into(), region), + ("AWS_ACCESS_KEY_ID".into(), access_key), + ("AWS_SECRET_ACCESS_KEY".into(), secret_key), + ("AWS_ACCOUNT_ID".into(), account_id), + ("ALIEN_DEPLOYMENT_TYPE".into(), "aws".into()), + ]); + let password = format!("registry-{}", uuid::Uuid::new_v4()); + let tag = format!("customer-cloud-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let test = CloudProxyTest::start_customer( + Platform::Aws, + "ALIEN_AWS_ARTIFACTS_BINDING", + &binding.to_string(), + env_vars, + "alien-e2e".to_string(), + password.clone(), + ) + .await; + let result = std::panic::AssertUnwindSafe(test.customer_push_and_pull(&password, &tag)) + .catch_unwind() + .await; + delete_ecr_test_image( + require_env!("AWS_MANAGEMENT_REGION"), + require_env!("AWS_MANAGEMENT_ACCESS_KEY_ID"), + require_env!("AWS_MANAGEMENT_SECRET_ACCESS_KEY"), + require_env!("AWS_MANAGEMENT_ACCOUNT_ID"), + "alien-e2e", + &tag, + ) + .await; + if let Err(payload) = result { + std::panic::resume_unwind(payload); + } +} + // --------------------------------------------------------------------------- // GCP GAR // --------------------------------------------------------------------------- @@ -605,6 +964,57 @@ async fn test_proxy_push_pull_gar() { test.push_and_pull(&repo_name, &db_path).await; } +#[tokio::test] +async fn test_customer_proxy_push_pull_gar() { + let _guard = cloud_proxy_env_lock().await; + load_test_env(); + + let sa_key = require_env!("GOOGLE_MANAGEMENT_SERVICE_ACCOUNT_KEY"); + let project_id = require_env!("GOOGLE_MANAGEMENT_PROJECT_ID"); + let region = require_env!("GOOGLE_MANAGEMENT_REGION"); + let gar_repo_url = require_env!("E2E_GCP_GAR_REPOSITORY"); + let push_sa = require_env!("E2E_GCP_AR_PUSH_SA_EMAIL"); + let pull_sa = require_env!("E2E_GCP_AR_PULL_SA_EMAIL"); + let gar_repo_name = gar_repo_url.rsplit('/').next().unwrap_or("alien-e2e"); + let package = format!("customer-cloud-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let binding = serde_json::json!({ + "service": "gar", + "repositoryName": gar_repo_name, + "pullServiceAccountEmail": pull_sa, + "pushServiceAccountEmail": push_sa, + }); + let env_vars = HashMap::from([ + ("GOOGLE_SERVICE_ACCOUNT_KEY".into(), sa_key), + ("GCP_PROJECT_ID".into(), project_id.clone()), + ("GCP_REGION".into(), region), + ("ALIEN_DEPLOYMENT_TYPE".into(), "gcp".into()), + ]); + let password = format!("registry-{}", uuid::Uuid::new_v4()); + let test = CloudProxyTest::start_customer( + Platform::Gcp, + "ALIEN_GCP_ARTIFACTS_BINDING", + &binding.to_string(), + env_vars, + format!("{project_id}/{gar_repo_name}/{package}"), + password.clone(), + ) + .await; + let result = std::panic::AssertUnwindSafe(test.customer_push_and_pull(&password, "verified")) + .catch_unwind() + .await; + delete_gar_test_package( + require_env!("GOOGLE_MANAGEMENT_SERVICE_ACCOUNT_KEY"), + require_env!("GOOGLE_MANAGEMENT_PROJECT_ID"), + require_env!("GOOGLE_MANAGEMENT_REGION"), + gar_repo_name, + &package, + ) + .await; + if let Err(payload) = result { + std::panic::resume_unwind(payload); + } +} + /// Same as test_proxy_push_pull_gar but pushes through an ngrok HTTPS tunnel. /// The manager's base_url is the ngrok URL, so Location header rewrites point /// back through ngrok — reproducing the E2E flow exactly. @@ -682,12 +1092,13 @@ async fn test_proxy_push_gar_via_ngrok() { &binding.to_string(), env_vars, Some((ngrok_url.clone(), port)), + None, ) .await; let repo_name = format!("{}/{}/default", project_id, gar_repo_name); let tag = format!("ngrok-{}", &uuid::Uuid::new_v4().to_string()[..8]); - let (image_name, tar_path) = build_test_image(ngrok_host, &repo_name, &tag).await; + let (image_name, tar_path) = build_test_image(ngrok_host, &repo_name, &tag, 400).await; let image = dockdash::Image::from_tarball(&tar_path).unwrap(); image diff --git a/crates/alien-manager/tests/stack_import.rs b/crates/alien-manager/tests/stack_import.rs index 726ebd5b4..7581edb44 100644 --- a/crates/alien-manager/tests/stack_import.rs +++ b/crates/alien-manager/tests/stack_import.rs @@ -192,6 +192,7 @@ async fn make_fixture_for_platform(platform: Platform, seeded_stack: Option>(); + for required in [ + "ecr:GetAuthorizationToken", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + "ecr:PutImage", + ] { + assert!( + aws_actions.contains(&required), + "missing AWS OCI action {required}" + ); + } + for forbidden in [ + "ecr:CreateRepository", + "ecr:DeleteRepository", + "ecr:DeleteRepositoryPolicy", + "ecr:SetRepositoryPolicy", + "sts:AssumeRole", + ] { + assert!( + !aws_actions.contains(&forbidden), + "remote AWS data access must not grant {forbidden}" + ); + } + + let gcp_entries = permission.platforms.gcp.as_ref().expect("GCP grants"); + let gcp_permissions = gcp_entries + .iter() + .flat_map(|entry| entry.grant.permissions.iter().flatten().map(String::as_str)) + .collect::>(); + for required in [ + "artifactregistry.repositories.downloadArtifacts", + "artifactregistry.repositories.uploadArtifacts", + ] { + assert!( + gcp_permissions.contains(&required), + "missing GCP OCI permission {required}" + ); + } + for forbidden in [ + "artifactregistry.repositories.create", + "artifactregistry.repositories.delete", + "artifactregistry.versions.delete", + "iam.serviceAccounts.actAs", + "iam.serviceAccounts.getAccessToken", + ] { + assert!( + !gcp_permissions.contains(&forbidden), + "remote GCP data access must not grant {forbidden}" + ); + } + assert!( + gcp_entries + .iter() + .all(|entry| entry.grant.predefined_roles.is_none()), + "a custom role avoids the broader, mutable Artifact Registry Writer role" + ); + + let azure_entries = permission.platforms.azure.as_ref().expect("Azure grants"); + let roles = azure_entries + .iter() + .flat_map(|entry| { + entry + .grant + .predefined_roles + .iter() + .flatten() + .map(String::as_str) + }) + .collect::>(); + assert_eq!(roles, vec!["AcrPush"]); + assert!(!roles.contains(&"AcrDelete")); +} + +#[test] +fn ordinary_push_no_longer_grants_repository_lifecycle() { + let permission = get_permission_set("artifact-registry/push").expect("push permission exists"); + let serialized = serde_json::to_string(permission).expect("serialize permission set"); + for forbidden in [ + "ecr:CreateRepository", + "ecr:DeleteRepository", + "artifactregistry.repositories.create", + "artifactregistry.repositories.delete", + "artifactregistry.versions.delete", + ] { + assert!( + !serialized.contains(forbidden), + "artifact-registry/push must not grant {forbidden}" + ); + } +} diff --git a/crates/alien-preflights/src/mutations/remote_bindings.rs b/crates/alien-preflights/src/mutations/remote_bindings.rs index 21d687da7..3c2005ce7 100644 --- a/crates/alien-preflights/src/mutations/remote_bindings.rs +++ b/crates/alien-preflights/src/mutations/remote_bindings.rs @@ -2,8 +2,8 @@ use crate::{error::ErrorData, error::Result, StackMutation}; use alien_core::{ - Ai, DeploymentConfig, Key, Platform, RemoteBindingGrant, RemoteBindings, ResourceEntry, - ResourceLifecycle, Stack, StackState, + Ai, ArtifactRegistry, DeploymentConfig, Key, Platform, RemoteBindingGrant, RemoteBindings, + ResourceEntry, ResourceLifecycle, Stack, StackState, }; use alien_error::AlienError; use async_trait::async_trait; @@ -100,7 +100,9 @@ fn validate_isolated_remote_resource(stack: &Stack, mutation_name: &str) -> Resu .filter(|(_, entry)| { matches!( entry.config.resource_type(), - resource_type if resource_type == Key::RESOURCE_TYPE || resource_type == Ai::RESOURCE_TYPE + resource_type if resource_type == Key::RESOURCE_TYPE + || resource_type == Ai::RESOURCE_TYPE + || resource_type == ArtifactRegistry::RESOURCE_TYPE ) }) .collect::>(); @@ -127,8 +129,8 @@ fn validate_isolated_remote_resource(stack: &Stack, mutation_name: &str) -> Resu mod tests { use super::*; use alien_core::{ - Ai, EnvironmentVariablesSnapshot, ExternalBindings, Key, ManagementConfig, StackSettings, - Storage, + Ai, ArtifactRegistry, EnvironmentVariablesSnapshot, ExternalBindings, Key, + ManagementConfig, StackSettings, Storage, }; fn config() -> DeploymentConfig { @@ -334,4 +336,57 @@ mod tests { assert_eq!(error.code, "STACK_MUTATION_FAILED"); assert!(error.to_string().contains("only remoteAccess resource")); } + + #[tokio::test] + async fn remote_artifact_registry_gets_only_data_access_and_allows_non_remote_siblings() { + let stack = Stack::new("application".to_string()) + .add_with_remote_access( + ArtifactRegistry::new("images".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add( + Storage::new("internal".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .build(); + + let mutated = RemoteBindingsMutation + .mutate(stack, &StackState::new(Platform::Test), &config()) + .await + .expect("non-remote siblings are allowed beside a remote registry"); + let bindings = mutated + .resources + .get(REMOTE_BINDINGS_ID) + .and_then(|entry| entry.config.downcast_ref::()) + .expect("Remote Bindings config"); + + assert_eq!(bindings.grants.len(), 1); + assert_eq!(bindings.grants[0].resource_id, "images"); + assert_eq!( + bindings.grants[0].permission_set, + "artifact-registry/remote-read-write" + ); + } + + #[tokio::test] + async fn remote_artifact_registry_rejects_another_published_resource() { + let stack = Stack::new("application".to_string()) + .add_with_remote_access( + ArtifactRegistry::new("images".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add_with_remote_access( + Storage::new("exports".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .build(); + + let error = RemoteBindingsMutation + .mutate(stack, &StackState::new(Platform::Test), &config()) + .await + .expect_err("remote registry must reject another remotely published resource"); + + assert_eq!(error.code, "STACK_MUTATION_FAILED"); + assert!(error.to_string().contains("only remoteAccess resource")); + } } diff --git a/crates/alien-terraform/src/emitters/aws/artifact_registry.rs b/crates/alien-terraform/src/emitters/aws/artifact_registry.rs index 37bab46e2..0707510b2 100644 --- a/crates/alien-terraform/src/emitters/aws/artifact_registry.rs +++ b/crates/alien-terraform/src/emitters/aws/artifact_registry.rs @@ -4,12 +4,17 @@ use crate::{ block::{attr, nested, resource_block}, emitter::{TfEmitter, TfFragment}, emitters::aws::helpers::{ - downcast, iam_role_name_template, jsonencode, nested_block, required_label, + aws_terraform_permission_context, downcast, emit_iam_role_policy_for_target_with_label, + iam_policy_name_sanitize, iam_role_name_template, jsonencode, nested_block, required_label, resource_prefix_template, tags, }, expr, }; -use alien_core::{import::EmitContext, ArtifactRegistry, Result, ServiceAccount}; +use alien_core::{ + import::EmitContext, ArtifactRegistry, PermissionSetReference, RemoteBindings, Result, + ServiceAccount, +}; +use alien_permissions::BindingTarget; use hcl::expr::Expression; #[derive(Debug, Clone, Copy, Default)] @@ -77,6 +82,7 @@ impl TfEmitter for AwsArtifactRegistryEmitter { fragment .resource_blocks .push(ecr_role_policy(label, &push_label, /* push */ true)); + emit_remote_access_policy(ctx, &mut fragment, label)?; Ok(fragment) } @@ -170,8 +176,6 @@ fn ecr_role_policy(repo_label: &str, role_label: &str, push: bool) -> hcl::struc if push { for action in [ "ecr:CompleteLayerUpload", - "ecr:CreateRepository", - "ecr:DeleteRepository", "ecr:InitiateLayerUpload", "ecr:PutImage", "ecr:UploadLayerPart", @@ -229,6 +233,45 @@ fn ecr_role_policy(repo_label: &str, role_label: &str, push: bool) -> hcl::struc ) } +fn emit_remote_access_policy( + ctx: &EmitContext<'_>, + fragment: &mut TfFragment, + repository_label: &str, +) -> Result<()> { + let Some(definition) = alien_core::remote_bindings::remote_binding_for_entry(ctx.resource) + else { + return Ok(()); + }; + let Some(access_label) = ctx.stack.resources().find_map(|(id, entry)| { + (entry.config.resource_type() == RemoteBindings::RESOURCE_TYPE) + .then(|| ctx.name_for(id)) + .flatten() + }) else { + return Ok(()); + }; + let permission = PermissionSetReference::from_name(definition.permission_set); + let Some(permission_set) = + permission.resolve(|name| alien_permissions::get_permission_set(name).cloned()) + else { + return Ok(()); + }; + let context = aws_terraform_permission_context() + .with_resource_name(format!("${{aws_ecr_repository.{repository_label}.name}}")); + emit_iam_role_policy_for_target_with_label( + fragment, + access_label, + &permission_set, + &format!("{access_label}_{repository_label}_remote_registry"), + &format!( + "access-{}-{}", + ctx.resource_id, + iam_policy_name_sanitize(&permission_set.id) + ), + &context, + BindingTarget::Resource, + ) +} + fn trust_principals(ctx: &EmitContext<'_>) -> Vec { let mut principals = Vec::new(); for (id, entry) in ctx.stack.resources() { diff --git a/crates/alien-terraform/src/emitters/aws/remote_stack_management.rs b/crates/alien-terraform/src/emitters/aws/remote_stack_management.rs index 6e47e23bf..74de4c949 100644 --- a/crates/alien-terraform/src/emitters/aws/remote_stack_management.rs +++ b/crates/alien-terraform/src/emitters/aws/remote_stack_management.rs @@ -256,7 +256,12 @@ fn resource_scoped_aws_permission_context( .with_resource_id(resource_id.to_string()); context.resource_name = None; - if resource_entry.config.downcast_ref::().is_some() { + if resource_entry.config.downcast_ref::().is_some() + || resource_entry + .config + .downcast_ref::() + .is_some() + { return context.with_resource_name(format!("${{local.resource_prefix}}-{resource_id}")); } @@ -280,7 +285,9 @@ fn kubernetes_cluster_name(cluster: &KubernetesCluster) -> String { #[cfg(test)] mod tests { use super::*; - use alien_core::{Resource, ResourceEntry, ResourceLifecycle, Storage, Worker, WorkerCode}; + use alien_core::{ + ArtifactRegistry, Resource, ResourceEntry, ResourceLifecycle, Storage, Worker, WorkerCode, + }; fn live_worker_entry(id: &str) -> ResourceEntry { ResourceEntry { @@ -333,4 +340,24 @@ mod tests { assert_eq!(context.resource_id.as_deref(), Some("uploads")); assert_eq!(context.resource_name, None); } + + #[test] + fn aws_remote_management_names_artifact_registry_child_prefix() { + let entry = ResourceEntry { + enabled_when: None, + config: Resource::new(ArtifactRegistry::new("images".to_string()).build()), + lifecycle: ResourceLifecycle::Frozen, + dependencies: Vec::new(), + remote_access: true, + }; + let context = resource_scoped_aws_permission_context( + "images", + &entry, + &aws_terraform_permission_context(), + ); + assert_eq!( + context.resource_name.as_deref(), + Some("${local.resource_prefix}-images") + ); + } } diff --git a/crates/alien-terraform/src/emitters/azure/artifact_registry.rs b/crates/alien-terraform/src/emitters/azure/artifact_registry.rs index 1eb335ad6..d51d84e9d 100644 --- a/crates/alien-terraform/src/emitters/azure/artifact_registry.rs +++ b/crates/alien-terraform/src/emitters/azure/artifact_registry.rs @@ -18,7 +18,7 @@ use crate::{ emitters::azure::helpers::{downcast, required_label, resource_prefix_template, tags}, expr, }; -use alien_core::{import::EmitContext, ArtifactRegistry, Result}; +use alien_core::{import::EmitContext, ArtifactRegistry, RemoteBindings, Result}; use hcl::expr::Expression; #[derive(Debug, Clone, Copy, Default)] @@ -101,6 +101,44 @@ impl TfEmitter for AzureArtifactRegistryEmitter { )); } + if alien_core::remote_bindings::remote_binding_for_entry(ctx.resource).is_some() { + if let Some(access_label) = ctx.stack.resources().find_map(|(id, entry)| { + (entry.config.resource_type() == RemoteBindings::RESOURCE_TYPE) + .then(|| ctx.name_for(id)) + .flatten() + }) { + fragment.resource_blocks.push(resource_block( + "azurerm_role_assignment", + &format!("{label}_remote_access_role"), + [ + attr( + "name", + expr::raw(format!( + "uuidv5(\"dns\", \"${{local.resource_prefix}}-{}-remote-access-${{azurerm_user_assigned_identity.{access_label}.principal_id}}\")", + registry.id() + )), + ), + attr( + "scope", + expr::traversal(["azurerm_container_registry", label, "id"]), + ), + attr( + "role_definition_name", + Expression::String("AcrPush".to_string()), + ), + attr( + "principal_id", + expr::traversal([ + "azurerm_user_assigned_identity", + access_label, + "principal_id", + ]), + ), + ], + )); + } + } + Ok(fragment) } diff --git a/crates/alien-terraform/src/emitters/gcp/artifact_registry.rs b/crates/alien-terraform/src/emitters/gcp/artifact_registry.rs index 90449a8c9..d5b8a1c00 100644 --- a/crates/alien-terraform/src/emitters/gcp/artifact_registry.rs +++ b/crates/alien-terraform/src/emitters/gcp/artifact_registry.rs @@ -18,7 +18,7 @@ use crate::{ }; use alien_core::{ import::EmitContext, ArtifactRegistry, ErrorData, PermissionProfile, PermissionSet, - PermissionSetReference, RemoteStackManagement, Result, ServiceAccount, + PermissionSetReference, RemoteBindings, RemoteStackManagement, Result, ServiceAccount, }; use alien_error::AlienError; use alien_permissions::{ @@ -115,6 +115,7 @@ impl TfEmitter for GcpArtifactRegistryEmitter { emit_management_repository_bindings(ctx, &mut fragment, label)?; emit_service_account_repository_bindings(ctx, &mut fragment, label)?; + emit_remote_access_repository_bindings(ctx, &mut fragment, label)?; Ok(fragment) } @@ -173,6 +174,38 @@ impl TfEmitter for GcpArtifactRegistryEmitter { } } +fn emit_remote_access_repository_bindings( + ctx: &EmitContext<'_>, + fragment: &mut TfFragment, + registry_label: &str, +) -> Result<()> { + let Some(definition) = alien_core::remote_bindings::remote_binding_for_entry(ctx.resource) + else { + return Ok(()); + }; + let Some(access_label) = ctx.stack.resources().find_map(|(id, entry)| { + (entry.config.resource_type() == RemoteBindings::RESOURCE_TYPE) + .then(|| ctx.name_for(id)) + .flatten() + }) else { + return Ok(()); + }; + let permission = PermissionSetReference::from_name(definition.permission_set); + let Some(permission_set) = + permission.resolve(|name| alien_permissions::get_permission_set(name).cloned()) + else { + return Ok(()); + }; + emit_repository_bindings_for_member( + fragment, + registry_label, + access_label, + ctx.stack.id(), + "remote_access", + &permission_set, + ) +} + fn emit_management_repository_bindings( ctx: &EmitContext<'_>, fragment: &mut TfFragment, diff --git a/crates/alien-terraform/tests/generator/aws_compute_tests.rs b/crates/alien-terraform/tests/generator/aws_compute_tests.rs index 9b75f4b5b..113f9ba1a 100644 --- a/crates/alien-terraform/tests/generator/aws_compute_tests.rs +++ b/crates/alien-terraform/tests/generator/aws_compute_tests.rs @@ -7,8 +7,8 @@ use super::helpers::{assert_terraform_valid, render, snapshot_module}; use alien_core::{ - ArtifactRegistry, Build, CapacityGroup, ComputeCluster, ErrorData, Platform, ResourceLifecycle, - Stack, StackSettings, Worker, WorkerCode, + ArtifactRegistry, Build, CapacityGroup, ComputeCluster, ErrorData, Platform, RemoteBindings, + ResourceLifecycle, ResourceRef, Stack, StackSettings, Worker, WorkerCode, }; use alien_terraform::{generate_terraform_module, TerraformOptions, TerraformTarget, TfRegistry}; @@ -25,6 +25,34 @@ fn aws_artifact_registry_renders_ecr_repository() { assert_terraform_valid(&module, "aws_artifact_registry"); } +#[test] +fn aws_remote_artifact_registry_has_data_access_without_repository_lifecycle() { + let mut stack = Stack::new("remote-registry".to_string()) + .add_with_remote_access( + ArtifactRegistry::new("registry".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add( + RemoteBindings::new("access".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .build(); + stack.resources.get_mut("registry").unwrap().dependencies = + vec![ResourceRef::new(RemoteBindings::RESOURCE_TYPE, "access")]; + let module = render(&stack, TerraformTarget::Aws, StackSettings::default()); + let rendered = module + .files + .values() + .cloned() + .collect::>() + .join("\n"); + assert!(rendered.contains("ecr:GetAuthorizationToken")); + assert!(rendered.contains("ecr:PutImage")); + assert!(!rendered.contains("ecr:CreateRepository")); + assert!(!rendered.contains("ecr:DeleteRepository")); + assert_terraform_valid(&module, "aws_remote_artifact_registry"); +} + #[test] fn aws_build_renders_codebuild_project() { let stack = Stack::new("acme-build".to_string()) diff --git a/crates/alien-terraform/tests/generator/azure_compute_tests.rs b/crates/alien-terraform/tests/generator/azure_compute_tests.rs index 132ab828d..05d752f51 100644 --- a/crates/alien-terraform/tests/generator/azure_compute_tests.rs +++ b/crates/alien-terraform/tests/generator/azure_compute_tests.rs @@ -7,8 +7,8 @@ use super::helpers::{assert_terraform_valid, render, snapshot_module}; use alien_core::{ ArtifactRegistry, AzureContainerAppsEnvironment, AzureResourceGroup, Build, - ComputePoolSelection, ComputeSettings, ResourceLifecycle, Stack, StackSettings, Worker, - WorkerCode, + ComputePoolSelection, ComputeSettings, RemoteBindings, ResourceLifecycle, ResourceRef, Stack, + StackSettings, Worker, WorkerCode, }; use alien_core::{ContainerAppsEnvironmentBinding, ExternalBinding, ExternalBindings}; use alien_terraform::TerraformTarget; @@ -35,6 +35,34 @@ fn azure_artifact_registry_renders_premium_acr_with_pull_push_uami() { assert_terraform_valid(&module, "azure_artifact_registry"); } +#[test] +fn azure_remote_artifact_registry_grants_access_identity_acr_push() { + let mut stack = Stack::new("remote-registry".to_string()) + .add(resource_group(), ResourceLifecycle::Frozen) + .add_with_remote_access( + ArtifactRegistry::new("registry".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add( + RemoteBindings::new("access".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .build(); + stack.resources.get_mut("registry").unwrap().dependencies = + vec![ResourceRef::new(RemoteBindings::RESOURCE_TYPE, "access")]; + let module = render(&stack, TerraformTarget::Azure, StackSettings::default()); + let rendered = module + .files + .values() + .cloned() + .collect::>() + .join("\n"); + assert!(rendered.contains("registry_remote_access_role")); + assert!(rendered.contains("role_definition_name = \"AcrPush\"")); + assert!(!rendered.contains("AcrDelete")); + assert_terraform_valid(&module, "azure_remote_artifact_registry"); +} + #[test] fn azure_build_renders_acr_task() { let stack = Stack::new("acme-build".to_string()) diff --git a/crates/alien-terraform/tests/generator/gcp_compute_tests.rs b/crates/alien-terraform/tests/generator/gcp_compute_tests.rs index 5e7e67294..a0cff4c16 100644 --- a/crates/alien-terraform/tests/generator/gcp_compute_tests.rs +++ b/crates/alien-terraform/tests/generator/gcp_compute_tests.rs @@ -8,8 +8,8 @@ use super::helpers::{assert_terraform_valid, render, snapshot_module}; use alien_core::{ ArtifactRegistry, Build, CapacityGroup, ComputeCluster, ErrorData, Platform, Queue, - ResourceLifecycle, ServiceAccount, Stack, StackSettings, Storage, Worker, WorkerCode, - WorkerTrigger, + RemoteBindings, ResourceLifecycle, ResourceRef, ServiceAccount, Stack, StackSettings, Storage, + Worker, WorkerCode, WorkerTrigger, }; use alien_terraform::{generate_terraform_module, TerraformOptions, TerraformTarget, TfRegistry}; @@ -26,6 +26,33 @@ fn gcp_artifact_registry_renders_docker_repository() { assert_terraform_valid(&module, "gcp_artifact_registry"); } +#[test] +fn gcp_remote_artifact_registry_has_repository_scoped_data_access() { + let mut stack = Stack::new("remote-registry".to_string()) + .add_with_remote_access( + ArtifactRegistry::new("registry".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .add( + RemoteBindings::new("access".to_string()).build(), + ResourceLifecycle::Frozen, + ) + .build(); + stack.resources.get_mut("registry").unwrap().dependencies = + vec![ResourceRef::new(RemoteBindings::RESOURCE_TYPE, "access")]; + let module = render(&stack, TerraformTarget::Gcp, StackSettings::default()); + let rendered = module + .files + .values() + .cloned() + .collect::>() + .join("\n"); + assert!(rendered.contains("artifactregistry.repositories.uploadArtifacts")); + assert!(rendered.contains("remote_access")); + assert!(!rendered.contains("artifactregistry.repositories.delete")); + assert_terraform_valid(&module, "gcp_remote_artifact_registry"); +} + #[test] fn gcp_build_renders_cloud_build_trigger() { let stack = Stack::new("acme-build".to_string()) diff --git a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_artifact_registry.snap b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_artifact_registry.snap index 49cddcf0d..e8269bc8f 100644 --- a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_artifact_registry.snap +++ b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_artifact_registry.snap @@ -226,7 +226,7 @@ resource "aws_iam_role_policy" "registry_pull_policy" { resource "aws_iam_role_policy" "registry_push_policy" { name = "ecr-push-pull" role = aws_iam_role.registry_push.id - policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "GetAuthorizationToken", Effect = "Allow", Action = "ecr:GetAuthorizationToken", Resource = "*" }, { Sid = "RepositoryAccess", Effect = "Allow", Action = ["ecr:BatchCheckLayerAvailability", "ecr:BatchGetImage", "ecr:DescribeImages", "ecr:DescribeRepositories", "ecr:GetDownloadUrlForLayer", "ecr:ListImages", "ecr:CompleteLayerUpload", "ecr:CreateRepository", "ecr:DeleteRepository", "ecr:InitiateLayerUpload", "ecr:PutImage", "ecr:UploadLayerPart"], Resource = [aws_ecr_repository.registry.arn, format("%s-*", aws_ecr_repository.registry.arn)] }] }) + policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "GetAuthorizationToken", Effect = "Allow", Action = "ecr:GetAuthorizationToken", Resource = "*" }, { Sid = "RepositoryAccess", Effect = "Allow", Action = ["ecr:BatchCheckLayerAvailability", "ecr:BatchGetImage", "ecr:DescribeImages", "ecr:DescribeRepositories", "ecr:GetDownloadUrlForLayer", "ecr:ListImages", "ecr:CompleteLayerUpload", "ecr:InitiateLayerUpload", "ecr:PutImage", "ecr:UploadLayerPart"], Resource = [aws_ecr_repository.registry.arn, format("%s-*", aws_ecr_repository.registry.arn)] }] }) } === registration.tf === diff --git a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_full_stack.snap b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_full_stack.snap index 06eb859c3..5954e9561 100644 --- a/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_full_stack.snap +++ b/crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__aws_full_stack.snap @@ -678,7 +678,7 @@ resource "aws_iam_role_policy" "registry_pull_policy" { resource "aws_iam_role_policy" "registry_push_policy" { name = "ecr-push-pull" role = aws_iam_role.registry_push.id - policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "GetAuthorizationToken", Effect = "Allow", Action = "ecr:GetAuthorizationToken", Resource = "*" }, { Sid = "RepositoryAccess", Effect = "Allow", Action = ["ecr:BatchCheckLayerAvailability", "ecr:BatchGetImage", "ecr:DescribeImages", "ecr:DescribeRepositories", "ecr:GetDownloadUrlForLayer", "ecr:ListImages", "ecr:CompleteLayerUpload", "ecr:CreateRepository", "ecr:DeleteRepository", "ecr:InitiateLayerUpload", "ecr:PutImage", "ecr:UploadLayerPart"], Resource = [aws_ecr_repository.registry.arn, format("%s-*", aws_ecr_repository.registry.arn)] }] }) + policy = jsonencode({ Version = "2012-10-17", Statement = [{ Sid = "GetAuthorizationToken", Effect = "Allow", Action = "ecr:GetAuthorizationToken", Resource = "*" }, { Sid = "RepositoryAccess", Effect = "Allow", Action = ["ecr:BatchCheckLayerAvailability", "ecr:BatchGetImage", "ecr:DescribeImages", "ecr:DescribeRepositories", "ecr:GetDownloadUrlForLayer", "ecr:ListImages", "ecr:CompleteLayerUpload", "ecr:InitiateLayerUpload", "ecr:PutImage", "ecr:UploadLayerPart"], Resource = [aws_ecr_repository.registry.arn, format("%s-*", aws_ecr_repository.registry.arn)] }] }) } === registration.tf ===