diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2e7d49..9d23d99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ env: EXPECTED_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} ZED_INTERFACES_SHA: 996fc56e20c4bb41366377be14edfae886b84e82 - ZED_LIB_CORE_SHA: c3d486a1519381276fbec02aa25247f542924443 + ZED_LIB_CORE_SHA: 43b87769032c993f424e89825ad22abe980f4652 jobs: test: diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml index 236beef..29dac74 100644 --- a/.github/workflows/publish-container.yml +++ b/.github/workflows/publish-container.yml @@ -57,7 +57,7 @@ env: # Keep these synchronized with the Rust CI pins; source-graph drift previously # made the debug test build pass while the release container could not compile. ZED_INTERFACES_SHA: 996fc56e20c4bb41366377be14edfae886b84e82 - ZED_LIB_CORE_SHA: c3d486a1519381276fbec02aa25247f542924443 + ZED_LIB_CORE_SHA: 43b87769032c993f424e89825ad22abe980f4652 jobs: image: diff --git a/.github/workflows/zed-contract-parity.yml b/.github/workflows/zed-contract-parity.yml index cc690cb..92b6d20 100644 --- a/.github/workflows/zed-contract-parity.yml +++ b/.github/workflows/zed-contract-parity.yml @@ -173,7 +173,7 @@ jobs: test -s "$instance_dir/current.json" - name: Admit independent TypeSpec and Draft 2020-12 authorities with TJSV - uses: ORESoftware/typespec-json-schema-validator@8bf7ebda4e35c0d11b5b5e5f865867bee4fc3085 + uses: ORESoftware/typespec-json-schema-validator@bd503465dab8c5148fee722b443ed04ff126c9bf with: typespec: contracts/toolchain/main.tsp schema: contracts/toolchain/authored.schema.json diff --git a/.zpkg.toml b/.zpkg.toml index 0b4e4f5..b0452df 100644 --- a/.zpkg.toml +++ b/.zpkg.toml @@ -1,7 +1,7 @@ [package] org = "zed-pkg" name = "zed-api-server" -version = "0.1.1" +version = "0.1.2" description = "Zed registry and account API server with PostgreSQL metadata and S3/R2 artifact storage" license = "MIT" keywords = ["zed-pkg", "package-manager", "registry", "postgres", "r2"] diff --git a/Cargo.lock b/Cargo.lock index da7c904..018efff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5388,7 +5388,7 @@ dependencies = [ [[package]] name = "zed-api-server" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "aws-config", @@ -5462,7 +5462,7 @@ dependencies = [ [[package]] name = "zed-orm-core" version = "0.1.0" -source = "git+https://github.com/zed-pkg/zed-lib-core.git?rev=c3d486a1519381276fbec02aa25247f542924443#c3d486a1519381276fbec02aa25247f542924443" +source = "git+https://github.com/zed-pkg/zed-lib-core.git?rev=43b87769032c993f424e89825ad22abe980f4652#43b87769032c993f424e89825ad22abe980f4652" dependencies = [ "chrono", "sea-orm", diff --git a/Cargo.toml b/Cargo.toml index e421dec..a52e3ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed-api-server" -version = "0.1.1" +version = "0.1.2" edition = "2024" description = "zed-pkg registry REST API: Postgres metadata, S3/R2 artifacts" license = "MIT" @@ -22,7 +22,7 @@ zed-interfaces = { git = "https://github.com/zed-pkg/zed-interfaces.git", rev = # Canonical account, registry projection, and migration data plane. The API is # the only long-running service that opts into write symbols; the discrete # `migrate` command is the only runtime path that invokes DDL. -zed-orm-core = { git = "https://github.com/zed-pkg/zed-lib-core.git", rev = "c3d486a1519381276fbec02aa25247f542924443", features = ["read-write", "migrate"] } +zed-orm-core = { git = "https://github.com/zed-pkg/zed-lib-core.git", rev = "43b87769032c993f424e89825ad22abe980f4652", features = ["read-write", "migrate"] } # The private shared-auth-clients repository cannot be cloned by this # repository's scoped GITHUB_TOKEN. `src/shared_auth.rs` implements the small, # reviewed exchange/introspection HTTP contract using the existing reqwest diff --git a/src/auth.rs b/src/auth.rs index 181229d..2e5af6b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -11,6 +11,8 @@ use crate::shared_auth::{ClientError, Introspection}; use crate::state::AppState; const REQUIRED_ACCOUNT_SCOPE: &str = "zpkg:account"; +const REQUIRED_PACKAGE_READ_SCOPE: &str = "zpkg:packages:read"; +const ZPKG_CLI_AUTHORIZED_PARTY: &str = "zpkg-cli"; const CUSTOMER_AUTH_REALM: &str = "customer"; /// Verified browser/account identity. The Shared Auth token is intentionally @@ -25,6 +27,16 @@ pub struct AccountIdentity { pub parent_jti: String, } +/// Verified CLI identity for private package-resource authorization. +/// Authentication alone grants no package access; handlers must still perform +/// product-local visibility and membership authorization for the target resource. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PackageReaderIdentity { + pub session: SessionIdentity, + pub session_id: String, + pub parent_jti: String, +} + /// Tokens are stored as sha256 hex; the plaintext is shown exactly once by /// the `create-token` subcommand. pub fn hash_token(plaintext: &str) -> String { @@ -61,6 +73,24 @@ pub async fn require_account(state: &AppState, headers: &HeaderMap) -> ApiResult account_from_introspection(&introspection, &state.shared_auth_application_id) } +pub(crate) async fn require_package_reader( + state: &AppState, + headers: &HeaderMap, +) -> ApiResult { + let token = bearer_token(headers).ok_or_else(ApiErr::unauthorized)?; + let client = state.shared_auth.as_ref().ok_or_else(|| { + ApiErr::service_unavailable( + "auth_unavailable", + "shared authentication is not configured", + ) + })?; + let introspection = client + .introspect_for_audience(&token, &state.shared_auth_audience) + .await + .map_err(map_shared_auth_error)?; + package_reader_from_introspection(&introspection) +} + fn account_from_introspection( introspection: &Introspection, expected_authorized_party: &str, @@ -137,6 +167,75 @@ fn account_from_introspection( }) } +fn package_reader_from_introspection( + introspection: &Introspection, +) -> ApiResult { + if !introspection.active { + return Err(ApiErr::unauthorized()); + } + let subject = required_claim(introspection.sub.as_deref())? + .parse::() + .map_err(|_| ApiErr::unauthorized())?; + required_claim(introspection.iss.as_deref())?; + + let delegated_required = || { + ApiErr::forbidden( + "delegated_user_token_required", + "private package reads require a session-backed delegated user token", + ) + }; + let session_id = optional_rest_string(introspection, "sid")?.ok_or_else(delegated_required)?; + let authorized_party = + optional_rest_string(introspection, "azp")?.ok_or_else(delegated_required)?; + let parent_jti = + optional_rest_string(introspection, "parent_jti")?.ok_or_else(delegated_required)?; + + if authorized_party != ZPKG_CLI_AUTHORIZED_PARTY { + return Err(ApiErr::forbidden( + "wrong_authorized_party", + "the delegated token was not issued to the zed-pkg CLI", + )); + } + + let scope = optional_rest_string(introspection, "scope")?.ok_or_else(|| { + ApiErr::forbidden( + "insufficient_scope", + "the delegated token is missing the private-package read scope", + ) + })?; + if !scope + .split_ascii_whitespace() + .any(|candidate| candidate == REQUIRED_PACKAGE_READ_SCOPE) + { + return Err(ApiErr::forbidden( + "insufficient_scope", + "the delegated token is missing the private-package read scope", + )); + } + + let realm = optional_rest_string(introspection, "auth_realm")? + .or(optional_rest_string(introspection, "realm")?) + .unwrap_or_else(|| CUSTOMER_AUTH_REALM.to_owned()); + if realm != CUSTOMER_AUTH_REALM { + return Err(ApiErr::forbidden( + "wrong_auth_realm", + "the zed-pkg CLI accepts customer identities only", + )); + } + + Ok(PackageReaderIdentity { + session: SessionIdentity { + subject, + realm, + email: introspection.email.clone(), + display_name: optional_rest_string(introspection, "display_name")?, + avatar_url: optional_rest_string(introspection, "avatar_url")?, + }, + session_id, + parent_jti, + }) +} + pub(crate) fn map_shared_auth_error(error: ClientError) -> ApiErr { match error { ClientError::Unauthorized | ClientError::Status(401) | ClientError::InvalidInput(_) => { @@ -319,6 +418,102 @@ mod tests { ); } + fn package_reader_claims( + authorized_party: &str, + scope: &str, + realm: Option<&str>, + ) -> serde_json::Map { + delegated_rest(authorized_party, scope) + .into_iter() + .chain(realm.map(|realm| ("auth_realm".into(), Value::String(realm.into())))) + .collect() + } + + fn package_reader_introspection( + authorized_party: &str, + scope: &str, + realm: Option<&str>, + ) -> Introspection { + Introspection { + active: true, + sub: Some(SUBJECT.into()), + iss: Some("https://auth.example.test".into()), + email: None, + rest: package_reader_claims(authorized_party, scope, realm), + } + } + + #[test] + fn cli_package_reader_preserves_revocable_lineage() { + let introspection = + package_reader_introspection("zpkg-cli", "zpkg:packages:read", Some("customer")); + let identity = package_reader_from_introspection(&introspection).ok(); + assert_eq!( + identity + .as_ref() + .map(|identity| identity.session.subject.to_string()), + Some(SUBJECT.to_owned()) + ); + assert_eq!( + identity + .as_ref() + .map(|identity| identity.session_id.as_str()), + Some("session-1") + ); + assert_eq!( + identity + .as_ref() + .map(|identity| identity.parent_jti.as_str()), + Some("parent-token-1") + ); + } + + #[test] + fn cli_package_reader_rejects_wrong_party_and_scope() { + let wrong_party = + package_reader_introspection("zpkg-web", "zpkg:packages:read", Some("customer")); + assert_eq!( + package_reader_from_introspection(&wrong_party) + .err() + .map(|error| error.code), + Some("wrong_authorized_party") + ); + + let wrong_scope = + package_reader_introspection("zpkg-cli", "zpkg:account", Some("customer")); + assert_eq!( + package_reader_from_introspection(&wrong_scope) + .err() + .map(|error| error.code), + Some("insufficient_scope") + ); + } + + #[test] + fn cli_package_reader_rejects_base_and_admin_tokens() { + let base = Introspection { + active: true, + sub: Some(SUBJECT.into()), + iss: Some("https://auth.example.test".into()), + email: None, + rest: serde_json::Map::new(), + }; + assert_eq!( + package_reader_from_introspection(&base) + .err() + .map(|error| error.code), + Some("delegated_user_token_required") + ); + + let admin = package_reader_introspection("zpkg-cli", "zpkg:packages:read", Some("admin")); + assert_eq!( + package_reader_from_introspection(&admin) + .err() + .map(|error| error.code), + Some("wrong_auth_realm") + ); + } + #[test] fn delegated_token_without_account_scope_is_forbidden() { let introspection = Introspection { diff --git a/src/routes/artifacts.rs b/src/routes/artifacts.rs index d7e41bf..63faa4e 100644 --- a/src/routes/artifacts.rs +++ b/src/routes/artifacts.rs @@ -4,11 +4,12 @@ use std::sync::Arc; use axum::body::Body; use axum::extract::{Path, State}; -use axum::http::{StatusCode, header}; +use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use tokio_util::io::ReaderStream; +use crate::auth::require_package_reader; use crate::entities::version; use crate::error::{ApiErr, ApiResult}; use crate::files; @@ -19,10 +20,75 @@ use super::{artifact_format, find_org, find_package}; const IMMUTABLE: &str = "public, max-age=31536000, immutable"; +fn artifact_requires_private_authorization( + packages: &[zed_orm_core::models::PackageSummary], +) -> bool { + !packages.is_empty() + && packages + .iter() + .all(|package| package.visibility != "public") +} + +async fn authorize_artifact_read( + state: &AppState, + headers: &HeaderMap, + sha256: &str, +) -> ApiResult<()> { + let read = state.registry_read.as_ref().ok_or_else(|| { + ApiErr::service_unavailable( + "registry_data_plane_unavailable", + "canonical registry read context is not configured", + ) + })?; + let packages = zed_orm_core::read::packages_for_artifact_sha256(read, sha256) + .await + .map_err(crate::account::map_orm_error)?; + + // Legacy-only artifacts predate canonical visibility and remain in the + // existing anonymous compatibility plane. A shared digest with any public + // canonical reference is public bytes by definition; private references + // cannot make those same bytes secret again. + if !artifact_requires_private_authorization(&packages) { + return Ok(()); + } + + let reader = require_package_reader(state, headers).await?; + let Some(user) = + zed_orm_core::read::user_by_subject(read, &reader.session.realm, reader.session.subject) + .await + .map_err(crate::account::map_orm_error)? + else { + return Err(ApiErr::not_found("artifact")); + }; + + for package in &packages { + let org_role = zed_orm_core::read::org_role_for_user(read, package.org_id, user.id) + .await + .map_err(crate::account::map_orm_error)?; + if org_role.is_some() { + return Ok(()); + } + if let Some(project_id) = package.project_id { + let project_role = zed_orm_core::read::project_role_for_user(read, project_id, user.id) + .await + .map_err(crate::account::map_orm_error)?; + if project_role.is_some() { + return Ok(()); + } + } + } + + // Do not confirm a private artifact's existence to an authenticated user + // who lacks membership in every referencing package. + Err(ApiErr::not_found("artifact")) +} + pub async fn get_artifact( State(state): State>, + headers: HeaderMap, Path(sha256): Path, ) -> ApiResult { + authorize_artifact_read(&state, &headers, &sha256).await?; let row = version::Entity::find() .filter(version::Column::Sha256.eq(&sha256)) .one(&state.db) @@ -93,3 +159,50 @@ pub async fn get_file( // author-published files cannot run as active content from this origin (H2). Ok((StatusCode::OK, files::served_file_headers(&path), file).into_response()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use uuid::Uuid; + use zed_orm_core::models::PackageSummary; + + fn package(visibility: &str) -> PackageSummary { + PackageSummary { + id: Uuid::nil(), + org_id: Uuid::nil(), + org_slug: "example".into(), + project_id: None, + project_slug: None, + name: "pkg".into(), + description: None, + visibility: visibility.into(), + repo_url: "https://github.com/example/pkg".into(), + config: json!({}), + latest_version: None, + download_count: 0, + version_count: 1, + updated_at: chrono::Utc::now().fixed_offset(), + } + } + + #[test] + fn shared_digest_is_anonymous_when_any_reference_is_public() { + assert!(!artifact_requires_private_authorization(&[])); + assert!(!artifact_requires_private_authorization(&[package( + "public" + )])); + assert!(!artifact_requires_private_authorization(&[ + package("private"), + package("public"), + ])); + } + + #[test] + fn all_private_digest_requires_resource_authorization() { + assert!(artifact_requires_private_authorization(&[ + package("private"), + package("private"), + ])); + } +}