diff --git a/crates/core/src/auth/authz.rs b/crates/core/src/auth/authz.rs index 152fb285..477100f9 100644 --- a/crates/core/src/auth/authz.rs +++ b/crates/core/src/auth/authz.rs @@ -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. @@ -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)) @@ -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. @@ -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(); diff --git a/crates/core/src/auth/principal.rs b/crates/core/src/auth/principal.rs index 0ea6f00c..88bd04ee 100644 --- a/crates/core/src/auth/principal.rs +++ b/crates/core/src/auth/principal.rs @@ -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. @@ -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], diff --git a/crates/db/src/workload_api_key_dao.rs b/crates/db/src/workload_api_key_dao.rs index 9e1fd1c3..e3ee3644 100644 --- a/crates/db/src/workload_api_key_dao.rs +++ b/crates/db/src/workload_api_key_dao.rs @@ -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], @@ -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], @@ -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 "#, @@ -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 { @@ -159,8 +164,8 @@ pub async fn find_live( ) -> sqlx::Result> { 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() "#, @@ -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")?, diff --git a/crates/db/tests/workload_api_key.rs b/crates/db/tests/workload_api_key.rs index 766f601d..a32d635c 100644 --- a/crates/db/tests/workload_api_key.rs +++ b/crates/db/tests/workload_api_key.rs @@ -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), @@ -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 diff --git a/crates/server/src/auth/token_exchange.rs b/crates/server/src/auth/token_exchange.rs index 2fbd24ed..de5166ae 100644 --- a/crates/server/src/auth/token_exchange.rs +++ b/crates/server/src/auth/token_exchange.rs @@ -309,6 +309,7 @@ async fn redeem( let Principal::Workload { realm_id, role_id, + issuer_wide, issuer_sha256, config_sha256, subject_sha256, @@ -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, diff --git a/crates/server/src/auth/wif.rs b/crates/server/src/auth/wif.rs index 1b1440f1..a2422287 100644 --- a/crates/server/src/auth/wif.rs +++ b/crates/server/src/auth/wif.rs @@ -173,8 +173,20 @@ pub struct ClaimRule { /// The forge `role.name` to grant on a match. Resolved to a `role.id` at /// validation time; a non-existent role denies (fail-closed). pub role: String, + /// Trust every workload of the realm's issuer, without a workload + /// identity condition: the opt-in for a self-managed CI whose projects + /// all may read. Refused for GitHub Actions and GitLab.com, whose tokens + /// anyone can mint, and only allowed after every other rule. A workload + /// that matched such a rule holds its role's grants capped at `read` + /// (the `issuer_wide` of [`Principal::Workload`]); trusted publishers, which + /// stay pinned to a workload identity, still add `write`. Left out of the + /// normalized config while unset, so realms that predate it keep their + /// policy digest. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub issuer_wide: bool, /// Claim conditions, AND-combined. Empty is rejected by config validation - /// and fails closed at runtime for defense in depth. + /// and fails closed at runtime for defense in depth, except on an + /// [`Self::issuer_wide`] rule, where they only narrow it. #[serde(default)] pub conditions: Vec, } @@ -244,6 +256,7 @@ pub fn trusted_publisher_conditions_match( claims, &ClaimRule { role: "trusted-publisher".into(), + issuer_wide: false, conditions: conditions.to_vec(), }, identity_claims, @@ -329,6 +342,7 @@ pub fn validate_workload_identity_config(config: &JsonValue) -> Result Result Result &str { issuer.trim_end_matches('/') } -/// The forge role granted by the **first** rule whose every condition matches -/// `claims`, or `None` (default deny). -pub fn matched_role<'a>( +/// The **first** rule whose every condition matches `claims`, or `None` +/// (default deny). Its `role` is the forge role granted. +pub fn matched_rule<'a>( claims: &IdTokenClaims, rules: &'a [ClaimRule], identity_claims: &[String], -) -> Option<&'a str> { +) -> Option<&'a ClaimRule> { let matched = rules .iter() - .find(|rule| rule_matches(claims, rule, identity_claims)) - .map(|r| r.role.as_str())?; - (matched != authz_dao::ADMIN_ROLE).then_some(matched) + .find(|rule| rule_matches(claims, rule, identity_claims))?; + (matched.role != authz_dao::ADMIN_ROLE).then_some(matched) } /// Whether every condition of `rule` matches `claims`. Empty and semantically /// context-only rule sets fail closed even if a malformed row bypassed config -/// validation. +/// validation, and so does an issuer-wide rule for a public issuer. fn rule_matches(claims: &IdTokenClaims, rule: &ClaimRule, identity_claims: &[String]) -> bool { - !rule.conditions.is_empty() - && rule_has_identity_scope(&claims.iss, identity_claims, rule) + let scoped = if rule.issuer_wide { + !is_public_issuer(&claims.iss) + } else { + !rule.conditions.is_empty() && rule_has_identity_scope(&claims.iss, identity_claims, rule) + }; + scoped && rule.conditions.iter().all(|cond| { let values = claim_values(claims, &cond.claim); values.iter().any(|v| cond.any_of.iter().any(|a| a == v)) }) } +/// Whether `issuer` is GitHub Actions or GitLab.com, which mint tokens for +/// anyone who signs up, so an issuer-wide rule never applies to them. +fn is_public_issuer(issuer: &str) -> bool { + // `Url` lowercases the scheme and host and drops a default port, so no + // spelling of the URL slips past. + let normalized = url::Url::parse(issuer) + .map(|url| url.as_str().trim_end_matches('/').to_string()) + .unwrap_or_else(|_| normalize_issuer(issuer).to_string()); + normalized == GITHUB_ACTIONS_ISSUER || normalized == GITLAB_COM_ISSUER +} + /// Whether a rule binds trust to a workload identity rather than only to /// verifier-global or execution-context claims. /// @@ -1174,6 +1222,15 @@ mod tests { use super::*; use serde_json::{json, Map}; + /// The role of the first matching rule. + fn matched_role<'a>( + claims: &IdTokenClaims, + rules: &'a [ClaimRule], + identity_claims: &[String], + ) -> Option<&'a str> { + matched_rule(claims, rules, identity_claims).map(|rule| rule.role.as_str()) + } + fn claims_with(additional: Map) -> IdTokenClaims { IdTokenClaims { sub: "subject-1".into(), @@ -1428,6 +1485,7 @@ mod tests { // first rule needs a protected ref on a different project — no match ClaimRule { role: "deployer".into(), + issuer_wide: false, conditions: vec![ClaimCondition { claim: "project_path".into(), any_of: vec!["group/other".into()], @@ -1436,6 +1494,7 @@ mod tests { // second rule: project + protected ref both match ClaimRule { role: "publisher".into(), + issuer_wide: false, conditions: vec![ ClaimCondition { claim: "project_path".into(), @@ -1451,6 +1510,158 @@ mod tests { assert_eq!(matched_role(&claims, &rules, &[]), Some("publisher")); } + #[test] + fn issuer_wide_rules_are_for_a_self_managed_issuer_and_come_last() { + let pinned = json!({"role": "ci-write", "conditions": [ + {"claim": "job_project_id", "any_of": ["1001"]} + ]}); + let wide = json!({"role": "ci-read", "issuer_wide": true, "conditions": []}); + let protected = json!({"role": "ci-protected", "issuer_wide": true, "conditions": [ + {"claim": "ref_protected", "any_of": ["true"]} + ]}); + let realm = |issuer: &str, rules: JsonValue| json!({"issuer": issuer, "allowed_audiences": ["forge"], "rules": rules}); + + let ok = validate_workload_identity_config(&realm( + "https://gitlab.example", + json!([pinned, protected, wide]), + )) + .expect("issuer-wide rules after the pinned one"); + assert!( + ok["rules"][0].get("issuer_wide").is_none(), + "unset stays out of the normalized config" + ); + assert_eq!(ok["rules"][1]["issuer_wide"], true); + assert_eq!(ok["rules"][2]["issuer_wide"], true); + validate_workload_identity_config(&realm("https://gitlab.example", json!([wide]))) + .expect("no condition is required"); + + // Anyone can mint a token of a public issuer, however it is spelled. + for issuer in [ + "https://gitlab.com", + "https://GitLab.com/", + "https://gitlab.com:443", + "https://token.actions.githubusercontent.com", + ] { + let err = + validate_workload_identity_config(&realm(issuer, json!([wide]))).expect_err(issuer); + assert!( + err.contains("issuer-wide trust is refused"), + "{issuer}: {err}" + ); + } + // Rules are first-match: ahead of a pinned rule it would shadow it. + let err = validate_workload_identity_config(&realm( + "https://gitlab.example", + json!([wide, pinned]), + )) + .unwrap_err(); + assert!(err.contains("must come after every other rule"), "{err}"); + // Its conditions are still checked, and it never grants admin. + assert!(validate_workload_identity_config(&realm( + "https://gitlab.example", + json!([{"role": "ci-read", "issuer_wide": true, "conditions": [ + {"claim": "ref_protected", "any_of": []} + ]}]), + )) + .is_err()); + assert!(validate_workload_identity_config(&realm( + "https://gitlab.example", + json!([{"role": "admin", "issuer_wide": true}]), + )) + .is_err()); + } + + #[test] + fn issuer_wide_rules_match_every_workload_the_rules_ahead_do_not() { + let condition = |claim: &str, value: &str| ClaimCondition { + claim: claim.into(), + any_of: vec![value.into()], + }; + let rules = vec![ + ClaimRule { + role: "ci-write".into(), + issuer_wide: false, + conditions: vec![condition("job_project_id", "1001")], + }, + ClaimRule { + role: "ci-protected".into(), + issuer_wide: true, + conditions: vec![condition("ref_protected", "true")], + }, + ClaimRule { + role: "ci-read".into(), + issuer_wide: true, + conditions: vec![], + }, + ]; + let job = |project: &str, protected: bool| { + let mut add = Map::new(); + add.insert("job_project_id".into(), json!(project)); + add.insert("ref_protected".into(), json!(protected)); + claims_with(add) + }; + let matched = |claims: IdTokenClaims| { + matched_rule(&claims, &rules, &[]).map(|rule| (rule.role.clone(), rule.issuer_wide)) + }; + assert_eq!( + matched(job("1001", false)), + Some(("ci-write".into(), false)) + ); + assert_eq!( + matched(job("2002", true)), + Some(("ci-protected".into(), true)) + ); + assert_eq!(matched(job("2002", false)), Some(("ci-read".into(), true))); + } + + #[test] + fn issuer_wide_rules_fail_closed_for_public_issuers_even_if_a_bad_row_bypasses_validation() { + let rules = vec![ClaimRule { + role: "ci-read".into(), + issuer_wide: true, + conditions: vec![], + }]; + for issuer in [ + GITLAB_COM_ISSUER, + "https://gitlab.com/", + GITHUB_ACTIONS_ISSUER, + ] { + let mut claims = claims_with(Map::new()); + claims.iss = issuer.into(); + assert_eq!(matched_role(&claims, &rules, &[]), None, "{issuer}"); + } + assert_eq!( + matched_role(&claims_with(Map::new()), &rules, &[]), + Some("ci-read") + ); + } + + #[test] + fn realms_without_issuer_wide_rules_keep_their_config_digest() { + let config = json!({ + "issuer": "https://gitlab.example", + "allowed_audiences": ["forge"], + "rules": [{"role": "ci", "conditions": [ + {"claim": "job_project_id", "any_of": ["1001"]} + ]}] + }); + // What a realm saved before issuer-wide rules existed normalizes to. + assert_eq!( + serde_json::to_string(&validate_workload_identity_config(&config).unwrap()).unwrap(), + r#"{"allow_http_issuer":false,"allowed_audiences":["forge"],"allowed_authorized_parties":[],"clock_skew_seconds":60,"issuer":"https://gitlab.example","max_token_age_seconds":39600,"positive_cache_seconds":60,"rules":[{"conditions":[{"any_of":["1001"],"claim":"job_project_id"}],"role":"ci"}]}"# + ); + assert_eq!( + hex::encode(workload_identity_config_sha256(&config).unwrap()), + "895a21172935d94fa9a67417dcb12d373f11d05b44b8628e80b14268ac2cb561" + ); + let mut explicit = config.clone(); + explicit["rules"][0]["issuer_wide"] = json!(false); + assert_eq!( + workload_identity_config_sha256(&explicit).unwrap(), + workload_identity_config_sha256(&config).unwrap() + ); + } + #[test] fn trusted_publishers_require_stable_issuer_identity_claims() { let github = vec![ @@ -1554,6 +1765,7 @@ mod tests { let claims = claims_with(add); let rules = vec![ClaimRule { role: "publisher".into(), + issuer_wide: false, conditions: vec![ ClaimCondition { claim: "project_path".into(), @@ -1573,6 +1785,7 @@ mod tests { let claims = claims_with(Map::new()); let rules = vec![ClaimRule { role: "reader".into(), + issuer_wide: false, conditions: vec![], }]; assert_eq!(matched_role(&claims, &rules, &[]), None); @@ -1585,6 +1798,7 @@ mod tests { let claims = claims_with(add); let rules = vec![ClaimRule { role: "publisher".into(), + issuer_wide: false, conditions: vec![ ClaimCondition { claim: "aud".into(), @@ -1604,6 +1818,7 @@ mod tests { let claims = claims_with(Map::new()); let rules = vec![ClaimRule { role: authz_dao::ADMIN_ROLE.into(), + issuer_wide: false, conditions: vec![ClaimCondition { claim: "sub".into(), any_of: vec!["subject-1".into()], diff --git a/crates/server/src/dispatch.rs b/crates/server/src/dispatch.rs index 0e55cd69..04127241 100644 --- a/crates/server/src/dispatch.rs +++ b/crates/server/src/dispatch.rs @@ -486,6 +486,7 @@ mod tests { assert!(principal_allows_protocol_mutations(&Principal::Workload { realm_id: 1, role_id: 2, + issuer_wide: false, issuer_sha256: [1; 32], config_sha256: [3; 32], subject_sha256: [2; 32], diff --git a/crates/server/src/images/token.rs b/crates/server/src/images/token.rs index 6afb6850..4241a34b 100644 --- a/crates/server/src/images/token.rs +++ b/crates/server/src/images/token.rs @@ -100,6 +100,11 @@ pub enum TokenPrincipal { Workload { realm: i64, role: i64, + /// The workload matched an issuer-wide rule: its role reads only. + /// Left out while unset, so tokens minted before it read back as + /// before. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + wide: bool, iss: String, cfg: String, sub: String, @@ -139,6 +144,7 @@ impl TokenPrincipal { Principal::Workload { realm_id, role_id, + issuer_wide, issuer_sha256, config_sha256, subject_sha256, @@ -150,6 +156,7 @@ impl TokenPrincipal { } => TokenPrincipal::Workload { realm: *realm_id, role: *role_id, + wide: *issuer_wide, iss: hex::encode(issuer_sha256), cfg: hex::encode(config_sha256), sub: hex::encode(subject_sha256), @@ -186,6 +193,7 @@ impl TokenPrincipal { TokenPrincipal::Workload { realm, role, + wide, iss, cfg, sub, @@ -197,6 +205,7 @@ impl TokenPrincipal { } => Principal::Workload { realm_id: *realm, role_id: *role, + issuer_wide: *wide, issuer_sha256: arr(iss)?, config_sha256: arr(cfg)?, subject_sha256: arr(sub)?, @@ -506,6 +515,7 @@ mod tests { let p = Principal::Workload { realm_id: 1, role_id: 2, + issuer_wide: true, issuer_sha256: [1; 32], config_sha256: [2; 32], subject_sha256: [3; 32], @@ -519,11 +529,15 @@ mod tests { }]), }; let tp = TokenPrincipal::of(&p); + assert!(serde_json::to_string(&tp) + .unwrap() + .contains("\"wide\":true")); assert_eq!(tp.credential_ends(), Some(7)); assert_eq!(tp.to_principal(Principal::Unauthenticated), Some(p)); let direct = Principal::Workload { realm_id: 1, role_id: 2, + issuer_wide: false, issuer_sha256: [1; 32], config_sha256: [2; 32], subject_sha256: [3; 32], @@ -535,6 +549,8 @@ mod tests { }; let tp = TokenPrincipal::of(&direct); assert!(!serde_json::to_string(&tp).unwrap().contains("\"key\"")); + // A token minted before `wide` existed reads back as pinned. + assert!(!serde_json::to_string(&tp).unwrap().contains("\"wide\"")); assert_eq!(tp.to_principal(Principal::Unauthenticated), Some(direct)); let session = Principal::User { diff --git a/crates/server/tests/trusted_publishers.rs b/crates/server/tests/trusted_publishers.rs index edfa14c9..9cd99f44 100644 --- a/crates/server/tests/trusted_publishers.rs +++ b/crates/server/tests/trusted_publishers.rs @@ -802,3 +802,213 @@ async fn reader_role_can_publish_only_live_publisher_packages() { 401 ); } + +#[tokio::test] +async fn issuer_wide_rule_reads_and_only_pinned_rules_or_publishers_write() { + let (issuer, signer) = issuer().await; + let srv = common::start().await; + let (admin, password) = srv.admin_basic(); + let client = srv.client(); + let repo = format!("issuer-wide-{}", common::unique()); + let oci_repo = format!("issuer-wide-oci-{}", common::unique()); + let image = format!("{oci_repo}/app"); + + for body in [ + json!({"name": repo, "kind": "hosted"}), + json!({"name": oci_repo, "kind": "hosted", "format": "docker"}), + ] { + let response = client + .post(format!("{}/admin/repositories", srv.base_url)) + .basic_auth(&admin, Some(&password)) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + } + + // Both roles may write: the issuer-wide rule's must still only read. + let mut roles = Vec::new(); + for prefix in ["issuer-wide-pinned", "issuer-wide-every"] { + let role_name = format!("{prefix}-{}", common::unique()); + let role: Value = client + .post(format!("{}/admin/roles", srv.base_url)) + .basic_auth(&admin, Some(&password)) + .json(&json!({"name": role_name})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let role_id = role["id"].as_i64().unwrap(); + for repository in [&repo, &oci_repo] { + let response = client + .put(format!("{}/admin/roles/{role_id}/grants", srv.base_url)) + .basic_auth(&admin, Some(&password)) + .json(&json!({"repository": repository, "level": "write"})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + } + roles.push(role_name); + } + + let realm_name = format!("issuer-wide-wif-{}", common::unique()); + let pinned_rule = json!({ + "role": roles[0], + "conditions": [ + {"claim": "repository_id", "any_of": ["7007"]}, + {"claim": "repository_owner_id", "any_of": ["2001"]} + ] + }); + let wide_rule = json!({"role": roles[1], "issuer_wide": true, "conditions": []}); + let realm = |rules: Value| { + json!({ + "kind": "workload_identity", + "name": realm_name, + "enabled": true, + "config": { + "issuer": issuer.uri(), + "allow_http_issuer": true, + "allowed_audiences": ["https://forge.example.com"], + "rules": rules + } + }) + }; + // First-match: ahead of the pinned rule it would shadow it. + let response = client + .post(format!("{}/admin/realms", srv.base_url)) + .basic_auth(&admin, Some(&password)) + .json(&realm(json!([wide_rule, pinned_rule]))) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let response = client + .post(format!("{}/admin/realms", srv.base_url)) + .basic_auth(&admin, Some(&password)) + .json(&realm(json!([pinned_rule, wide_rule]))) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201, "{}", response.text().await.unwrap()); + + let response = client + .post(format!( + "{}/admin/repositories/{repo}/trusted-publishers", + srv.base_url + )) + .basic_auth(&admin, Some(&password)) + .json(&json!({ + "name": "widgets-release", + "realm": realm_name, + "packages": {"includes": ["Acme.Widgets.*"], "excludes": []}, + "conditions": [ + {"claim": "repository_id", "any_of": ["1001"]}, + {"claim": "repository_owner_id", "any_of": ["2001"]} + ], + "actions": ["write"] + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201, "{}", response.text().await.unwrap()); + + // The pinned rule matches first and writes through its role. + let pinned = format!("Acme.Pinned.{}", common::unique()); + let pinned_token = signer.sign(&claims(&issuer.uri(), "7007")); + assert_eq!( + push(&srv, &repo, &pinned_token, &pinned).await.status(), + 201 + ); + + // Any other project of the issuer matches the issuer-wide rule: it reads, + // but its role's `write` is capped away, with the CI token and with an + // exchanged key alike. + let read = |credential: String| { + let url = format!( + "{}/repository/{repo}/flat/{}/index.json", + srv.base_url, + pinned.to_lowercase() + ); + let client = client.clone(); + async move { + client + .get(url) + .header("X-NuGet-ApiKey", credential) + .send() + .await + .unwrap() + .status() + } + }; + let wide_token = signer.sign(&claims(&issuer.uri(), "3003")); + assert_eq!(read(wide_token.clone()).await, 200); + let denied = format!("Acme.Widgets.Wide{}", common::unique()); + assert_eq!(push(&srv, &repo, &wide_token, &denied).await.status(), 403); + let wide_key = exchange_workload_key(&srv, signer.sign(&claims(&issuer.uri(), "3003"))).await; + assert_eq!(read(wide_key.clone()).await, 200); + assert_eq!(push(&srv, &repo, &wide_key, &denied).await.status(), 403); + + // A registry token minted from the key keeps the cap. + let registry = client + .get(format!("{}/v2/token", srv.base_url)) + .query(&[ + ("service", "test.local"), + ("scope", &format!("repository:{image}:pull,push")), + ]) + .basic_auth("oidc", Some(&wide_key)) + .send() + .await + .unwrap(); + assert_eq!(registry.status(), 200); + let registry_token = registry.json::().await.unwrap()["token"] + .as_str() + .unwrap() + .to_string(); + let token_claims: Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(registry_token.split('.').nth(1).unwrap()) + .unwrap(), + ) + .unwrap(); + assert_eq!(token_claims["fp"]["wide"], true, "{token_claims}"); + assert_eq!( + token_claims["access"][0]["actions"], + json!(["pull"]), + "{token_claims}" + ); + let config = + br#"{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}"#; + assert!( + push_oci_image(&srv, &image, ®istry_token, "wide", config) + .await + .status() + .is_client_error() + ); + + // A trusted publisher pinned to its project still adds `write` on top of + // the issuer-wide read, for its packages only. + let publisher_token = signer.sign(&claims(&issuer.uri(), "1001")); + let released = format!("Acme.Widgets.Release{}", common::unique()); + assert_eq!( + push(&srv, &repo, &publisher_token, &released) + .await + .status(), + 201 + ); + let other = format!("Acme.Other.{}", common::unique()); + assert_eq!( + push(&srv, &repo, &publisher_token, &other).await.status(), + 403 + ); + let publisher_key = + exchange_workload_key(&srv, signer.sign(&claims(&issuer.uri(), "1001"))).await; + let exchanged = format!("Acme.Widgets.Exchanged{}", common::unique()); + assert_eq!( + push(&srv, &repo, &publisher_key, &exchanged).await.status(), + 201 + ); +} diff --git a/docs/changes/2.2.0/626.feature.md b/docs/changes/2.2.0/626.feature.md new file mode 100644 index 00000000..0f3069c0 --- /dev/null +++ b/docs/changes/2.2.0/626.feature.md @@ -0,0 +1,10 @@ +Workload identity: a rule of a self-managed issuer can take every project of +the issuer (`"issuer_wide": true`, **Every project of this issuer** in the +realm's rules), so every CI job of a self-managed GitLab can restore without +a list of namespace or project IDs to keep current. Such a rule needs no +identity condition (conditions such as `ref_protected` may narrow it), must +come after every other rule, and is refused for GitHub Actions and +GitLab.com. A job it matched holds its role's grants capped at `read`, with +the CI token, an exchanged key and registry tokens alike; a rule for a +project ahead of it, or a trusted publisher, still lets a project publish. +Realms without such a rule keep their keys on upgrade. diff --git a/docs/gitlab-ci.md b/docs/gitlab-ci.md index a653825f..0aad8fcd 100644 --- a/docs/gitlab-ci.md +++ b/docs/gitlab-ci.md @@ -147,7 +147,9 @@ API takes it (`POST /admin/realms`): GitLab without the `job_*` claims. - **More projects** go into the same realm: add their IDs to the rule's lists, or add a rule per team with a role of its own. Rules are ordered, - and the first rule whose conditions all match picks the role. + and the first rule whose conditions all match picks the role. On a + self-managed GitLab, a last rule can take + [every project of the instance](#every-project-of-the-instance), read-only. - **Saving the realm revokes its keys.** Any change to the realm revokes every key it has issued, so jobs running at that moment fail and must be retried. @@ -360,6 +362,39 @@ there later. - **Rules are ordered.** A project that needs a role of its own goes into a rule before this one. +### Every project of the instance + +On a self-managed GitLab whose projects all may restore, one rule takes every +job the instance signs a token for, a project or subgroup created later +included, where a list of groups must be kept current: + +```json +{ "role": "gitlab-ci-read", "issuer_wide": true, "conditions": [] } +``` + +In the web UI it is the rule's **Every project of this issuer** switch. + +- **Everyone who runs a pipeline gets the role.** Every account that can + create a project and run a pipeline on the instance reads with it, in a + personal namespace or a fork too. A GitLab with open sign-up is as public + as GitLab.com: close sign-up, or pin the groups as above. +- **Read-only, whatever the role grants.** A job this rule matched holds the + role's grants at `read`, and so do its exchanged key and the registry + tokens minted from either: a `write` or `delete` grant on the role does + not apply to it, so a role given `write` later does not hand it to every + project. +- **Projects that publish** keep their trusted publisher + ([step 4](#4-the-trusted-publisher)), which adds `write` for the project's + own packages on top, or get a rule of their own ahead of this one, whose + role may write. +- **Last.** Rules are first-match, so this rule comes after every other rule; + Forge refuses the realm otherwise. +- **Conditions narrow it.** Add context conditions, such as `ref_protected` + `true` for the pipelines of protected branches and tags only; none is + required. +- **Not on `https://gitlab.com`** (nor for GitHub Actions): anyone can mint + its tokens, and Forge refuses the rule there. + ### The runner In the runner's `config.toml`, in its `[[runners]]` entry: diff --git a/docs/trusted-publishing.md b/docs/trusted-publishing.md index b753ce9e..0eac961c 100644 --- a/docs/trusted-publishing.md +++ b/docs/trusted-publishing.md @@ -51,7 +51,23 @@ A workload token is accepted only when all of these checks pass: `namespace_id`, `job_namespace_id`, `project_path`, `namespace_path`) or a claim the realm declares in `identity_claims` (for a CI system with its own workload identifier); claims such as `actor` or `event_name` can be shared - by unrelated jobs and never count as an identity; and + by unrelated jobs and never count as an identity. The one exception is an + **issuer-wide** rule (`"issuer_wide": true`, **Every project of this + issuer** in the web UI), the opt-in for a self-managed issuer whose + workloads all may read. It matches every token of the realm's issuer + without an identity condition; conditions on context such as + `ref_protected` may narrow it, and none is required. Forge refuses it for + GitHub Actions and GitLab.com, whose tokens anyone can mint, and a + self-managed GitLab with open sign-up is just as public: every account that + can create a project and run a pipeline, in a personal namespace or a fork + too, gets the role. Rules are first-match, so an issuer-wide rule must come + after every other rule. A workload it matched holds its role's grants + capped at `read`, with the CI token, an exchanged key and the registry + tokens minted from either: a `write` or `delete` grant the role holds, or + is given later, never reaches every project. Writes stay pinned to a + workload identity: a rule for the project ahead of the issuer-wide one + picks a role of its own, and a trusted publisher still adds `write` for its + own project on top of the issuer-wide read; and - a realm rule must accept the token and map it to a role; a trusted publisher alone never authenticates a workload. The role continues to grant base access, commonly `read` on hosted and proxy repositories, while a trusted publisher diff --git a/migrations/0005_workload_issuer_wide.sql b/migrations/0005_workload_issuer_wide.sql new file mode 100644 index 00000000..63b468fd --- /dev/null +++ b/migrations/0005_workload_issuer_wide.sql @@ -0,0 +1,12 @@ +-- --------------------------------------------------------------------------- +-- An exchanged workload key remembers an issuer-wide match (#626) +-- +-- A `workload_identity` realm rule may trust every workload of a self-managed +-- issuer (`issuer_wide`). Such a workload holds its role's grants capped at +-- `read`, and so must the key the token exchange issues for it: the key +-- authorizes as the principal captured at exchange time, and the cap is part +-- of that principal. Keys issued before this column existed matched a rule +-- pinned to a workload identity, which is what the default says. +-- --------------------------------------------------------------------------- +ALTER TABLE workload_api_key + ADD COLUMN issuer_wide boolean NOT NULL DEFAULT false; diff --git a/web/src/api/realms.ts b/web/src/api/realms.ts index 85d6d957..9b31df17 100644 --- a/web/src/api/realms.ts +++ b/web/src/api/realms.ts @@ -103,10 +103,17 @@ export interface WorkloadIdentityCondition { /** * A workload-identity rule: grants `role` when ALL `conditions` match (AND). - * At least one condition is required; catch-all trust is rejected. + * At least one condition, pinning a workload identity, is required; + * catch-all trust is rejected, except as an `issuer_wide` rule. */ export interface WorkloadIdentityRule { role: string + /** + * Trust every workload of a self-managed issuer: no condition is required, + * and the role only reads. Refused for GitHub Actions and GitLab.com, and + * allowed only after every other rule. Omitted while off. + */ + issuer_wide?: boolean conditions?: WorkloadIdentityCondition[] } diff --git a/web/src/components/RealmForm.test.ts b/web/src/components/RealmForm.test.ts index 90c7c7cd..3933aee0 100644 --- a/web/src/components/RealmForm.test.ts +++ b/web/src/components/RealmForm.test.ts @@ -192,6 +192,38 @@ describe('workload identity config', () => { it('omits a blank exchanged-key lifetime so the server default applies', () => { expect(toRealmConfig(values(''))).not.toHaveProperty('api_key_ttl_seconds') }) + + it('sends issuer_wide only for a rule that takes every project', () => { + const config = toRealmConfig({ + ...values(''), + wifRules: [ + { role: 'ci', issuerWide: false, conditions: [{ claim: 'pipeline_id', anyOf: '42' }] }, + { role: 'ci-read', issuerWide: true, conditions: [] }, + ], + }) + expect(config).toMatchObject({ + rules: [ + { role: 'ci', conditions: [{ claim: 'pipeline_id', any_of: ['42'] }] }, + { role: 'ci-read', issuer_wide: true, conditions: [] }, + ], + }) + expect((config as { rules: object[] }).rules[0]).not.toHaveProperty('issuer_wide') + }) + + it('reads issuer_wide back from a stored realm', () => { + const seeded = seedRealmFormValues({ + kind: 'workload_identity', + config: { + issuer: 'https://gitlab.example.com', + allowed_audiences: ['forge'], + rules: [ + { role: 'ci', conditions: [{ claim: 'job_project_id', any_of: ['1001'] }] }, + { role: 'ci-read', issuer_wide: true, conditions: [] }, + ], + }, + } as unknown as RealmView) + expect(seeded.wifRules.map((rule) => rule.issuerWide)).toEqual([false, true]) + }) }) describe('OIDC federated credential', () => { diff --git a/web/src/components/RealmForm.tsx b/web/src/components/RealmForm.tsx index bd16a52c..922ab018 100644 --- a/web/src/components/RealmForm.tsx +++ b/web/src/components/RealmForm.tsx @@ -146,6 +146,8 @@ export interface RealmFormValues { /** Ordered claim→role rules; anyOf holds one value per line per condition. */ wifRules: Array<{ role: string + /** Every project of a self-managed issuer, read-only (`issuer_wide`). */ + issuerWide: boolean conditions: Array<{ claim: string; anyOf: string }> }> // trusted_header @@ -305,6 +307,7 @@ export function seedRealmFormValues(existing?: RealmView): RealmFormValues { wif.api_key_ttl_seconds != null ? String(wif.api_key_ttl_seconds) : '', wifRules: (wif.rules ?? []).map((rule) => ({ role: rule.role ?? '', + issuerWide: rule.issuer_wide ?? false, conditions: (rule.conditions ?? []).map((c) => ({ claim: c.claim ?? '', anyOf: joinLines(c.any_of), @@ -499,6 +502,7 @@ export function RealmForm({ if (values.wifRules.length === 0) { next.wifRules = 'At least one rule is required.' } + const firstIssuerWide = values.wifRules.findIndex((rule) => rule.issuerWide) values.wifRules.forEach((rule, ri) => { if (rule.role.trim() === '') { next[`wifRule.${ri}.role`] = 'A role is required.' @@ -506,10 +510,19 @@ export function RealmForm({ next[`wifRule.${ri}.role`] = 'Workload identity cannot grant the built-in global admin role.' } - if (rule.conditions.length === 0) { + if (!rule.issuerWide && firstIssuerWide !== -1 && firstIssuerWide < ri) { + // Rules are first-match: the issuer-wide rule would take this one's + // workloads. + next[`wifRule.${firstIssuerWide}.issuerWide`] = + `Rules are first-match, so a rule for every project comes after every other rule; here it would take the workloads of rule ${ri + 1}. Remove it, and add it again as the last rule.` + } + if (rule.issuerWide && isPublicWifIssuer(values.issuer)) { + next[`wifRule.${ri}.issuerWide`] = + 'Anyone can mint a GitHub Actions or GitLab.com token: pin the rule to a repository or project instead.' + } else if (!rule.issuerWide && rule.conditions.length === 0) { next[`wifRule.${ri}.conditions`] = 'At least one claim condition is required; catch-all trust is not allowed.' - } else if (!wifRuleHasIdentityScope(values.issuer, rule.conditions)) { + } else if (!wifRuleHasIdentityScope(values.issuer, rule)) { next[`wifRule.${ri}.conditions`] = isGitHubActionsIssuer(values.issuer) ? 'GitHub Actions rules must include both repository_id and repository_owner_id.' : isGitLabComIssuer(values.issuer) @@ -605,13 +618,34 @@ export function RealmForm({ } function addRule() { - setValues((v) => ({ - ...v, - wifRules: [ - ...v.wifRules, - { role: '', conditions: [{ claim: '', anyOf: '' }] }, - ], - })) + setValues((v) => { + // Ahead of the rules for every project, which must come last. + const at = v.wifRules.findIndex((r) => r.issuerWide) + const end = at === -1 ? v.wifRules.length : at + return { + ...v, + wifRules: [ + ...v.wifRules.slice(0, end), + { role: '', issuerWide: false, conditions: [{ claim: '', anyOf: '' }] }, + ...v.wifRules.slice(end), + ], + } + }) + } + + function setIssuerWide(index: number, issuerWide: boolean) { + setRule(index, (r) => { + // A rule for every project needs no condition: drop the blank ones. + const kept = issuerWide + ? r.conditions.filter((c) => c.claim.trim() !== '' || c.anyOf.trim() !== '') + : r.conditions + return { + ...r, + issuerWide, + conditions: + !issuerWide && kept.length === 0 ? [{ claim: '', anyOf: '' }] : kept, + } + }) } function removeRule(index: number) { @@ -1235,7 +1269,7 @@ export function RealmForm({ {errors.wifRules && {errors.wifRules}} {values.wifRules.map((rule, ri) => ( @@ -1261,6 +1295,33 @@ export function RealmForm({ onChange={(role) => setRule(ri, (r) => ({ ...r, role }))} error={errors[`wifRule.${ri}.role`]} /> + setIssuerWide(ri, c)} + /> + {errors[`wifRule.${ri}.issuerWide`] && ( + {errors[`wifRule.${ri}.issuerWide`]} + )} + {rule.issuerWide && ( + + Every account that can create a project and run a pipeline on{' '} + {values.issuer.trim() === '' ? ( + 'this issuer' + ) : ( + {normalizedIssuer(values.issuer)} + )}{' '} + reads with{' '} + {rule.role.trim() === '' ? 'this role' : {rule.role.trim()}} + , in a personal namespace or a fork too. A self-managed GitLab + with open sign-up is as public as GitLab.com. To let a project + publish, add a trusted publisher on the repository, or a rule + for its project ahead of this one. + + )} {errors[`wifRule.${ri}.conditions`] && ( {errors[`wifRule.${ri}.conditions`]} @@ -1722,12 +1783,22 @@ function isGitLabComIssuer(raw: string): boolean { return normalizedIssuer(raw) === GITLAB_COM_ISSUER } -/** Mirror the server's defense against syntactically non-empty catch-all rules. */ +/** GitHub Actions and GitLab.com: anyone can mint their tokens. */ +function isPublicWifIssuer(raw: string): boolean { + return isGitHubActionsIssuer(raw) || isGitLabComIssuer(raw) +} + +/** + * Mirror the server's defense against syntactically non-empty catch-all + * rules. A rule for every project of a self-managed issuer is scoped by the + * issuer alone. + */ function wifRuleHasIdentityScope( issuer: string, - conditions: Array<{ claim: string }>, + rule: { issuerWide: boolean; conditions: Array<{ claim: string }> }, ): boolean { - const claims = conditions.map((condition) => condition.claim.trim()) + if (rule.issuerWide) return !isPublicWifIssuer(issuer) + const claims = rule.conditions.map((condition) => condition.claim.trim()) if (isGitHubActionsIssuer(issuer)) { return ( claims.includes('repository_id') && @@ -1868,7 +1939,9 @@ export function toRealmConfig(values: RealmFormValues): RealmConfig { claim: cond.claim.trim(), any_of: parseLines(cond.anyOf), })) - return { role: rule.role.trim(), conditions } + return rule.issuerWide + ? { role: rule.role.trim(), issuer_wide: true, conditions } + : { role: rule.role.trim(), conditions } }), } const skew = parseNumber(values.clock_skew_seconds) diff --git a/web/src/routes/admin/realms/realms.test.tsx b/web/src/routes/admin/realms/realms.test.tsx index e4a9ea58..c587913f 100644 --- a/web/src/routes/admin/realms/realms.test.tsx +++ b/web/src/routes/admin/realms/realms.test.tsx @@ -749,6 +749,105 @@ describe('admin realms create', { timeout: 20_000 }, () => { }) }) + it('takes every project of a self-managed issuer with a last, read-only rule', async () => { + let posted: unknown = null + mockApi({ + realms: [], + roles: [ + role({ id: 4, name: 'ci-deployer' }), + role({ id: 5, name: 'readers' }), + ], + onCreate: (body) => { + posted = body + return json(realm({ id: 9, kind: 'workload_identity' }), 201) + }, + }) + + const u = userEvent.setup() + await renderAt('/manage/realms/new') + await u.click(await screen.findByRole('radio', { name: /^Workload identity/ })) + await u.type(screen.getByLabelText('Name'), 'gitlab') + await u.type(screen.getByLabelText('Issuer URL'), 'https://gitlab.example.com') + await u.type(screen.getByLabelText('Allowed audiences'), 'https://forge.example.com') + + await u.click(screen.getByRole('button', { name: 'Add rule' })) + const rolePicker = screen.getByLabelText('Role') + await waitFor(() => + expect(within(rolePicker).getByRole('option', { name: 'readers' })).toBeInTheDocument(), + ) + await u.selectOptions(rolePicker, 'readers') + // Switching it on drops the blank condition: none is required. + await u.click(screen.getByRole('switch', { name: 'Rule 1: every project of this issuer' })) + expect(screen.queryByLabelText('Claim')).toBeNull() + expect( + screen + .getAllByRole('status') + .map((status) => status.textContent) + .some((text) => + text?.includes( + 'Every account that can create a project and run a pipeline on https://gitlab.example.com reads with readers, in a personal namespace or a fork too.', + ), + ), + ).toBe(true) + + // A rule added now goes ahead of it: rules are first-match. + await u.click(screen.getByRole('button', { name: 'Add rule' })) + await u.selectOptions(screen.getAllByLabelText('Role')[0], 'ci-deployer') + await u.type(screen.getByLabelText('Claim'), 'job_project_id') + await u.type(screen.getByLabelText('Any of'), '1001') + await u.click(screen.getByRole('button', { name: 'Create realm' })) + + await waitFor(() => expect(posted).not.toBeNull()) + expect((posted as { config: { rules: unknown } }).config.rules).toEqual([ + { role: 'ci-deployer', conditions: [{ claim: 'job_project_id', any_of: ['1001'] }] }, + { role: 'readers', issuer_wide: true, conditions: [] }, + ]) + }) + + it('refuses a rule for every project ahead of another rule', async () => { + let posted: unknown = null + mockApi({ + realms: [], + onCreate: (body) => { + posted = body + return json(realm(body as Partial), 201) + }, + }) + + const u = userEvent.setup() + await renderAt('/manage/realms/new') + await u.click(await screen.findByRole('radio', { name: /^Workload identity/ })) + await u.type(screen.getByLabelText('Name'), 'gitlab') + await u.type(screen.getByLabelText('Issuer URL'), 'https://gitlab.example.com') + await u.type(screen.getByLabelText('Allowed audiences'), 'https://forge.example.com') + await u.click(screen.getByRole('button', { name: 'Add rule' })) + await u.click(screen.getByRole('button', { name: 'Add rule' })) + await u.type(screen.getAllByLabelText('Role')[0], 'readers') + await u.type(screen.getAllByLabelText('Role')[1], 'ci-deployer') + await u.type(screen.getAllByLabelText('Claim')[1], 'job_project_id') + await u.type(screen.getAllByLabelText('Any of')[1], '1001') + await u.click(screen.getByRole('switch', { name: 'Rule 1: every project of this issuer' })) + await u.click(screen.getByRole('button', { name: 'Create realm' })) + + expect( + await screen.findByText(/a rule for every project comes after every other rule/), + ).toBeInTheDocument() + expect(posted).toBeNull() + }) + + it('offers no rule for every project of GitLab.com', async () => { + mockApi({ realms: [] }) + + const u = userEvent.setup() + await renderAt('/manage/realms/new') + await u.click(await screen.findByRole('radio', { name: /^Workload identity/ })) + await u.type(screen.getByLabelText('Issuer URL'), 'https://gitlab.com/') + await u.click(screen.getByRole('button', { name: 'Add rule' })) + expect( + screen.getByRole('switch', { name: 'Rule 1: every project of this issuer' }), + ).toBeDisabled() + }) + it('validates required workload_identity fields inline and does not POST', async () => { let posted: unknown = null mockApi({