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
56 changes: 54 additions & 2 deletions crates/core/src/auth/authz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
//! scope sets `admin: true`; an unscoped token (`NULL` scope) is evaluated
//! as the default scope, i.e. the owner's set without `admin`. See
//! [`EffectiveSet::intersect_scope`].
//! * **Workload** — the grants of the one role its realm's claim-rules mapped
//! it to, capped at `read` when the rule trusted every workload of the
//! issuer (an issuer-wide rule). Trusted publishers add `write` / `delete`
//! on top, outside the effective set.
//! * **Trusted header** — a user the headers of the proxy in front named
//! ([`super::AuthMethod::TrustedHeader`]) holds its grants without the
//! `admin` role: the headers never make anyone an administrator.
Expand Down Expand Up @@ -427,12 +431,21 @@ impl Authz {
// forge role its realm's claim-rules mapped it to (plus, for reads,
// the public reach every principal holds). No `app_user`, no token
// scope — a deleted role resolves to deny-all.
Principal::Workload { role_id, .. } => {
let role = authz_cache::load_role_effective(&self.pool, &self.cache, *role_id)
Principal::Workload {
role_id,
issuer_wide,
..
} => {
let mut role = authz_cache::load_role_effective(&self.pool, &self.cache, *role_id)
.await
.map_err(
|e| tracing::error!(error = %e, role_id, "authz workload role load failed"),
)?;
if *issuer_wide {
// Any workload of the issuer matched: the role only
// reads, as the anonymous role's grants do for others.
role = role.intersect_scope(&read_only_scope());
}
Ok(
match public_read
.filter(|_| self.public_read_for_signed_in.load(Ordering::Relaxed))
Expand Down Expand Up @@ -475,6 +488,16 @@ impl Authz {
}
}

/// A scope that keeps every repository the owner reaches, at `read` and
/// without `admin`: what a workload of an issuer-wide rule holds of its role.
fn read_only_scope() -> TokenScope {
TokenScope {
level: Some(Level::Read),
admin: false,
repositories: None,
}
}

/// A scope that allows no repositories — intersecting any owner set with it
/// yields the empty (deny-all) set. Used as the fail-closed fallback for a
/// token whose stored scope is malformed.
Expand Down Expand Up @@ -513,6 +536,35 @@ mod tests {
Authz::new(pool, Cache::l1_only(16))
}

#[test]
fn read_only_scope_caps_every_grant_at_read() {
let raw = crate::authz::RawEffectiveSet {
is_admin: false,
grants: vec![
crate::authz::RawGrant {
level: "delete".into(),
repo: None,
selector: None,
},
crate::authz::RawGrant {
level: "write".into(),
repo: Some(7),
selector: None,
},
],
};
let capped = EffectiveSet::build(&raw)
.expect("builds")
.intersect_scope(&read_only_scope());
assert!(!capped.is_admin());
for repo in [1, 7] {
assert!(capped.allows_repo(Level::Read, repo));
assert!(!capped.allows_repo(Level::Write, repo));
assert!(!capped.allows_repo(Level::Delete, repo));
}
assert!(!capped.allows_every_repo(Level::Write));
}

#[test]
fn deny_all_scope_is_an_empty_allowlist() {
let s = deny_all_scope();
Expand Down
7 changes: 7 additions & 0 deletions crates/core/src/auth/principal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ pub enum Principal {
/// `role.id` the matched claim-rule granted. The authz seam resolves
/// this role's effective set (deny-all if the role was since deleted).
role_id: i64,
/// Whether the matched claim-rule trusts every workload of the issuer
/// rather than one pinned to a workload identity. The authz seam caps
/// the role's grants at `read` for it, so a role given `write` does
/// not hand it to every project; trusted publishers, which stay
/// pinned, still add theirs.
issuer_wide: bool,
/// SHA-256 of the normalized trusted issuer. The publish audit hook
/// compares this to the current realm row while holding a row lock, so
/// a stale realm snapshot cannot be attributed to a reconfigured issuer.
Expand Down Expand Up @@ -204,6 +210,7 @@ mod tests {
let workload = Principal::Workload {
realm_id: 3,
role_id: 7,
issuer_wide: false,
issuer_sha256: [0; 32],
config_sha256: [0; 32],
subject_sha256: [0; 32],
Expand Down
14 changes: 10 additions & 4 deletions crates/db/src/workload_api_key_dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ pub struct NewWorkloadApiKey<'a> {
pub key_sha256: &'a [u8; 32],
pub realm_id: i64,
pub role_id: i64,
/// Whether the token matched an issuer-wide rule: the key holds its role
/// capped at `read`, as the token did.
pub issuer_wide: bool,
pub issuer_sha256: &'a [u8; 32],
pub config_sha256: &'a [u8; 32],
pub subject_sha256: &'a [u8; 32],
Expand Down Expand Up @@ -57,6 +60,7 @@ pub struct WorkloadApiKey {
pub key_sha256: [u8; 32],
pub realm_id: i64,
pub role_id: i64,
pub issuer_wide: bool,
pub issuer_sha256: [u8; 32],
pub config_sha256: [u8; 32],
pub subject_sha256: [u8; 32],
Expand Down Expand Up @@ -97,10 +101,10 @@ pub async fn insert(
INSERT INTO workload_api_key (
key_sha256, realm_id, role_id, issuer_sha256, config_sha256,
subject_sha256, jti_sha256, token_issued_at, token_expires_at,
expires_at
expires_at, issuer_wide
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9,
now() + make_interval(secs => $10))
now() + make_interval(secs => $10), $11)
ON CONFLICT (issuer_sha256, jti_sha256) DO NOTHING
RETURNING id, expires_at
"#,
Expand All @@ -115,6 +119,7 @@ pub async fn insert(
.bind(new.token_issued_at)
.bind(new.token_expires_at)
.bind(new.ttl_seconds as f64)
.bind(new.issuer_wide)
.fetch_optional(&mut *conn)
.await?;
match row {
Expand Down Expand Up @@ -159,8 +164,8 @@ pub async fn find_live(
) -> sqlx::Result<Option<WorkloadApiKey>> {
let row = sqlx::query(
r#"
SELECT id, key_sha256, realm_id, role_id, issuer_sha256, config_sha256,
subject_sha256, jti_sha256, token_issued_at, expires_at
SELECT id, key_sha256, realm_id, role_id, issuer_wide, issuer_sha256,
config_sha256, subject_sha256, jti_sha256, token_issued_at, expires_at
FROM workload_api_key
WHERE key_sha256 = $1 AND expires_at > now()
"#,
Expand All @@ -179,6 +184,7 @@ pub async fn find_live(
key_sha256: digest(&row, "key_sha256")?,
realm_id: row.try_get("realm_id")?,
role_id: row.try_get("role_id")?,
issuer_wide: row.try_get("issuer_wide")?,
issuer_sha256: digest(&row, "issuer_sha256")?,
config_sha256: digest(&row, "config_sha256")?,
subject_sha256: digest(&row, "subject_sha256")?,
Expand Down
37 changes: 37 additions & 0 deletions crates/db/tests/workload_api_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ async fn redeem(
key_sha256: &digest(n, key_salt),
realm_id: 1,
role_id: 1,
issuer_wide: false,
issuer_sha256: &digest(n, 1),
config_sha256: &digest(n, 2),
subject_sha256: &digest(n, 3),
Expand Down Expand Up @@ -72,6 +73,42 @@ async fn a_token_is_redeemed_once() {
assert_eq!(redeem(&pool, n, 11, exp, 60).await, Redemption::Replayed);
}

#[tokio::test]
async fn a_key_keeps_whether_its_token_matched_an_issuer_wide_rule() {
let pool = pool().await;
let exp = db_now(&pool).await + Duration::minutes(5);
for issuer_wide in [false, true] {
let n = unique();
let mut conn = pool.acquire().await.unwrap();
let issued = workload_api_key_dao::insert(
&mut conn,
NewWorkloadApiKey {
key_sha256: &digest(n, 10),
realm_id: 1,
role_id: 1,
issuer_wide,
issuer_sha256: &digest(n, 1),
config_sha256: &digest(n, 2),
subject_sha256: &digest(n, 3),
jti_sha256: &digest(n, 4),
token_issued_at: exp - Duration::minutes(5),
token_expires_at: exp,
token_leeway_seconds: 60,
matched_publishers: &[],
ttl_seconds: 3600,
},
)
.await
.unwrap();
assert!(matches!(issued, Redemption::Issued { .. }));
let key = workload_api_key_dao::find_live(&pool, &digest(n, 10))
.await
.unwrap()
.expect("live key");
assert_eq!(key.issuer_wide, issuer_wide);
}
}

#[tokio::test]
async fn a_token_past_its_leeway_on_the_database_clock_is_not_redeemed() {
// An instance whose clock runs behind the database's still verifies the
Expand Down
2 changes: 2 additions & 0 deletions crates/server/src/auth/token_exchange.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ async fn redeem(
let Principal::Workload {
realm_id,
role_id,
issuer_wide,
issuer_sha256,
config_sha256,
subject_sha256,
Expand Down Expand Up @@ -344,6 +345,7 @@ async fn redeem(
key_sha256: &workload_key_sha256(&api_key),
realm_id,
role_id,
issuer_wide,
issuer_sha256: &issuer_sha256,
config_sha256: &config_sha256,
subject_sha256: &subject_sha256,
Expand Down
Loading
Loading