From 0e958289def617710cf823e8e2fc30e7309e6144 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Thu, 22 Oct 2026 23:49:12 +0100 Subject: [PATCH] Web UI: administrators make tokens for other users New token on a user's row under Users, with the Account page's dialog (now tokenDialog in users.js), offering only roles within that user's permissions. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ docs/permissions.md | 10 ++++-- internal/server/server_test.go | 27 ++++++++++++++ internal/web/static/assets/pages/account.js | 36 +++---------------- internal/web/static/assets/pages/users.js | 39 ++++++++++++++++++++- 5 files changed, 79 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dffff2a..b004649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to Housecarl are documented here. The format follows ### Added +- An administrator can make an API token for another user in the web + UI: New token on the user's row under Users (the CLI's `hc token create + --user` and the API could already). - **Ban lists** ([docs/bans.md](docs/bans.md)). A program (Guard, which reads logs, or fail2ban) bans an address, of every port or of some, for a time; the ban list says what the address is kept from: the diff --git a/docs/permissions.md b/docs/permissions.md index c147a86..9876438 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -54,9 +54,13 @@ hc token create portal --user portal --grant sets:write:guests --expires 365d ``` The secret is shown once; only its hash is stored. A restricted token can -only create tokens with a subset of its own grants. The web UI only offers -roles within what your account may do; a token with more would get no -more than its user anyway. +only create tokens with a subset of its own grants. + +In the web UI, Account → New token makes one of your own; an +administrator makes one for another user with New token on that user's +row under Users (`--user` in the CLI, `POST /users/{id}/tokens` in the +API). The dialog only offers roles within what the token's user may do; a +token with more would get no more than its user anyway. **Rotating** gives a token a new secret, keeping its ID, name and permissions, so only the programs that use it need updating: diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 21b2cd7..bd4badc 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1335,3 +1335,30 @@ func TestCreatePlaced(t *testing.T) { t.Errorf("%d rules", n) } } + +// TestTokenForUser: an administrator makes a token for another user (the +// web UI's New token on a user's row); that user can't make one for +// someone else. +func TestTokenForUser(t *testing.T) { + e := newEnv(t) + var u api.User + if code := e.call(e.token, "POST", "/users", api.UserInput{Username: ptr("portal"), Grants: &[]string{"sets:write"}}, &u); code != 201 { + t.Fatalf("user: %d", code) + } + var tok api.Token + if code := e.call(e.token, "POST", "/users/"+itoa(u.ID)+"/tokens", api.TokenInput{Name: "portal", ExpiresIn: "30d"}, &tok); code != 201 || tok.Secret == "" || tok.UserID != u.ID { + t.Fatalf("token for portal: %d %+v", code, tok) + } + if code := e.call(tok.Secret, "GET", "/rules", nil, nil); code != 403 { + t.Errorf("the token can do more than its user: %d", code) + } + var users []api.User + e.call(e.token, "GET", "/users", nil, &users) + for _, o := range users { + if o.ID != u.ID { + if code := e.call(tok.Secret, "POST", "/users/"+itoa(o.ID)+"/tokens", api.TokenInput{Name: "x"}, nil); code != 403 { + t.Errorf("a token for another user, made by a non-administrator: %d", code) + } + } + } +} diff --git a/internal/web/static/assets/pages/account.js b/internal/web/static/assets/pages/account.js index bb53659..c0a9463 100644 --- a/internal/web/static/assets/pages/account.js +++ b/internal/web/static/assets/pages/account.js @@ -1,8 +1,8 @@ // Your account: password and API tokens. -import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js"; -import { get, api, state, catalog, can } from "../api.js"; +import { h, mount, table, time, input, field, errorBox, toast } from "../ui.js"; +import { get, api, state, catalog } from "../api.js"; import { rerender } from "../app.js"; -import { revoke, rotateDialog } from "./users.js"; +import { revoke, rotateDialog, tokenDialog } from "./users.js"; export async function render(el) { const me = state.me; @@ -30,7 +30,7 @@ export async function render(el) { h("div", { class: "panel" }, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"), h("div", { class: "muted" }, "For the hc CLI and scripts: 'hc config set token'.")), - h("button", { onclick: () => newToken(cat) }, "New token")), + h("button", { onclick: () => tokenDialog(cat) }, "New token")), table([ { label: "Name", render: (t) => t.name }, { label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) }, @@ -41,31 +41,3 @@ export async function render(el) { h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] }, ], tokens, { empty: "No tokens." }))); } - -async function newToken(cat) { - const name = input({ placeholder: "laptop, backup script…" }); - // Only roles within what the user may do: a token can never do more - // than its user, so offering more would only mislead. - const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => { - const [res, acc] = g.split(":"); - return can(res, acc === "write"); - })); - const role = select([["", "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], ""); - const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d"); - const err = h("div"); - await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [ - { label: "Cancel", value: null }, - { label: "Create", kind: "primary", onclick: async () => { - mount(err); - try { - const t = await api("POST", "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } }); - await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again."), copyBox(t.secret))); - rerender(); - return true; - } catch (e) { - mount(err, errorBox(e)); - return false; - } - } }, - ]); -} diff --git a/internal/web/static/assets/pages/users.js b/internal/web/static/assets/pages/users.js index 7384a87..0167753 100644 --- a/internal/web/static/assets/pages/users.js +++ b/internal/web/static/assets/pages/users.js @@ -1,6 +1,6 @@ // Users and everyone's tokens (administrators). import { h, mount, table, time, fmtTime, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge, copyBox } from "../ui.js"; -import { get, api, catalog } from "../api.js"; +import { get, api, catalog, can } from "../api.js"; import { rerender } from "../app.js"; export async function render(el) { @@ -16,6 +16,7 @@ export async function render(el) { { label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : "never" }, { label: "Comment", render: (u) => u.comment }, { label: "", tdClass: "actions-cell", render: (u) => [ + h("button", { class: "small reveal", onclick: () => tokenDialog(cat, u) }, "New token"), " ", h("button", { class: "small reveal", onclick: () => userDialog(u, cat) }, "Edit"), " ", h("button", { class: "small reveal", onclick: () => del(u) }, "Delete")] }, ], users)), @@ -32,6 +33,42 @@ export async function render(el) { ], tokens, { empty: "No tokens." }))); } +// tokenDialog makes an API token: the viewer's own, or (administrators) +// another user's. It shows the secret once. +export async function tokenDialog(cat, user) { + const name = input({ placeholder: "laptop, backup script…" }); + // Only roles within what the user may do: a token can never do more + // than its user, so offering more would only mislead. + const may = (res, write) => { + if (!user) return can(res, write); + if (user.admin) return true; + const gs = user.grant_strings || []; + return gs.includes(res + ":write") || !write && gs.includes(res + ":read"); + }; + const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => { + const [res, acc] = g.split(":"); + return may(res, acc === "write"); + })); + const role = select([["", user ? "everything " + user.username + " may do" : "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], ""); + const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d"); + const err = h("div"); + await modal(user ? "New API token for " + user.username : "New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [ + { label: "Cancel", value: null }, + { label: "Create", kind: "primary", onclick: async () => { + mount(err); + try { + const t = await api("POST", user ? "/users/" + user.id + "/tokens" : "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } }); + await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again." + (user ? " Give it to " + user.username + "; 'hc config set token' sets it in the CLI." : "")), copyBox(t.secret))); + rerender(); + return true; + } catch (e) { + mount(err, errorBox(e)); + return false; + } + } }, + ]); +} + // rotateDialog gives a token a new secret. It keeps its name, ID and // permissions; only the programs that use it need the new secret. export async function rotateDialog(t) {