diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2e7d49..3b8f51d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ env: CARGO_TERM_COLOR: always 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_INTERFACES_SHA: b6ec4ad9926b9c8b36ce9a5eb9814f5ea56e8126 ZED_LIB_CORE_SHA: c3d486a1519381276fbec02aa25247f542924443 jobs: diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml index 236beef..613ec78 100644 --- a/.github/workflows/publish-container.yml +++ b/.github/workflows/publish-container.yml @@ -56,7 +56,7 @@ env: # that tag always identifies the complete source graph used by the build. # 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_INTERFACES_SHA: b6ec4ad9926b9c8b36ce9a5eb9814f5ea56e8126 ZED_LIB_CORE_SHA: c3d486a1519381276fbec02aa25247f542924443 jobs: diff --git a/Cargo.lock b/Cargo.lock index da7c904..4ae2f63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5446,7 +5446,7 @@ dependencies = [ [[package]] name = "zed-interfaces" version = "0.1.1" -source = "git+https://github.com/zed-pkg/zed-interfaces.git?rev=996fc56e20c4bb41366377be14edfae886b84e82#996fc56e20c4bb41366377be14edfae886b84e82" +source = "git+https://github.com/zed-pkg/zed-interfaces.git?rev=b6ec4ad9926b9c8b36ce9a5eb9814f5ea56e8126#b6ec4ad9926b9c8b36ce9a5eb9814f5ea56e8126" dependencies = [ "bs58", "hex", diff --git a/Cargo.toml b/Cargo.toml index e421dec..3a1c888 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ ores-middleware = { git = "https://github.com/ORESoftware/ores-middleware", rev # (zed-interfaces#64). The repository root remains a Cargo workspace, so git # consumers resolve its Rust package from the polyglot `src/rust` member without # a path override. -zed-interfaces = { git = "https://github.com/zed-pkg/zed-interfaces.git", rev = "996fc56e20c4bb41366377be14edfae886b84e82" } +zed-interfaces = { git = "https://github.com/zed-pkg/zed-interfaces.git", rev = "b6ec4ad9926b9c8b36ce9a5eb9814f5ea56e8126" } # 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. diff --git a/src/auth.rs b/src/auth.rs index 181229d..e48a45e 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 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 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,77 @@ mod tests { ); } + #[test] + fn cli_package_reader_requires_exact_party_scope_lineage_and_customer_realm() { + let mut rest = delegated_rest("zpkg-cli", "zpkg:packages:read"); + rest.insert("auth_realm".into(), Value::String("customer".into())); + let introspection = Introspection { + active: true, + sub: Some(SUBJECT.into()), + iss: Some("https://auth.example.test".into()), + email: None, + rest, + }; + let identity = package_reader_from_introspection(&introspection).unwrap(); + assert_eq!(identity.session.subject, SUBJECT.parse::().unwrap()); + assert_eq!(identity.session_id, "session-1"); + assert_eq!(identity.parent_jti, "parent-token-1"); + + let wrong_party = Introspection { + active: true, + sub: Some(SUBJECT.into()), + iss: Some("https://auth.example.test".into()), + email: None, + rest: delegated_rest("zpkg-web", "zpkg:packages:read"), + }; + assert_eq!( + package_reader_from_introspection(&wrong_party) + .unwrap_err() + .code, + "wrong_authorized_party" + ); + + let wrong_scope = Introspection { + active: true, + sub: Some(SUBJECT.into()), + iss: Some("https://auth.example.test".into()), + email: None, + rest: delegated_rest("zpkg-cli", "zpkg:account"), + }; + assert_eq!( + package_reader_from_introspection(&wrong_scope) + .unwrap_err() + .code, + "insufficient_scope" + ); + + 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).unwrap_err().code, + "delegated_user_token_required" + ); + + let mut admin_rest = delegated_rest("zpkg-cli", "zpkg:packages:read"); + admin_rest.insert("auth_realm".into(), Value::String("admin".into())); + let admin = Introspection { + active: true, + sub: Some(SUBJECT.into()), + iss: Some("https://auth.example.test".into()), + email: None, + rest: admin_rest, + }; + assert_eq!( + package_reader_from_introspection(&admin).unwrap_err().code, + "wrong_auth_realm" + ); + } + #[test] fn delegated_token_without_account_scope_is_forbidden() { let introspection = Introspection { diff --git a/src/edge_fallback.rs b/src/edge_fallback.rs new file mode 100644 index 0000000..6ae0ca8 --- /dev/null +++ b/src/edge_fallback.rs @@ -0,0 +1,267 @@ +use std::fmt; + +use zed_interfaces::edge_fallback::{ + EDGE_FALLBACK_AUDIENCE_V2, EDGE_FALLBACK_CAPABILITY_VERSION_V2, + EDGE_FALLBACK_MAX_TTL_SECONDS_V2, EdgeFallbackCapabilityV2, EdgeFallbackGrantV2, + ReadOperationV2, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum EdgeCapabilityIssueError { + PublicPackage, + UnsupportedVisibility, + ReadDenied, + UnsupportedRepository, + InvalidCapability(String), + InvalidLifetime, +} + +impl fmt::Display for EdgeCapabilityIssueError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PublicPackage => formatter + .write_str("public packages use anonymous fallback and do not need a capability"), + Self::UnsupportedVisibility => formatter.write_str("unsupported package visibility"), + Self::ReadDenied => formatter.write_str("caller is not authorized to read the package"), + Self::UnsupportedRepository => { + formatter.write_str("package repository is not an approved GitHub source") + } + Self::InvalidCapability(message) => { + write!(formatter, "edge fallback capability is invalid: {message}") + } + Self::InvalidLifetime => formatter.write_str("capability lifetime is invalid"), + } + } +} + +impl std::error::Error for EdgeCapabilityIssueError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EdgeCapabilityPrincipal { + pub subject: String, + pub session_id: String, + pub parent_jti: String, +} + +impl From<&crate::auth::PackageReaderIdentity> for EdgeCapabilityPrincipal { + fn from(identity: &crate::auth::PackageReaderIdentity) -> Self { + Self { + subject: identity.session.subject.to_string(), + session_id: identity.session_id.clone(), + parent_jti: identity.parent_jti.clone(), + } + } +} + +pub(crate) fn authorize_private_package_read( + visibility: &str, + org_role: Option<&str>, + project_role: Option<&str>, +) -> Result<(), EdgeCapabilityIssueError> { + if visibility == "public" { + return Err(EdgeCapabilityIssueError::PublicPackage); + } + if visibility != "private" { + return Err(EdgeCapabilityIssueError::UnsupportedVisibility); + } + if has_membership(org_role) || has_membership(project_role) { + Ok(()) + } else { + Err(EdgeCapabilityIssueError::ReadDenied) + } +} + +pub(crate) fn github_grant_for_package( + package: &str, + repo_url: &str, +) -> Result { + let resource = canonical_github_resource(repo_url) + .ok_or(EdgeCapabilityIssueError::UnsupportedRepository)?; + Ok(EdgeFallbackGrantV2::Github { + operation: ReadOperationV2::Read, + package: package.to_owned(), + credential_ref: format!("github-app:repo:{resource}"), + resource, + }) +} + +pub(crate) fn build_github_capability_v2( + issuer: &str, + principal: &EdgeCapabilityPrincipal, + package: &str, + repo_url: &str, + issued_at: u64, + ttl_seconds: u64, + capability_id: &str, +) -> Result { + if ttl_seconds == 0 || ttl_seconds > EDGE_FALLBACK_MAX_TTL_SECONDS_V2 { + return Err(EdgeCapabilityIssueError::InvalidLifetime); + } + let expires_at = issued_at + .checked_add(ttl_seconds) + .ok_or(EdgeCapabilityIssueError::InvalidLifetime)?; + let capability = EdgeFallbackCapabilityV2 { + zed_edge_capability: EDGE_FALLBACK_CAPABILITY_VERSION_V2, + iss: issuer.to_owned(), + aud: EDGE_FALLBACK_AUDIENCE_V2.to_owned(), + sub: principal.subject.clone(), + sid: principal.session_id.clone(), + parent_jti: principal.parent_jti.clone(), + iat: issued_at, + nbf: issued_at, + exp: expires_at, + jti: capability_id.to_owned(), + grants: vec![github_grant_for_package(package, repo_url)?], + }; + capability + .validate() + .map_err(|error| EdgeCapabilityIssueError::InvalidCapability(error.to_string()))?; + Ok(capability) +} + +fn has_membership(role: Option<&str>) -> bool { + role.is_some_and(|value| { + let value = value.trim(); + !value.is_empty() && value.len() <= 64 && !value.chars().any(char::is_control) + }) +} + +fn canonical_github_resource(repo_url: &str) -> Option { + let trimmed = repo_url.trim(); + if !(trimmed.starts_with("https://github.com/") || trimmed.starts_with("git@github.com:")) { + return None; + } + let (owner, repo) = crate::verify::parse_github(trimmed)?; + Some(format!("{owner}/{repo}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn principal() -> EdgeCapabilityPrincipal { + EdgeCapabilityPrincipal { + subject: "user:ad7c2010-c28a-4cad-a510-4c4020f93535".to_owned(), + session_id: "session:abc-123".to_owned(), + parent_jti: "parent-token-0001".to_owned(), + } + } + + #[test] + fn package_reader_lineage_projects_into_edge_principal() { + let reader = crate::auth::PackageReaderIdentity { + session: crate::auth::SessionIdentity { + subject: "ad7c2010-c28a-4cad-a510-4c4020f93535".parse().unwrap(), + realm: "customer".to_owned(), + email: None, + display_name: None, + avatar_url: None, + }, + session_id: "session:abc-123".to_owned(), + parent_jti: "parent-token-0001".to_owned(), + }; + + let principal = EdgeCapabilityPrincipal::from(&reader); + assert_eq!(principal.subject, "ad7c2010-c28a-4cad-a510-4c4020f93535"); + assert_eq!(principal.session_id, "session:abc-123"); + assert_eq!(principal.parent_jti, "parent-token-0001"); + } + + #[test] + fn private_read_requires_existing_org_or_project_membership() { + assert_eq!( + authorize_private_package_read("private", None, None), + Err(EdgeCapabilityIssueError::ReadDenied) + ); + assert!(authorize_private_package_read("private", Some("reader"), None).is_ok()); + assert!(authorize_private_package_read("private", None, Some("member")).is_ok()); + assert_eq!( + authorize_private_package_read("public", Some("owner"), None), + Err(EdgeCapabilityIssueError::PublicPackage) + ); + assert_eq!( + authorize_private_package_read("internal", Some("owner"), None), + Err(EdgeCapabilityIssueError::UnsupportedVisibility) + ); + } + + #[test] + fn github_resource_is_derived_from_registry_owned_repo_url() + -> Result<(), EdgeCapabilityIssueError> { + let grant = github_grant_for_package( + "acme/private-lib", + "https://github.com/acme/private-lib.git", + )?; + assert_eq!(grant.provider(), "github"); + assert_eq!(grant.package(), "acme/private-lib"); + assert_eq!(grant.resource(), "acme/private-lib"); + assert_eq!(grant.credential_ref(), "github-app:repo:acme/private-lib"); + + assert!( + github_grant_for_package("acme/private-lib", "http://github.com/acme/private-lib") + .is_err() + ); + assert!( + github_grant_for_package("acme/private-lib", "https://gitlab.com/acme/private-lib") + .is_err() + ); + Ok(()) + } + + #[test] + fn v2_capability_binds_lineage_resource_and_short_lifetime() + -> Result<(), EdgeCapabilityIssueError> { + let capability = build_github_capability_v2( + "https://api.zpkg.net", + &principal(), + "acme/private-lib", + "git@github.com:acme/private-lib.git", + 1_000, + 120, + "capability-0001", + )?; + + assert_eq!(capability.zed_edge_capability, 2); + assert_eq!(capability.aud, "zed-edge-fallback"); + assert_eq!(capability.sid, "session:abc-123"); + assert_eq!(capability.parent_jti, "parent-token-0001"); + assert_eq!(capability.iat, 1_000); + assert_eq!(capability.nbf, 1_000); + assert_eq!(capability.exp, 1_120); + assert_eq!( + capability.grants.first().map(EdgeFallbackGrantV2::resource), + Some("acme/private-lib") + ); + Ok(()) + } + + #[test] + fn capability_rejects_zero_overlong_and_overflowing_lifetimes() { + for ttl in [0, EDGE_FALLBACK_MAX_TTL_SECONDS_V2 + 1] { + assert_eq!( + build_github_capability_v2( + "https://api.zpkg.net", + &principal(), + "acme/private-lib", + "https://github.com/acme/private-lib", + 1_000, + ttl, + "capability-0001", + ), + Err(EdgeCapabilityIssueError::InvalidLifetime) + ); + } + assert_eq!( + build_github_capability_v2( + "https://api.zpkg.net", + &principal(), + "acme/private-lib", + "https://github.com/acme/private-lib", + u64::MAX, + 1, + "capability-0001", + ), + Err(EdgeCapabilityIssueError::InvalidLifetime) + ); + } +} diff --git a/src/main.rs b/src/main.rs index c5905c4..646f170 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod binary_artifact; #[cfg(test)] mod binary_artifact_adversarial_tests; mod config; +mod edge_fallback; mod embeddings; mod entities; mod error;