diff --git a/crates/db/tests/migrations.rs b/crates/db/tests/migrations.rs index de07c427..6c41369e 100644 --- a/crates/db/tests/migrations.rs +++ b/crates/db/tests/migrations.rs @@ -1107,6 +1107,64 @@ async fn readers_before_the_upgrade_get_the_reader_role() { .await .expect("an administrator's role named reader"); assign(service, "reader").await; + // Workload-identity realms name their rules' roles: one names the + // administrator's `reader` (twice, between other rules), one does not. + let realm = |name: &'static str, issuer: &'static str, rules: serde_json::Value| { + let pool = pool.clone(); + async move { + sqlx::query_scalar::<_, i64>( + "INSERT INTO auth_realm (kind, name, priority, config, updated_at) \ + VALUES ('workload_identity', $1, \ + (SELECT coalesce(max(priority), 0) + 10 FROM auth_realm), $2, \ + '2026-01-01T00:00:00Z') \ + RETURNING id", + ) + .bind(name) + .bind(serde_json::json!({ + "issuer": issuer, + "allowed_audiences": ["forge"], + "rules": rules, + })) + .fetch_one(&pool) + .await + .expect("insert a workload-identity realm") + } + }; + let rule = |role: &str, project: &str| { + serde_json::json!({ + "role": role, + "conditions": [{ "claim": "project_path", "equals": project }], + }) + }; + let naming = realm( + "gitlab", + "https://gitlab.example.com", + serde_json::json!([ + rule("reader", "group/a"), + rule("ci-pub", "group/b"), + rule(" reader ", "group/c"), + ]), + ) + .await; + let other = realm( + "github", + "https://token.actions.githubusercontent.com", + serde_json::json!([rule("ci-pub", "org/repo")]), + ) + .await; + let config = |realm_id: i64| { + let pool = pool.clone(); + async move { + sqlx::query_as::<_, (serde_json::Value, chrono::DateTime)>( + "SELECT config, updated_at FROM auth_realm WHERE id = $1", + ) + .bind(realm_id) + .fetch_one(&pool) + .await + .expect("read a realm") + } + }; + let (other_before, other_updated) = config(other).await; forge_db::migrate(&pool).await.expect("migrate the rest"); @@ -1144,6 +1202,35 @@ async fn readers_before_the_upgrade_get_the_reader_role() { .await .expect("the built-in reader role"); assert!(builtin); + + // The rules that named the administrator's role name it still, under + // its new name, in their order; the other rules and realms are unchanged. + let (rewritten, updated) = config(naming).await; + let roles: Vec<&str> = rewritten["rules"] + .as_array() + .expect("the rules") + .iter() + .map(|rule| rule["role"].as_str().expect("a rule's role")) + .collect(); + assert_eq!(roles, [renamed.as_str(), "ci-pub", renamed.as_str()]); + assert_eq!( + rewritten["rules"][1], + rule("ci-pub", "group/b"), + "a rule naming another role is kept as it was" + ); + assert_eq!( + rewritten["rules"][0]["conditions"], + rule("reader", "group/a")["conditions"], + "a rewritten rule keeps its conditions" + ); + assert_eq!(rewritten["issuer"], "https://gitlab.example.com"); + let before = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z").unwrap(); + assert!(updated > before, "the rewritten realm records the change"); + assert_eq!( + config(other).await, + (other_before, other_updated), + "a realm whose rules name no `reader` is untouched" + ); } /// A Postgres server for one test: a throwaway Postgres 18 container matching diff --git a/docs/changes/2.2.0/749.upgrade.md b/docs/changes/2.2.0/749.upgrade.md new file mode 100644 index 00000000..016b8399 --- /dev/null +++ b/docs/changes/2.2.0/749.upgrade.md @@ -0,0 +1,10 @@ +A workload-identity rule that named a role an administrator had named +`reader` names it under its new name, `reader-`, too, so the CI jobs it +matches keep that role's grants instead of the built-in `reader`'s (every +repository). The keys those realms' jobs exchanged before the upgrade stop +working with the change; the jobs exchange their ID tokens again. A rule +that names `reader` while no role of that name exists, for example because +the role was deleted, refused the jobs it matched until now; from the +upgrade on it grants the built-in `reader`, every repository. Before +upgrading, check the rules of each workload-identity realm under **Admin → +Authentication**, and point such a rule at a role that exists or remove it. diff --git a/migrations/0013_reader_role.sql b/migrations/0013_reader_role.sql index ed0039de..e570c9af 100644 --- a/migrations/0013_reader_role.sql +++ b/migrations/0013_reader_role.sql @@ -8,13 +8,44 @@ -- mode `off`, for an account without one. That level stays, as Roles only. -- -- A role an administrator named `reader` keeps its grants and members under --- the name `reader-`. +-- the name `reader-`. The rules of a workload-identity realm name their +-- role (`rules[].role`), resolved by name on every CI token: a rule that +-- named that role would grant the built-in `reader`, every repository, from +-- now on, so it names `reader-` too (#749). The realm's policy digest +-- changes with it, which ends the keys its jobs exchanged; they kept the +-- renamed role's id and exchange again. A rule that names `reader` while no +-- role of that name exists (a deleted one) refused its jobs and grants the +-- built-in `reader` from now on: there is no role to point it at, so the +-- upgrade notes ask administrators to check their rules first. -- -- Every user that is neither an administrator nor a writer was a reader and -- gets the role, so they read every repository from now on. Service accounts -- do not: each stands for one pipeline or network, whose roles were chosen -- for it, and they become Roles only. -- --------------------------------------------------------------------------- +UPDATE auth_realm realm +SET config = jsonb_set( + realm.config, + '{rules}', + (SELECT jsonb_agg( + CASE WHEN btrim(rule ->> 'role') = 'reader' + THEN jsonb_set(rule, '{role}', to_jsonb('reader-' || custom.id)) + ELSE rule + END + ORDER BY ordinal) + FROM jsonb_array_elements(realm.config -> 'rules') + WITH ORDINALITY AS rules (rule, ordinal))), + updated_at = now() +FROM role custom +WHERE custom.name = 'reader' AND NOT custom.builtin + AND realm.kind = 'workload_identity' + AND jsonb_typeof(realm.config -> 'rules') = 'array' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(realm.config -> 'rules') AS named (rule) + WHERE btrim(named.rule ->> 'role') = 'reader' + ); + UPDATE role SET name = 'reader-' || id WHERE name = 'reader' AND NOT builtin; INSERT INTO role (name, description, builtin) VALUES