diff --git a/CHANGELOG.md b/CHANGELOG.md index c048faf..7d6f977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ All notable changes to Jarl are documented here. The format follows - A linked zone's Compare views tab starts with a view it is linked to, not the first other view. +- The New token form only offers templates within what the token's user + may do. A token never does more than its user, so offering more only + misled. ## [0.6.0] - 2026-10-13 diff --git a/internal/web/static/assets/pages/account.js b/internal/web/static/assets/pages/account.js index e6473f4..5df84bf 100644 --- a/internal/web/static/assets/pages/account.js +++ b/internal/web/static/assets/pages/account.js @@ -9,7 +9,7 @@ export async function render(el) { const tokensEl = h("div"); const drawTokens = async () => { const tokens = await get("/me/tokens"); - mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens)); + mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens, { admin: me.admin, grants: me.user.grants })); }; const cur = input({ type: "password", autocomplete: "current-password" }); const pw1 = input({ type: "password", autocomplete: "new-password" }); diff --git a/internal/web/static/assets/pages/users.js b/internal/web/static/assets/pages/users.js index c18473d..8141cb4 100644 --- a/internal/web/static/assets/pages/users.js +++ b/internal/web/static/assets/pages/users.js @@ -104,7 +104,7 @@ export async function tokensDialog(u, grants, own) { const base = own ? "/me/tokens" : "/users/" + u.id + "/tokens"; const draw = async () => { const tokens = await get(base); - mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw)); + mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw, u)); }; await draw(); await modal("API tokens of " + u.username, box, [{ label: "Close", value: null }], { wide: true }); @@ -185,13 +185,26 @@ async function rotateDialog(t, redraw) { await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true }); } -export function newTokenForm(base, grants, redraw) { +// coveredBy reports whether a user (admin, or grants) may do what a grant +// string ("records:write names=..." ) allows, by resource and access. A +// filter on either side only narrows the token further, so it is ignored. +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")); +} + +// newTokenForm makes a token for owner (the user it belongs to). Its +// templates are only the roles within what owner may do: a token never +// does more than its user, so offering more would only mislead. +export function newTokenForm(base, grants, redraw, owner) { const err = h("div"); const out = h("div"); const name = input({ placeholder: "e.g. certbot on web01" }); const exp = select([["", "never expires"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "1 year"]], "90d"); const gr = textarea("", { class: "mono", rows: 3, placeholder: "optional restriction, e.g.\nrecords:write zones=example.com names=_acme-challenge.* types=TXT" }); - const tmpl = select([["", "Insert a template…"], ...Object.keys(grants.roles).map((r) => [r, r])], ""); + const roles = Object.keys(grants.roles).filter((r) => grants.roles[r].every((g) => coveredBy(owner, g))); + const tmpl = select([["", "Insert a template…"], ...roles.map((r) => [r, r])], ""); tmpl.addEventListener("change", () => { if (!tmpl.value) return; gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n"); @@ -203,7 +216,7 @@ export function newTokenForm(base, grants, redraw) { field("Name", name), field("Expires", exp), field("Restrict to", gr, "Leave empty for all of the user's permissions. A token can never do more than its user."), - h("span"), tmpl), + roles.length ? [h("span"), tmpl] : null), err, h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => { mount(err); mount(out);