diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8eb9e..d35e5f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to Jarl are documented here. The format follows ## [Unreleased] +### Added + +- A `certificates` role (template): `certificates:write`, for tokens and + users that get certificates for other hosts with `jarl cert get`. In + the web UI's user and token forms it is in "Insert a template…", to + narrow with `names=`; in the CLI, `--role certificates`. + ## [0.6.1] - 2026-10-20 ### Added diff --git a/cmd/jarl/admin_cmds.go b/cmd/jarl/admin_cmds.go index 5a1a219..c440352 100644 --- a/cmd/jarl/admin_cmds.go +++ b/cmd/jarl/admin_cmds.go @@ -392,7 +392,8 @@ ACCESS is read or write, and filters take comma separated glob patterns: records:write zones=adbraun.lol names=_acme-challenge.* types=TXT *:read -Roles are ready-made grant sets: readonly, dns-editor, operator, acme.` +Roles are ready-made grant sets: readonly, dns-editor, operator, acme, +certificates.` func userCmd() *cobra.Command { cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp} @@ -477,7 +478,7 @@ func userCmd() *cobra.Command { } addUserFlags := func(c *cobra.Command) { c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)") - c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme") + c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator, acme or certificates") c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`) c.Flags().StringVar(&comment, "comment", "", "comment") c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)") @@ -758,7 +759,7 @@ func tokenCmd() *cobra.Command { }, } create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)") - create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator or acme") + create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator, acme or certificates") create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)") create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)") create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file") diff --git a/docs/cli.md b/docs/cli.md index 3e86ff6..ded7dbf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -537,6 +537,7 @@ jarl token list --all --expiring [60d] # expiring within 14 days jarl token create laptop --expires 365d jarl token create backup-script --grant "*:read" jarl token create acme --user certbot --role acme +jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host (or --role certificates) jarl token rotate laptop --save # new secret, same token and grants jarl token rotate --current --save --grace 10m # the token this CLI uses jarl token revoke 7 diff --git a/docs/permissions.md b/docs/permissions.md index 8d4b757..fc68a62 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -185,6 +185,7 @@ you can then edit. | `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` | | `operator` | `*:write` (everything except user management) | | `acme` | `records:write names=_acme-challenge.* types=TXT` | +| `certificates` | `certificates:write`: certificates for other hosts (`jarl cert get`) for any name in Jarl's zones. Narrow it to a host's names, e.g. `certificates:write names=web01.adbraun.lol` ([certificates.md](certificates.md#certificates-for-other-hosts)) | ```sh jarl user create alice --role dns-editor @@ -210,6 +211,7 @@ The audit log shows the user and the token name. jarl token create laptop # full rights of the user, no expiry jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d jarl token create certbot --user certbot --role acme # admin: a token for another user +jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host jarl token list --all jarl token revoke 12 ``` diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 9793f9b..6262489 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -135,6 +135,16 @@ func TestRoleTemplatesValid(t *testing.T) { } } +func TestCertificatesRole(t *testing.T) { + p := &Principal{User: &model.User{Grants: RoleTemplates["certificates"]}} + if !p.Can(Action{Resource: ResCerts, Write: true, Name: "web01.adbraun.lol"}) { + t.Error("the certificates role can't get a certificate") + } + if p.Can(Action{Resource: ResRecords, Write: true, Zone: "adbraun.lol."}) || p.Can(Action{Resource: ResZones, Zone: "adbraun.lol."}) { + t.Error("the certificates role reaches beyond certificates") + } +} + func TestCertificateGrants(t *testing.T) { g, err := ParseGrant("certificates:write names=*.adbraun.lol") if err != nil { diff --git a/internal/auth/grants.go b/internal/auth/grants.go index 81ef94c..e80e0a3 100644 --- a/internal/auth/grants.go +++ b/internal/auth/grants.go @@ -391,4 +391,7 @@ var RoleTemplates = map[string][]model.Grant{ }, "operator": {{Resource: ResAll, Access: Write}}, "acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}}, + // certificates for other hosts (jarl cert get): narrow it with names= + // to the host's own. + "certificates": {{Resource: ResCerts, Access: Write}}, } diff --git a/internal/web/static/assets/pages/users.js b/internal/web/static/assets/pages/users.js index eaaf046..301281e 100644 --- a/internal/web/static/assets/pages/users.js +++ b/internal/web/static/assets/pages/users.js @@ -195,7 +195,8 @@ async function rotateDialog(t, redraw) { function coveredBy(user, grant) { if (!user || user.admin) return true; const [res, acc] = grant.split(/\s+/)[0].split(":"); - return (user.grants || []).some((g) => (g.resource === "*" || g.resource === res) && (g.access === "write" || acc === "read")); + // "*" never covers certificates (they hand out private keys). + return (user.grants || []).some((g) => ((g.resource === "*" && res !== "certificates") || g.resource === res) && (g.access === "write" || acc === "read")); } // newTokenForm makes a token for owner (the user it belongs to). Its