diff --git a/README.md b/README.md index 94fdb71..10657f4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ github-app = "deployments" repositories = ["myorg/alfa", "myorg/beta"] role = "github-workflow" required-claims = { repository = "myorg/gamma" } +permissions = { contents = "read" } ``` See `idcat.toml.example` for a fuller configuration with multiple roles and GitHub Apps. @@ -55,8 +56,10 @@ combination, with additional required token claims. Set either `repository = "ow or `repositories = ["owner/name", "owner/other"]`; the request may match any configured repository pattern. For example, a request for `deployments` on `myorg/alfa` can require the token to satisfy `github-workflow` and also -carry `repository = "myorg/gamma"`. App-level `allowed-roles` still grant access to every -repository installation for that GitHub App. +carry `repository = "myorg/gamma"`. Every `[[installation-policy]]` must also declare a +non-empty `permissions` table naming the GitHub permissions the minted token gets, so a +policy can never hand out the App's full installation permissions. App-level +`allowed-roles` still grant access to every repository installation for that GitHub App. Mount the private keys as files. For example, in Kubernetes this could be a Secret volume mounted at `private-key-directory`, but `idcat` only reads files from the filesystem. diff --git a/idcat.toml.example b/idcat.toml.example index cf88b06..926438f 100644 --- a/idcat.toml.example +++ b/idcat.toml.example @@ -69,6 +69,9 @@ role = "github-workflow" [installation-policy.required-claims] repository = "myorg/gamma" +[installation-policy.permissions] +contents = "read" + [[installation-policy]] github-app = "deployments" repositories = ["myorg/beta", "myorg/delta"] @@ -77,6 +80,9 @@ role = "github-workflow" [installation-policy.required-claims] repository = "myorg/epsilon" +[installation-policy.permissions] +contents = "write" + # `repository` and each `repositories` entry accepts a glob where `*` matches # any characters (including `/`); so `"myorg/*"` matches any repo under # `myorg/`, `"*"` matches any repo at all, and a literal like `"myorg/alfa"` @@ -95,8 +101,12 @@ repository = "myorg/*" role = "github-workflow" allow-self-access = true -# A `[installation-policy.permissions]` table down-scopes what the minted -# token can DO. Absent/empty means the App's full installation permissions. +[installation-policy.permissions] +contents = "read" + +# The `[installation-policy.permissions]` table is required and must name at +# least one permission — it down-scopes what the minted token can DO, so +# there is no way to ask for the App's full installation permissions. # Keys are GitHub permission names in snake_case (forwarded to GitHub verbatim # — deliberately NOT kebab-case like the rest of this config); values are # read/write/admin. Unrecognised names or values only emit a startup warning diff --git a/src/config.rs b/src/config.rs index d0dabda..aa25ba9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -100,7 +100,6 @@ struct RawInstallationPolicyConfig { #[serde(default)] allow_self_access: bool, // Keys are GitHub permission names (snake_case), not kebab-case. - #[serde(default)] permissions: BTreeMap, } @@ -331,6 +330,14 @@ impl Config { ); } } + if installation_policy.permissions.is_empty() { + anyhow::bail!( + "installation-policy for github-app '{}' repository '{}' role '{}' must define at least one permission", + installation_policy.github_app, + installation_policy.repositories_label(), + installation_policy.role + ); + } for (name, value) in &installation_policy.permissions { if !known_github_permissions().contains(name.as_str()) { warn!( @@ -418,6 +425,7 @@ secret-key = "private-key.pem" github-app = "deployments" repository = "myorg/*" role = "github-workflow" +permissions = { contents = "read" } [installation-policy.required-claims] environment = "production" @@ -449,6 +457,7 @@ github-app = "deployments" repository = "myorg/*" role = "github-workflow" allow-self-access = true +permissions = { contents = "read" } "#, ) .unwrap(); @@ -477,6 +486,7 @@ github-app = "deployments" repository = "*" role = "github-workflow" allow-self-access = true +permissions = { contents = "read" } "#, ) .unwrap(); @@ -505,6 +515,7 @@ github-app = "deployments" repository = "myorg/alfa" role = "github-workflow" allow-self-access = true +permissions = { contents = "read" } "#, ) .unwrap(); @@ -535,6 +546,7 @@ secret-key = "private-key.pem" github-app = "deployments" repositories = ["myorg/alfa", "myorg/bravo"] role = "github-workflow" +permissions = { contents = "read" } [installation-policy.required-claims] repository = "myorg/gamma" @@ -570,6 +582,7 @@ github-app = "deployments" repository = "myorg/alfa" repositories = ["myorg/bravo"] role = "github-workflow" +permissions = { contents = "read" } [installation-policy.required-claims] repository = "myorg/gamma" @@ -603,6 +616,7 @@ secret-key = "private-key.pem" [[installation-policy]] github-app = "deployments" role = "github-workflow" +permissions = { contents = "read" } [installation-policy.required-claims] repository = "myorg/gamma" @@ -637,6 +651,7 @@ secret-key = "private-key.pem" github-app = "deployments" repositories = [] role = "github-workflow" +permissions = { contents = "read" } [installation-policy.required-claims] repository = "myorg/gamma" @@ -672,6 +687,7 @@ github-app = "deployments" repository = "myorg/*" role = "github-workflow" allow-self-access = true +permissions = { contents = "read" } [installation-policy.required-claims] repository = "myorg/alfa" @@ -706,6 +722,7 @@ secret-key = "private-key.pem" github-app = "deployments" repository = "myorg/alfa" role = "github-workflow" +permissions = { contents = "read" } [installation-policy.required-claims] repository = ["myorg/alfa", "myorg/bravo"] @@ -747,6 +764,7 @@ secret-key = "private-key.pem" github-app = "deployments" repository = "owner-only-no-slash" role = "github-workflow" +permissions = { contents = "read" } [installation-policy.required-claims] repository = "myorg/gamma" @@ -860,7 +878,40 @@ pull_requests = "write" } #[test] - fn installation_policy_permissions_default_empty_when_absent() { + fn rejects_installation_policy_without_permissions() { + let error = toml::from_str::( + r#" +[[role]] +name = "github-workflow" +audience = "idcat" +issuer = "https://token.actions.githubusercontent.com" +validation-key = "shared-secret" +algorithms = ["HS256"] + +[[github-app]] +name = "deployments" +app-id = 42 +secret-key = "private-key.pem" + +[[installation-policy]] +github-app = "deployments" +repository = "myorg/alfa" +role = "github-workflow" + +[installation-policy.required-claims] +repository = "myorg/gamma" +"#, + ) + .expect_err("installation-policy without permissions must be rejected"); + + assert!( + error.to_string().contains("missing field `permissions`"), + "expected missing permissions error, got: {error}" + ); + } + + #[test] + fn rejects_installation_policy_with_empty_permissions() { let config: Config = toml::from_str( r#" [[role]] @@ -879,6 +930,7 @@ secret-key = "private-key.pem" github-app = "deployments" repository = "myorg/alfa" role = "github-workflow" +permissions = {} [installation-policy.required-claims] repository = "myorg/gamma" @@ -886,8 +938,14 @@ repository = "myorg/gamma" ) .unwrap(); - let policy = &config.installation_policies[0]; - assert!(policy.permissions.is_empty()); + let error = config + .validate(false) + .expect_err("empty permissions table must be rejected"); + + assert_eq!( + error.to_string(), + "installation-policy for github-app 'deployments' repository 'myorg/alfa' role 'github-workflow' must define at least one permission" + ); } #[test] @@ -1144,6 +1202,7 @@ secret-key = "private-key.pem" github-app = "deployments" repository = "myorg/alfa" role = "github-workflow" +permissions = { contents = "read" } [installation-policy.required-claims] repository = "myorg/gamma" @@ -1221,6 +1280,7 @@ secret-key = "private-key.pem" github-app = "deployments" repository = "myorg/alfa" role = "github-workflow" +permissions = { contents = "read" } "#, ) .unwrap();