Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
170 changes: 170 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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<PackageReaderIdentity> {
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,
Expand Down Expand Up @@ -137,6 +167,75 @@ fn account_from_introspection(
})
}

fn package_reader_from_introspection(
introspection: &Introspection,
) -> ApiResult<PackageReaderIdentity> {
if !introspection.active {
return Err(ApiErr::unauthorized());
}
let subject = required_claim(introspection.sub.as_deref())?
.parse::<Uuid>()
.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(_) => {
Expand Down Expand Up @@ -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::<Uuid>().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 {
Expand Down
Loading
Loading