Skip to content
Merged
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 @@ -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:
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 @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/zed-contract-parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .zpkg.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down
195 changes: 195 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(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 {
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(crate) 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,102 @@ mod tests {
);
}

fn package_reader_claims(
authorized_party: &str,
scope: &str,
realm: Option<&str>,
) -> serde_json::Map<String, Value> {
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 {
Expand Down
Loading
Loading