diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index d9ee64c171..3cf678acda 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -13,9 +13,9 @@ - Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI. - Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation. - When public registration is disabled, invitation-based password registration also requires a matching, non-expired `team_invitation` row; registration revalidates it under a row lock before creating the profile. -- Access tokens (`app.http.access-token`): personal tokens have JWT issuer `access-token` and DB type other than `mcp`; MCP tokens have issuer `urn:penpot:mcp-token` (`actoken/mcp-token-issuer`) and DB type `mcp`. Authentication requires issuer and type to agree, and the claim `uid` to match the row's profile; other issuers and undecryptable tokens are treated as anonymous. -- Personal tokens need flag `:access-tokens`. MCP tokens need `:mcp` or `:access-tokens`, and only on routes whose `actoken/authz` gets `::actoken/allow-mcp true` (the RPC routes); elsewhere (e.g. assets) they are anonymous. -- On RPC, an MCP token reaches only commands with `::rpc/token-type "mcp"` (`wrap-authentication` in `app.rpc`); every other command answers 403 `:token-auth-required`. A command with that metadata also rejects sessions and personal tokens. Currently only `send-mcp-feedback`. Blocking a profile invalidates sessions, not tokens, so such commands check `:is-blocked`/`:is-active` themselves. +- Access tokens (`app.http.access-token`): personal tokens have JWT issuer `access-token` and DB type other than `mcp`; MCP tokens have issuer `urn:penpot:mcp-token` (`actoken/mcp-token-issuer`) and DB type `mcp`; admin tokens have issuer `access-token` and DB type `admin`. Authentication requires issuer and type to agree, and the claim `uid` to match the row's profile; other issuers and undecryptable tokens are treated as anonymous. +- Personal tokens need flag `:access-tokens`. MCP tokens need `:mcp` or `:access-tokens`, and only on routes whose `actoken/authz` gets `::actoken/allow-mcp true` (the RPC routes); elsewhere (e.g. assets) they are anonymous. Admin tokens ride the personal-token path (same issuer, same middleware) and are distinguished only by `type`: the `::get-admin-tokens` listing filters by it, and `::revoke-admin-token` refuses any other type. +- On RPC, an MCP token reaches only commands with `::rpc/token-type "mcp"` (`wrap-authentication` in `app.rpc`); every other command answers 403 `:token-auth-required`. A command with that metadata also rejects sessions and personal tokens. Currently only `send-mcp-feedback`. Blocking a profile invalidates sessions, not tokens, so such commands check `:is-blocked`/`:is-active` themselves. Admin granular tokens are the second exception: the `admin` wrapper branch requires a live owner (active, unblocked, not deleted) on top of the implicit perm. - HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`. ## Permission model @@ -25,6 +25,7 @@ - File/project role rows only count with a live team membership: the file/project permission queries require a matching `team_profile_rel` row, and leaving or removing a member deletes their team-scoped `file`/`project`/`team-project-profile-rel` rows (GHSA-v9r9-h77c-55m2). - File/project/comment checks are implemented in the owning command namespaces, often via helpers imported from `files`, `teams`, or `projects`; do not bypass those helpers with direct DB lookups unless preserving their not-found semantics. - Comment permission includes both logged-in state and the file/team comment policy. Shared viewer paths may pass `share-id`; preserve that path when changing comment queries. +- Admin authorization is not predicate-based: the `admin` RPC module derives an implicit `admin:` permission per call in `wrap-authentication` (vocabulary in `app.rpc.admin.perms`, issuance in `app.rpc.admin.tokens`). `superuser` bypasses everything; granular access is token-only with a live owner. Unlike product checks, admin denials use explicit authorization codes (`:superuser-required`, `:missing-perms`, `:profile-blocked`, `:profile-inactive`). ## Teams, projects, and invitations diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index c84e1a6f69..111abc4c2d 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -15,7 +15,16 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m ## Stable namespace map - `app.rpc.commands.*`: RPC command implementations, all reachable under `/api/main/methods/`. `/api/rpc/command/` is the deprecated alias of `/api/main/methods`; it still exists because the exporter calls it. -- `app.rpc.admin.*`: superuser-guarded admin commands under `/api/admin/methods/` (same wrapper as `main`, short names, no doc routes). Binary file transfer (`file-export`, `file-import`) lives in `app.http.admin` as plain HTTP routes next to the methods, not as commands. +- `app.rpc.admin.*`: admin commands under `/api/admin/methods/` + (same wrapper as `main`, short names, no doc routes). Guarded by an + implicit per-method permission (`admin:`, derived in + `wrap-authentication`, never declared); `superuser` bypasses every + check. `app.rpc.admin.perms` owns the vocabulary + (`admin-namespaces`, `grantable-perms`); `app.rpc.admin.tokens` + (issue/list/revoke/get-permissions, root-only) is the only ns + allowed an explicit `superuser` requirement. `export-files` / + `import-files` are plain admin commands answering with a download + stream, not separate HTTP routes. - `app.rpc.permissions`: permission predicate/check helper factories. - `app.http.*`: HTTP routes and middleware. - `app.auth.*`: provider-specific authentication helpers such as LDAP/OIDC. diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index cbd8250609..15f9e691ab 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -10,9 +10,9 @@ - Params schemas are open by default, so undeclared client keys reach the handler unless the map is `:closed true`. Creation commands (`create-file`, `create-project`, `create-team`, `create-team-with-invitations`, `upload-file-media-object`, `create-file-media-object-from-url`, `assemble-file-media-object`) use closed schemas: a client-provided `:id` fails with `:params-validation`. Their internal creation functions still accept an optional explicit `:id` for imports, duplicates and deterministic test fixtures. - Nil RPC bodies become HTTP 204 unless explicit status metadata is present. Stream bodies default to `application/octet-stream` when no content type is set. - The upload schema (`media.v/schema:upload`) takes a `:path` from the caller, and a Transit body can tag any string as a `path`, so a command that reads such an upload reads a file the client named. `create-import-binfile-job` avoids that boundary by accepting only a chunked upload (`:upload-id`); the commands that still take a plain file (the legacy `import-binfile`, the media and font uploads) trust it, and the guard belongs where uploads are accepted, not in each command. -- Three RPC modules live under `/api`, each with its own method map in `app/rpc.clj`: `main` (product, session or access token), `management` (machine callers, shared key), and `admin` (superuser-guarded product operations for the `/admin` panel, short command names, no doc routes). The admin file export/import transfer routes live next to the admin methods as plain HTTP handlers, not commands, behind the same superuser rule. -- The superuser check is a branch of `wrap-authentication`, not something each command repeats, so a new admin command cannot forget it. It runs after authentication and before params validation, so a non-superuser gets 403 `:superuser-required` without the rejection leaking which params the command expects. The rule itself is factored as `auth/superuser-allowed?`, which the admin HTTP transfer routes call to enforce exactly the same check outside the wrapper. -- `::perms` entries other than `"superuser"` keep their token-only meaning: a session calling them gets `token-auth-required`, exactly as before. `"superuser"` is the only permission a session can satisfy, by id membership in the `:app.auth/superusers` registry. A token can also satisfy it by carrying `"superuser"` as an operator-granted scope, for profiles the registry does not list; scopes are granted outside the public API, so this opens nothing by itself. The profile flags (`get-profile`, login) stay registry-based: the flag says who you are, the guard says what the call carries. +- Three RPC modules live under `/api`, each with its own method map in `app/rpc.clj`: `main` (product, session or access token), `management` (machine callers, shared key), and `admin` (superuser-rooted operations for the `/admin` panel, short command names, no doc routes). `export-files` / `import-files` are plain admin commands answering with a download stream, not separate HTTP routes. +- The `admin` guard is a branch of `wrap-authentication`, not something each command repeats, so a new admin command cannot forget it. Every admin method carries an implicit `admin:` permission derived from its registered name (`app.rpc.admin.perms/method-perm`): superuser (registry, `"superuser"` scope, or any profile on `devenv`) bypasses it; a non-superuser session is rejected as before; an access token must carry the implicit perm. Methods declare no `::perms` of their own — except `app.rpc.admin.tokens` (issue/list/revoke/get-permissions), which keeps an explicit `"superuser"` requirement so a granular token can never amplify itself. A registry test pins this invariant (`rpc-admin-perms-test`). Token calls additionally require a live owner (active, unblocked, not deleted); `::auth false` methods (the anonymous error-report share link) skip the admin branches entirely. It runs after authentication and before params validation, so a rejection never leaks which params the command expects. The rule itself is factored as `auth/superuser-allowed?`. +- `::perms` entries other than `"superuser"` keep their token-only meaning: a session calling them gets `token-auth-required`, exactly as before. `"superuser"` is the only permission a session can satisfy, by id membership in the `:app.auth/superusers` registry. A token can also satisfy it by carrying `"superuser"` as an operator-granted scope, for profiles the registry does not list; scopes are granted outside the public API — except through the admin token issuance (`app.rpc.admin.tokens`), where only superusers may grant the implicit `admin:` vocabulary and `"superuser"` itself is rejected. The profile flags (`get-profile`, login) stay registry-based: the flag says who you are, the guard says what the call carries. - The registry resolves the configured `:superusers` emails to profile ids once, at startup, in dependency order after migrations. What does not exist then is simply not in the set: a profile registered afterwards is recognized after the next restart. The check itself is a plain `contains?` on that set, so guarded calls cost no query. On a `devenv` host the check passes for any authenticated profile, for both sessions and tokens. `get-profile` and the login response carry the same answer as `:is-superuser`, read from the same set. - `:is-admin` means team membership (owner/admin/editor/viewer roles); `:is-superuser` is the global instance flag. Do not reuse one for the other. - `profile` stores emails lowercased but the `:superusers` config schema does not normalize, so an upper-case entry in the config never matches. diff --git a/backend/resources/admin/js/components/token-perms.js b/backend/resources/admin/js/components/token-perms.js new file mode 100644 index 0000000000..c8d51802d8 --- /dev/null +++ b/backend/resources/admin/js/components/token-perms.js @@ -0,0 +1,99 @@ +// Grouping and labeling for the admin token permission vocabulary. +// +// The vocabulary comes from the `get-admin-permissions` RPC (never +// hardcoded here): entries look like `admin:get-team-members`. +// `groupPermissions` buckets them by resource for the create form and +// marks reads apart from writes, so granting a destructive action +// never looks like granting a listing. Unknown shapes fall into +// "Other" and read as writes: caution by default. + +const READ_VERBS = new Set(["get", "validate"]); + +const RESOURCE_LABELS = new Map([ + ["team", "Teams"], + ["teams", "Teams"], + ["file", "Files"], + ["files", "Files"], + ["project", "Projects"], + ["projects", "Projects"], + ["profile", "Users"], + ["profiles", "Users"], + ["verification", "Users"], + ["job", "Jobs"], + ["jobs", "Jobs"], + ["error", "Error reports"], + ["virtual", "System"], + ["token", "Tokens"], +]); + +const RESOURCE_ORDER = [ + "Users", + "Teams", + "Projects", + "Files", + "Jobs", + "Error reports", + "System", + "Other", +]; + +const DESTRUCTIVE_VERBS = new Set([ + "delete", + "restore", + "block", + "unblock", + "cancel", +]); + +const MOVING_VERBS = new Set(["export", "import"]); + +function capitalize(text) { + return text.length === 0 ? text : text[0].toUpperCase() + text.slice(1); +} + +export function describePermission(perm) { + const body = perm.startsWith("admin:") ? perm.slice("admin:".length) : perm; + const parts = body.split("-").filter((part) => part.length > 0); + const verb = parts.length > 0 ? parts[0] : ""; + const rest = parts.slice(1); + + const resourceKey = rest.length > 0 ? rest[0] : ""; + const resource = RESOURCE_LABELS.get(resourceKey) ?? "Other"; + const label = rest.length > 0 ? capitalize(rest.join(" ")) : perm; + const kind = READ_VERBS.has(verb) ? "read" : "write"; + + let warning = null; + if (DESTRUCTIVE_VERBS.has(verb)) { + warning = "Destructive action."; + } else if (MOVING_VERBS.has(verb)) { + warning = "Moves file data in or out of this instance."; + } + + return { perm, resource, label, kind, warning }; +} + +export function groupPermissions(perms) { + const groups = new Map(); + for (const perm of perms) { + const described = describePermission(perm); + if (!groups.has(described.resource)) { + groups.set(described.resource, []); + } + groups.get(described.resource).push(described); + } + + return [...groups.entries()] + .map(([resource, items]) => ({ + resource, + items: items.toSorted((a, b) => a.label.localeCompare(b.label)), + })) + .toSorted((a, b) => { + const orderA = RESOURCE_ORDER.includes(a.resource) + ? RESOURCE_ORDER.indexOf(a.resource) + : RESOURCE_ORDER.length; + const orderB = RESOURCE_ORDER.includes(b.resource) + ? RESOURCE_ORDER.indexOf(b.resource) + : RESOURCE_ORDER.length; + return orderA - orderB || a.resource.localeCompare(b.resource); + }); +} diff --git a/backend/resources/admin/js/components/token-perms.test.js b/backend/resources/admin/js/components/token-perms.test.js new file mode 100644 index 0000000000..cc748b3330 --- /dev/null +++ b/backend/resources/admin/js/components/token-perms.test.js @@ -0,0 +1,75 @@ +// Contract test for the token permission grouping: run with plain +// node, no harness: +// +// node --experimental-detect-module --test \ +// backend/resources/admin/js/components/token-perms.test.js +// +// (The flag lets node read the panel's extensionless-ESM `.js` +// files; the panel itself has no build and no test runner.) + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { describePermission, groupPermissions } from "./token-perms.js"; + +describe("describePermission", () => { + it("marks listings as reads under their resource", () => { + assert.deepEqual(describePermission("admin:get-team-members"), { + perm: "admin:get-team-members", + resource: "Teams", + label: "Team members", + kind: "read", + warning: null, + }); + }); + + it("marks mutations as writes", () => { + const described = describePermission("admin:enable-team-feature"); + assert.equal(described.resource, "Teams"); + assert.equal(described.kind, "write"); + }); + + it("warns on destructive and data-moving actions", () => { + assert.equal( + describePermission("admin:delete-team").warning, + "Destructive action." + ); + assert.equal( + describePermission("admin:export-files").warning, + "Moves file data in or out of this instance." + ); + assert.equal(describePermission("admin:get-teams").warning, null); + }); + + it("treats unknown shapes with caution", () => { + const described = describePermission("admin:frobnicate"); + assert.equal(described.resource, "Other"); + assert.equal(described.kind, "write"); + }); +}); + +describe("groupPermissions", () => { + it("buckets by resource in panel order with sorted labels", () => { + const groups = groupPermissions([ + "admin:get-teams", + "admin:block-profile", + "admin:delete-team", + "admin:get-profiles", + ]); + assert.deepEqual( + groups.map((group) => group.resource), + ["Users", "Teams"] + ); + assert.deepEqual( + groups[1].items.map((item) => item.perm), + ["admin:delete-team", "admin:get-teams"] + ); + }); + + it("never mentions superuser: the bypass is not a grant", () => { + const groups = groupPermissions(["admin:get-teams", "superuser"]); + assert.deepEqual( + groups.map((group) => group.resource), + ["Teams", "Other"] + ); + }); +}); diff --git a/backend/resources/admin/js/main.js b/backend/resources/admin/js/main.js index 04785f560c..9536b9d7bb 100644 --- a/backend/resources/admin/js/main.js +++ b/backend/resources/admin/js/main.js @@ -24,6 +24,7 @@ import { projectDetailPage } from "./pages/project-detail.js"; import { usersPage } from "./pages/users.js"; import { jobsPage } from "./pages/jobs.js"; import { jobDetailPage } from "./pages/job-detail.js"; +import { tokensPage } from "./pages/tokens.js"; import { virtualClockPage } from "./pages/virtual-clock.js"; const NAV_ITEMS = [ @@ -36,6 +37,7 @@ const NAV_ITEMS = [ { query: "?screen=projects", label: "Projects", screens: ["projects", "project"] }, { query: "?screen=files", label: "Files", screens: ["files", "file"] }, { query: "?screen=jobs", label: "Jobs", screens: ["jobs", "job"] }, + { query: "?screen=tokens", label: "Tokens", screens: ["tokens"] }, { query: "?screen=virtual-clock", label: "Virtual clock", screens: ["virtual-clock"] }, ]; @@ -103,6 +105,8 @@ function renderAuthed(root) { jobsPage(content, { onNavigate: navigate }); } else if (params.get("screen") === "job" && params.get("id")) { jobDetailPage(content, { id: params.get("id"), onNavigate: navigate }); + } else if (params.get("screen") === "tokens") { + tokensPage(content); } else if (params.get("screen") === "virtual-clock") { virtualClockPage(content, { onNavigate: navigate }); } else { diff --git a/backend/resources/admin/js/pages/tokens.js b/backend/resources/admin/js/pages/tokens.js new file mode 100644 index 0000000000..d0fbc781fd --- /dev/null +++ b/backend/resources/admin/js/pages/tokens.js @@ -0,0 +1,248 @@ +// Access tokens: issue, list and revoke granular admin tokens. +// +// Creation resolves the optional owner email through `get-profiles` +// (exact match); a blank owner issues the token for the current +// profile. The secret is shown once, right after creation, and never +// again. Every value is painted as text. + +import { rpc, errorHint } from "../api.js"; +import { renderHeader } from "../components/header.js"; +import { renderTable } from "../components/table.js"; +import { groupPermissions } from "../components/token-perms.js"; +import { formatDate } from "../components/date.js"; +import { showToast } from "../components/toast.js"; + +const COLUMNS = [ + { key: "name", label: "Name", class: "admin-cell-name" }, + { key: "owner", label: "Owner", class: "admin-cell-email admin-cell-left" }, + { key: "permissions", label: "Permissions", class: "admin-cell-count" }, + { key: "created", label: "Created", class: "admin-cell-date" }, + { key: "expires", label: "Expires", class: "admin-cell-date" }, +]; + +function labeledInput(labelText, { placeholder = "", type = "text" } = {}) { + const label = document.createElement("label"); + label.className = "admin-field"; + label.textContent = labelText; + const input = document.createElement("input"); + input.className = "admin-input"; + input.type = type; + if (placeholder) { + input.placeholder = placeholder; + } + label.appendChild(input); + return { label, input }; +} + +export function tokensPage(root) { + root.appendChild(renderHeader("Access tokens")); + + const hint = document.createElement("p"); + hint.className = "admin-count"; + hint.textContent = + "Tokens act with a fixed set of admin permissions (for example " + + "listing teams without being superuser). A blank owner issues " + + "the token for this profile. The secret is shown once."; + root.appendChild(hint); + + // ---- Create form ---- + + const formTitle = document.createElement("h2"); + formTitle.className = "admin-section-title"; + formTitle.textContent = "New token"; + root.appendChild(formTitle); + + const controls = document.createElement("div"); + controls.className = "admin-controls"; + + const nameField = labeledInput("Name", { placeholder: "support-readonly" }); + controls.appendChild(nameField.label); + + const ownerField = labeledInput("Owner email (optional)", { + placeholder: "teammate@example.com", + }); + controls.appendChild(ownerField.label); + + const expiryField = labeledInput("Expiration (optional)", { + placeholder: "720h", + }); + controls.appendChild(expiryField.label); + + root.appendChild(controls); + + const permsTitle = document.createElement("h3"); + permsTitle.className = "admin-section-subtitle"; + permsTitle.textContent = "Permissions"; + root.appendChild(permsTitle); + + const permsBox = document.createElement("div"); + permsBox.className = "admin-perms"; + permsBox.textContent = "Loading permissions…"; + root.appendChild(permsBox); + + const createButton = document.createElement("button"); + createButton.className = "admin-button"; + createButton.textContent = "Create token"; + createButton.disabled = true; + createButton.addEventListener("click", async () => { + const name = nameField.input.value.trim(); + const checked = [...permsBox.querySelectorAll("input[type=checkbox]:checked")].map( + (box) => box.value + ); + if (!name) { + showToast("Type a name first.", "error"); + return; + } + if (checked.length === 0) { + showToast("Grant at least one permission.", "error"); + return; + } + createButton.disabled = true; + try { + const params = { name, perms: checked }; + const ownerEmail = ownerField.input.value.trim(); + if (ownerEmail) { + const found = await rpc("get-profiles", { email: ownerEmail }); + if (found.items.length === 0) { + showToast("No profile uses that email.", "error"); + return; + } + params.profileId = found.items[0].id; + } + const expiration = expiryField.input.value.trim(); + if (expiration) { + params.expiration = expiration; + } + const created = await rpc("create-admin-token", params); + paintSecret(created); + showToast("Token created."); + await loadTokens(); + } catch (error) { + showToast(errorHint(error) ?? "Could not create the token.", "error"); + } finally { + createButton.disabled = false; + } + }); + root.appendChild(createButton); + + const secretBox = document.createElement("div"); + secretBox.className = "admin-secret"; + secretBox.hidden = true; + root.appendChild(secretBox); + + function paintSecret(created) { + secretBox.replaceChildren(); + secretBox.hidden = false; + const note = document.createElement("p"); + note.className = "admin-count"; + note.textContent = "Copy it now — the secret is never shown again."; + secretBox.appendChild(note); + const secret = document.createElement("input"); + secret.className = "admin-input"; + secret.readOnly = true; + secret.value = created.token; + secret.setAttribute("aria-label", "New token secret"); + secret.addEventListener("focus", () => secret.select()); + secretBox.appendChild(secret); + } + + async function loadPermissions() { + try { + const vocabulary = await rpc("get-admin-permissions"); + permsBox.replaceChildren(); + for (const group of groupPermissions(vocabulary)) { + const fieldset = document.createElement("fieldset"); + fieldset.className = "admin-perm-group"; + const legend = document.createElement("legend"); + legend.textContent = group.resource; + fieldset.appendChild(legend); + for (const item of group.items) { + const option = document.createElement("label"); + option.className = "admin-perm-option"; + const box = document.createElement("input"); + box.type = "checkbox"; + box.value = item.perm; + option.appendChild(box); + const text = document.createElement("span"); + text.textContent = `${item.label} (${item.kind})`; + option.appendChild(text); + if (item.warning) { + const warning = document.createElement("small"); + warning.className = "admin-perm-warning"; + warning.textContent = item.warning; + option.appendChild(warning); + } + fieldset.appendChild(option); + } + permsBox.appendChild(fieldset); + } + createButton.disabled = false; + } catch { + permsBox.textContent = "Could not load permissions."; + } + } + + // ---- Existing tokens ---- + + const listTitle = document.createElement("h2"); + listTitle.className = "admin-section-title"; + listTitle.textContent = "Existing tokens"; + root.appendChild(listTitle); + + const listBox = document.createElement("div"); + listBox.textContent = "Loading…"; + root.appendChild(listBox); + + async function loadTokens() { + try { + const items = await rpc("get-admin-tokens"); + listBox.replaceChildren(); + if (items.length === 0) { + const empty = document.createElement("p"); + empty.className = "admin-count"; + empty.textContent = "No admin tokens yet."; + listBox.appendChild(empty); + return; + } + const table = renderTable( + COLUMNS, + items.map((item) => ({ + name: item.name, + owner: item.ownerEmail ?? "—", + permissions: `${item.perms.length} permission${item.perms.length === 1 ? "" : "s"}`, + created: formatDate(item.createdAt), + expires: item.expiresAt ? formatDate(item.expiresAt) : "never", + })) + ); + [...table.querySelector("tbody").children].forEach((rowEl, index) => { + const cell = document.createElement("td"); + const revoke = document.createElement("button"); + revoke.className = "admin-button admin-button-ghost"; + revoke.textContent = "Revoke"; + revoke.addEventListener("click", async () => { + const token = items[index]; + if (!window.confirm(`Revoke the token "${token.name}"?`)) { + return; + } + revoke.disabled = true; + try { + await rpc("revoke-admin-token", { id: token.id }); + showToast("Token revoked."); + await loadTokens(); + } catch (error) { + showToast(errorHint(error) ?? "Could not revoke the token.", "error"); + revoke.disabled = false; + } + }); + cell.appendChild(revoke); + rowEl.appendChild(cell); + }); + listBox.appendChild(table); + } catch { + listBox.textContent = "Could not load tokens."; + } + } + + loadPermissions(); + loadTokens(); +} diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index fc711052e1..509bc925e4 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -29,6 +29,7 @@ [app.msgbus :as-alias mbus] [app.nitrate :as nitrate] [app.redis :as rds] + [app.rpc.admin.perms :as perms] [app.rpc.climit :as climit] [app.rpc.commands.teams :as teams] [app.rpc.cond :as cond] @@ -182,7 +183,12 @@ ;; tokens are created with empty perms — so this grants nothing ;; by itself. scope-perms (disj required-perms "superuser") - superuser-required (contains? required-perms "superuser")] + superuser-required (contains? required-perms "superuser") + ;; Admin methods carry an implicit `admin:` permission + ;; derived from their registered name (see app.rpc.admin.perms). + admin-method? (perms/admin-method? mdata) + implicit-perm (when admin-method? + (perms/method-perm mdata))] (fn [cfg params] (let [profile-id (::profile-id params) auth-type (::auth-type params) @@ -205,6 +211,61 @@ :code :token-auth-required :hint "access token authentication required for this endpoint") + ;; Admin methods without an explicit "superuser" requirement + ;; answer to their implicit perm. Superuser bypasses it; a + ;; session without superuser keeps the historical rejection; + ;; a token needs the implicit perm (plus any declared scope). + (and admin-method? + (not superuser-required) + required-auth? + (auth/superuser-allowed? cfg profile-id token-perms)) + (f cfg params) + + (and admin-method? + (not superuser-required) + required-auth? + (not= auth-type :token)) + (ex/raise :type :authorization + :code :superuser-required + :hint "superuser required for this endpoint") + + (and admin-method? + (not superuser-required) + required-auth? + (not (set/subset? (conj scope-perms implicit-perm) token-perms))) + (ex/raise :type :authorization + :code :missing-perms + :hint "missing required permissions" + :required (conj scope-perms implicit-perm)) + + ;; The token owner behind an admin call must be able to act: + ;; a live, active, unblocked profile. Soft-deleted rows are + ;; hidden by the default `db/get*` filter, so a deleted owner + ;; reads as nil. Registry superusers bypassed above and never + ;; reach this branch. + (and admin-method? + (not superuser-required) + required-auth? + (= auth-type :token)) + (let [row (db/get* cfg :profile {:id profile-id} + {:columns [:id :is-active :is-blocked :deleted-at]})] + (cond + (and (some? row) + (:is-active row) + (not (:is-blocked row))) + (f cfg params) + + (and (some? row) + (:is-blocked row)) + (ex/raise :type :authorization + :code :profile-blocked + :hint "token owner profile is marked as blocked") + + :else + (ex/raise :type :authorization + :code :profile-inactive + :hint "token owner profile is not active"))) + (and (seq scope-perms) (not= auth-type :token)) (ex/raise :type :authorization @@ -507,14 +568,7 @@ (defn- resolve-admin-methods [cfg] (let [cfg (assoc cfg ::module "admin" ::type "command" ::metrics-id :rpc-admin-timing)] - (->> (sv/scan-ns - 'app.rpc.admin.errors - 'app.rpc.admin.file - 'app.rpc.admin.job - 'app.rpc.admin.misc - 'app.rpc.admin.profile - 'app.rpc.admin.project - 'app.rpc.admin.team) + (->> (apply sv/scan-ns perms/admin-namespaces) (map (partial process-method cfg wrap)) (into {})))) diff --git a/backend/src/app/rpc/admin/errors.clj b/backend/src/app/rpc/admin/errors.clj index 1fbda13d37..bc41107aff 100644 --- a/backend/src/app/rpc/admin/errors.clj +++ b/backend/src/app/rpc/admin/errors.clj @@ -10,9 +10,9 @@ They mirror `get-error-reports` / `get-error-report` (which stay token-only for integrations) for session callers, reusing their query and schemas. Guards live in wrap-authentication - (the 'superuser' permission), not here — except - get-shared-error-report, anonymous by design: its share token - is the only credential." + (the implicit `admin:` permission, bypassed by superuser), + not here — except get-shared-error-report, anonymous by design: its + share token is the only credential." (:require [app.common.data :as d] [app.common.exceptions :as ex] @@ -47,7 +47,6 @@ (sv/defmethod ::get-error-reports {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params error-reports/schema:get-error-reports-params ::sm/result error-reports/schema:get-error-reports-result} [cfg params] @@ -70,7 +69,6 @@ (sv/defmethod ::get-error-report {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params error-reports/schema:get-error-report-params ::sm/result error-reports/schema:error-report} [cfg {:keys [id]}] @@ -213,7 +211,6 @@ (sv/defmethod ::create-error-report-share {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:create-error-report-share-params ::sm/result schema:create-error-report-share-result} [cfg {:keys [id ttl-hours ::rpc/profile-id]}] diff --git a/backend/src/app/rpc/admin/file.clj b/backend/src/app/rpc/admin/file.clj index 853c0ef80d..01b193f190 100644 --- a/backend/src/app/rpc/admin/file.clj +++ b/backend/src/app/rpc/admin/file.clj @@ -7,8 +7,9 @@ (ns app.rpc.admin.file "Admin file commands, served through `/api/admin/methods`. - Access control lives in `wrap-authentication` (the `\"superuser\"` - permission), not here, so a new command cannot forget it." + Access control lives in `wrap-authentication` (the implicit + `admin:` permission, bypassed by superuser), not here, so a + new command cannot forget it." (:require [app.binfile.common :as bfc] [app.binfile.v1 :as bf.v1] @@ -88,7 +89,6 @@ (sv/defmethod ::validate-file {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:validate-file-params ::sm/result schema:validate-file-result} [cfg {:keys [file-id]}] @@ -114,7 +114,6 @@ ;; outer one instead of a savepoint. (sv/defmethod ::repair-file {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:repair-file-params ::sm/result schema:repair-file-result} [cfg {:keys [file-id skip-snapshot ::rpc/profile-id]}] @@ -217,7 +216,6 @@ (sv/defmethod ::get-files {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-files-params ::sm/result schema:get-files-result} [cfg params] @@ -243,7 +241,6 @@ (sv/defmethod ::get-file {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-file-params ::sm/result schema:file-summary} [cfg {:keys [id]}] @@ -263,7 +260,6 @@ (sv/defmethod ::restore-file {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:restore-file-params ::sm/result schema:restore-file-result} @@ -299,7 +295,6 @@ (sv/defmethod ::delete-file {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:delete-file-params ::sm/result schema:delete-file-result} @@ -346,7 +341,6 @@ (sv/defmethod ::export-files {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:export-files-params} [{:keys [::db/pool] :as cfg} {:keys [file-ids includelibs clone embedassets ::rpc/profile-id]}] @@ -399,7 +393,6 @@ (sv/defmethod ::import-files {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:import-files-params} [{:keys [::db/pool] :as cfg} {:keys [file ::rpc/profile-id]}] (let [profile (profile/get-profile pool profile-id) diff --git a/backend/src/app/rpc/admin/job.clj b/backend/src/app/rpc/admin/job.clj index 07a38da617..581b1c0bb6 100644 --- a/backend/src/app/rpc/admin/job.clj +++ b/backend/src/app/rpc/admin/job.clj @@ -9,8 +9,8 @@ Read-only inspection of the `job` table plus cancellation, for the Jobs section of the `/admin` panel. Access control lives in - `wrap-authentication` (the `\"superuser\"` permission), not here, - so a new command cannot forget it. + `wrap-authentication` (the implicit `admin:` permission, + bypassed by superuser), not here, so a new command cannot forget it. Cancellation reuses the substrate writer `app.jobs/cancel`, which moves pending and running jobs to `cancelled` with their `end` @@ -120,7 +120,6 @@ (sv/defmethod ::get-jobs {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-jobs-params ::sm/result schema:get-jobs-result} [cfg params] @@ -211,7 +210,6 @@ (sv/defmethod ::cancel-job {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:cancel-job-params ::sm/result schema:cancel-job-result} [cfg {:keys [id]}] @@ -223,7 +221,6 @@ (sv/defmethod ::get-job {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-job-params ::sm/result schema:job-detail} [cfg {:keys [id]}] diff --git a/backend/src/app/rpc/admin/misc.clj b/backend/src/app/rpc/admin/misc.clj index 3cbd07d3f7..0084722b35 100644 --- a/backend/src/app/rpc/admin/misc.clj +++ b/backend/src/app/rpc/admin/misc.clj @@ -9,7 +9,8 @@ Small diagnostic tools that do not deserve their own namespace: currently just the virtual clock. Access control lives in - `wrap-authentication` (the `\"superuser\"` permission), not here." + `wrap-authentication` (the implicit `admin:` permission, + bypassed by superuser), not here." (:require [app.common.exceptions :as ex] [app.common.schema :as-alias sm] @@ -32,7 +33,6 @@ (sv/defmethod ::get-virtual-clock {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params [:map] ::sm/result schema:get-virtual-clock-result} [_ {:keys [::rpc/profile-id]}] @@ -48,7 +48,6 @@ (sv/defmethod ::set-virtual-clock {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:set-virtual-clock-params ::sm/result schema:get-virtual-clock-result} [_ {:keys [::rpc/profile-id offset reset]}] diff --git a/backend/src/app/rpc/admin/perms.clj b/backend/src/app/rpc/admin/perms.clj new file mode 100644 index 0000000000..450ce4c9a8 --- /dev/null +++ b/backend/src/app/rpc/admin/perms.clj @@ -0,0 +1,49 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.rpc.admin.perms + "Vocabulary for the admin permission model. + + Every admin method carries an implicit permission derived from its + registered name (`admin:`). `superuser` is not a permission + here: it bypasses every check in `wrap-authentication`. Methods that + declare it explicitly stay root-only and out of the grantable set." + (:require + [app.rpc :as-alias rpc] + [app.util.services :as sv] + [cuerdas.core :as str])) + +(def admin-namespaces + "Every namespace contributing methods to `/api/admin/methods`. + Single source for the route resolver and the grantable vocabulary." + '[app.rpc.admin.errors + app.rpc.admin.file + app.rpc.admin.job + app.rpc.admin.misc + app.rpc.admin.profile + app.rpc.admin.project + app.rpc.admin.team + app.rpc.admin.tokens]) + +(defn admin-method? + [mdata] + (str/starts-with? (:ns mdata "") "app.rpc.admin.")) + +(defn method-perm + [mdata] + (str "admin:" (::sv/name mdata))) + +(defn grantable-perms + "Every implicit admin permission a superuser may grant on a token. + Root-only methods (they declare `superuser`) are excluded: granting + their implicit perm would be useless, the method never consults it." + [] + (into #{} + (comp (filter (fn [[_ mdata]] (admin-method? mdata))) + (remove (fn [[_ mdata]] + (contains? (set (::rpc/perms mdata)) "superuser"))) + (map (fn [[_ mdata]] (method-perm mdata)))) + (apply sv/scan-ns admin-namespaces))) diff --git a/backend/src/app/rpc/admin/profile.clj b/backend/src/app/rpc/admin/profile.clj index 087fbb2ec9..9fd4b8feef 100644 --- a/backend/src/app/rpc/admin/profile.clj +++ b/backend/src/app/rpc/admin/profile.clj @@ -7,8 +7,9 @@ (ns app.rpc.admin.profile "Admin profile commands, served through `/api/admin/methods`. - Access control lives in `wrap-authentication` (the `\"superuser\"` - permission), not here, so a new command cannot forget it." + Access control lives in `wrap-authentication` (the implicit + `admin:` permission, bypassed by superuser), not here, so a + new command cannot forget it." (:require [app.common.data :as d] [app.common.exceptions :as ex] @@ -90,7 +91,6 @@ (sv/defmethod ::get-profiles {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-profiles-params ::sm/result schema:get-profiles-result} [cfg params] @@ -144,7 +144,6 @@ (sv/defmethod ::get-profile {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-profile-params ::sm/result schema:profile} [cfg {:keys [id]}] @@ -196,7 +195,6 @@ (sv/defmethod ::block-profile {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:get-profile-params ::sm/result schema:block-result} @@ -209,7 +207,6 @@ (sv/defmethod ::unblock-profile {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:get-profile-params ::sm/result schema:block-result} @@ -223,7 +220,6 @@ (sv/defmethod ::resend-verification {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:get-profile-params ::sm/result schema:resend-result} @@ -279,7 +275,6 @@ (sv/defmethod ::delete-profiles {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:delete-profiles-params ::sm/result schema:delete-profiles-result} @@ -307,7 +302,6 @@ (sv/defmethod ::restore-profile {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:restore-profile-params ::sm/result schema:restore-profile-result} diff --git a/backend/src/app/rpc/admin/project.clj b/backend/src/app/rpc/admin/project.clj index c3c1975d58..6140ccd594 100644 --- a/backend/src/app/rpc/admin/project.clj +++ b/backend/src/app/rpc/admin/project.clj @@ -7,8 +7,9 @@ (ns app.rpc.admin.project "Admin project commands, served through `/api/admin/methods`. - Access control lives in `wrap-authentication` (the `\"superuser\"` - permission), not here, so a new command cannot forget it." + Access control lives in `wrap-authentication` (the implicit + `admin:` permission, bypassed by superuser), not here, so a + new command cannot forget it." (:require [app.common.data :as d] [app.common.exceptions :as ex] @@ -92,7 +93,6 @@ (sv/defmethod ::get-projects {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-projects-params ::sm/result schema:get-projects-result} [cfg params] @@ -119,7 +119,6 @@ (sv/defmethod ::get-project {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-project-params ::sm/result schema:project-summary} [cfg {:keys [id]}] @@ -139,7 +138,6 @@ (sv/defmethod ::restore-project {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:restore-project-params ::sm/result schema:restore-project-result} @@ -173,7 +171,6 @@ (sv/defmethod ::delete-project {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:delete-project-params ::sm/result schema:delete-project-result} diff --git a/backend/src/app/rpc/admin/team.clj b/backend/src/app/rpc/admin/team.clj index 11c4b1cbaa..554fa32fd1 100644 --- a/backend/src/app/rpc/admin/team.clj +++ b/backend/src/app/rpc/admin/team.clj @@ -7,8 +7,9 @@ (ns app.rpc.admin.team "Admin team commands, served through `/api/admin/methods`. - Access control lives in `wrap-authentication` (the `\"superuser\"` - permission), not here, so a new command cannot forget it." + Access control lives in `wrap-authentication` (the implicit + `admin:` permission, bypassed by superuser), not here, so a + new command cannot forget it." (:require [app.common.data :as d] [app.common.exceptions :as ex] @@ -92,7 +93,6 @@ (sv/defmethod ::get-teams {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-teams-params ::sm/result schema:get-teams-result} [cfg params] @@ -133,7 +133,6 @@ (sv/defmethod ::get-team {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-team-params ::sm/result schema:team} [cfg {:keys [id]}] @@ -186,7 +185,6 @@ (sv/defmethod ::get-team-members {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::sm/params schema:get-team-members-params ::sm/result [:vector schema:team-member]} [cfg {:keys [team-id]}] @@ -224,7 +222,6 @@ (sv/defmethod ::enable-team-feature {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:team-feature-params ::sm/result schema:team-feature-result} @@ -233,7 +230,6 @@ (sv/defmethod ::disable-team-feature {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:team-feature-params ::sm/result schema:team-feature-result} @@ -250,7 +246,6 @@ (sv/defmethod ::update-team-member-role {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:update-team-member-role-params} [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [team-id member-id role]}] @@ -298,7 +293,6 @@ (sv/defmethod ::restore-team {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:restore-team-params ::sm/result schema:restore-team-result} @@ -329,7 +323,6 @@ (sv/defmethod ::delete-team {::doc/added "2.20" - ::rpc/perms #{"superuser"} ::db/transaction true ::sm/params schema:delete-team-params ::sm/result schema:delete-team-result} diff --git a/backend/src/app/rpc/admin/tokens.clj b/backend/src/app/rpc/admin/tokens.clj new file mode 100644 index 0000000000..33a9ab3d6b --- /dev/null +++ b/backend/src/app/rpc/admin/tokens.clj @@ -0,0 +1,152 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.rpc.admin.tokens + "Admin access-token issuance and management, served through + `/api/admin/methods`. + + These are the only admin methods that keep an explicit `\"superuser\"` + requirement: a granular token must never be able to amplify itself, + so issuance, listing and revocation stay root-only. The granted + vocabulary is `app.rpc.admin.perms/grantable-perms`; `\"superuser\"` + is rejected even though it never appears there, to answer with a + clear error instead of a generic unknown-permission one." + (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.schema :as-alias sm] + [app.common.time :as ct] + [app.db :as db] + [app.rpc :as-alias rpc] + [app.rpc.admin.perms :as perms] + [app.rpc.commands.access-token :as access-token] + [app.rpc.doc :as doc] + [app.rpc.quotes :as quotes] + [app.util.services :as sv])) + +(def ^:private schema:create-admin-token-params + [:map {:title "create-admin-token-params"} + [:name [:string {:min 1 :max 250}]] + [:profile-id {:optional true} ::sm/uuid] + [:expiration {:optional true} ::ct/duration] + [:perms [:vector {:min 1} [:string {:min 1 :max 200}]]]]) + +(def ^:private schema:admin-token + [:map {:title "admin-token"} + [:id ::sm/uuid] + [:profile-id ::sm/uuid] + [:owner-email {:optional true} ::sm/text] + [:name ::sm/text] + [:perms [:vector ::sm/text]] + [:type {:optional true} ::sm/text] + [:created-at ct/schema:inst] + [:updated-at ct/schema:inst] + [:expires-at {:optional true} ct/schema:inst]]) + +(def ^:private schema:admin-token-created + [:merge schema:admin-token + [:map [:token ::sm/text]]]) + +(sv/defmethod ::create-admin-token + {::doc/added "2.20" + ::rpc/perms #{"superuser"} + ::sm/params schema:create-admin-token-params + ::sm/result schema:admin-token-created} + [cfg {creator-id ::rpc/profile-id :keys [profile-id name expiration perms]}] + (let [owner-id (or profile-id creator-id) + requested (set perms) + grantable (perms/grantable-perms)] + (when (contains? requested "superuser") + (ex/raise :type :validation + :code :superuser-not-grantable + :hint "the superuser scope cannot be granted on a token")) + (let [unknown (sort (remove grantable requested))] + (when (seq unknown) + (ex/raise :type :validation + :code :unknown-permission + :hint "unknown admin permission" + :unknown unknown))) + (when-not (db/get* cfg :profile {:id owner-id} {:columns [:id :deleted-at]}) + (ex/raise :type :not-found + :code :profile-not-found + :hint "owner profile not found")) + (quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile + ::quotes/profile-id owner-id}) + (let [row (db/tx-run! + cfg + (fn [{:keys [::db/conn] :as tx-cfg}] + (let [created (access-token/create-access-token + tx-cfg owner-id name expiration "admin")] + (db/update! conn :access-token + {:perms (db/create-array conn "text" (vec (sort requested)))} + {:id (:id created)}) + (db/get* tx-cfg :access-token {:id (:id created)}))))] + (-> row + (select-keys [:id :profile-id :name :perms :type + :created-at :updated-at :expires-at :token]) + (update :perms #(vec (sort (db/decode-pgarray % #{})))) + (d/without-nils))))) + +(def ^:private sql:list-admin-tokens + "SELECT t.id, t.profile_id, t.name, t.perms, t.type, + t.created_at, t.updated_at, t.expires_at, + p.email AS owner_email + FROM access_token AS t + JOIN profile AS p ON p.id = t.profile_id + WHERE t.type = 'admin'") + +(def ^:private schema:get-admin-permissions-result + [:vector ::sm/text]) + +(sv/defmethod ::get-admin-permissions + {::doc/added "2.20" + ::rpc/perms #{"superuser"} + ::sm/params [:map {:title "get-admin-permissions-params"}] + ::sm/result schema:get-admin-permissions-result} + [_ _] + (vec (sort (perms/grantable-perms)))) + +(def ^:private schema:get-admin-tokens-params + [:map {:title "get-admin-tokens-params"} + [:profile-id {:optional true} ::sm/uuid]]) + +(sv/defmethod ::get-admin-tokens + {::doc/added "2.20" + ::rpc/perms #{"superuser"} + ::sm/params schema:get-admin-tokens-params + ::sm/result [:vector schema:admin-token]} + [cfg {:keys [profile-id]}] + (let [sql (if profile-id + (str sql:list-admin-tokens " AND t.profile_id = ? ORDER BY t.created_at DESC, t.id DESC") + (str sql:list-admin-tokens " ORDER BY t.created_at DESC, t.id DESC")) + args (cond-> [sql] profile-id (conj profile-id))] + (->> (db/exec! cfg args) + (mapv (fn [row] + (-> row + (select-keys [:id :profile-id :name :perms :type + :created-at :updated-at :expires-at + :owner-email]) + (update :perms #(vec (sort (db/decode-pgarray % #{})))) + (d/without-nils))))))) + +(def ^:private schema:revoke-admin-token-params + [:map {:title "revoke-admin-token-params"} + [:id ::sm/uuid]]) + +(sv/defmethod ::revoke-admin-token + {::doc/added "2.20" + ::rpc/perms #{"superuser"} + ::sm/params schema:revoke-admin-token-params} + [cfg {:keys [id]}] + ;; Scoped to `type = 'admin'`: a personal or MCP token id must never + ;; be revoked through here, whatever trust it carries. + (if (db/get* cfg :access-token {:id id :type "admin"} {:columns [:id]}) + (do + (db/delete! cfg :access-token {:id id :type "admin"}) + nil) + (ex/raise :type :not-found + :code :access-token-not-found + :hint "admin access token not found"))) diff --git a/backend/test/backend_tests/rpc_admin_error_reports_test.clj b/backend/test/backend_tests/rpc_admin_error_reports_test.clj index 9208098ad0..bdae11627f 100644 --- a/backend/test/backend_tests/rpc_admin_error_reports_test.clj +++ b/backend/test/backend_tests/rpc_admin_error_reports_test.clj @@ -97,7 +97,7 @@ (t/deftest unlisted-token-without-scope-rejected (let [profile (th/create-profile* 1)] - (t/is (= :superuser-required + (t/is (= :missing-perms (caught-code #(call #{} :token (:id profile) #{} "get-error-reports" {})))))) diff --git a/backend/test/backend_tests/rpc_admin_file_test.clj b/backend/test/backend_tests/rpc_admin_file_test.clj index 3dc5ed0cec..bf76314c1a 100644 --- a/backend/test/backend_tests/rpc_admin_file_test.clj +++ b/backend/test/backend_tests/rpc_admin_file_test.clj @@ -127,7 +127,7 @@ (t/deftest unlisted-token-without-scope-rejected (let [profile (th/create-profile* 1)] - (t/is (= :superuser-required + (t/is (= :missing-perms (caught-code #(call #{} :token (:id profile) #{} "validate-file" {:file-id (uuid/next)})))))) diff --git a/backend/test/backend_tests/rpc_admin_misc_test.clj b/backend/test/backend_tests/rpc_admin_misc_test.clj index 9726f2be1e..78e2b56240 100644 --- a/backend/test/backend_tests/rpc_admin_misc_test.clj +++ b/backend/test/backend_tests/rpc_admin_misc_test.clj @@ -85,7 +85,7 @@ (t/deftest unlisted-token-without-scope-rejected (let [profile (th/create-profile* 1)] - (t/is (= :superuser-required + (t/is (= :missing-perms (caught-code #(call #{} :token (:id profile) #{} "get-virtual-clock" {})))))) diff --git a/backend/test/backend_tests/rpc_admin_perms_test.clj b/backend/test/backend_tests/rpc_admin_perms_test.clj new file mode 100644 index 0000000000..2f32d16f3a --- /dev/null +++ b/backend/test/backend_tests/rpc_admin_perms_test.clj @@ -0,0 +1,172 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-admin-perms-test + "Tests for the implicit per-method permission of the admin API. + + Every admin method requires `admin:` (derived from its + registered name) unless the caller is a superuser, which bypasses + everything. Granular access is token-only: sessions still need + superuser." + (:require + [app.auth :as-alias auth] + [app.common.data :as d] + [app.common.time :as ct] + [app.config :as cf] + [app.db :as db] + [app.http :as-alias http] + [app.rpc :as rpc] + [app.rpc.admin.perms :as perms] + [app.util.services :as sv] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;; ---------------------------------------------------------------- +;; Helpers +;; ---------------------------------------------------------------- + +(defn- with-host + [host f] + (with-redefs [cf/get (th/config-get-mock {:host host})] + (f))) + +(defn- raw-method + [cmd-name] + (some (fn [[f mdata]] + (when (= cmd-name (:app.util.services/name mdata)) + [f mdata])) + (apply sv/scan-ns perms/admin-namespaces))) + +(defn- call + [auth-type profile-id token-perms cmd params] + (let [[f mdata] (raw-method cmd) + cfg (assoc th/*system* ::auth/superusers #{}) + request (assoc (th/make-dummy-request) + :params (d/without-qualified params)) + server (with-meta params {::rpc/request-at (ct/now) + ::http/request request}) + wrapped (#'rpc/wrap cfg f mdata)] + (with-host "example.com" + (fn [] + (wrapped cfg (assoc server + ::rpc/profile-id profile-id + ::rpc/auth-type auth-type + ::rpc/token-perms token-perms)))))) + +(defn- caught-code + [thunk] + (try + (thunk) + ::no-throw + (catch clojure.lang.ExceptionInfo cause + (th/ex-code cause)))) + +;; ---------------------------------------------------------------- +;; Registry invariant +;; ---------------------------------------------------------------- + +(t/deftest admin-methods-declare-no-explicit-perms + (let [offenders (into [] + (comp (filter (fn [[_ mdata]] (perms/admin-method? mdata))) + (filter (fn [[_ mdata]] + (let [declared (set (:app.rpc/perms mdata))] + (and (seq declared) + (not (and (= "app.rpc.admin.tokens" (:ns mdata)) + (= #{"superuser"} declared))))))) + (map (fn [[_ mdata]] + (str (:ns mdata) "/" + (:app.util.services/name mdata))))) + (apply sv/scan-ns perms/admin-namespaces))] + (t/is (empty? offenders) + "admin methods rely on the implicit perm; only the token + management methods keep an explicit superuser requirement"))) + +(t/deftest grantable-vocabulary-covers-known-methods + (let [vocab (perms/grantable-perms)] + (t/is (contains? vocab "admin:get-profiles")) + (t/is (contains? vocab "admin:get-teams")) + (t/is (not (contains? vocab "superuser")) + "superuser is a bypass, never a grantable perm") + (t/is (not (contains? vocab "admin:create-admin-token")) + "root-only methods are out of the grantable set"))) + +;; ---------------------------------------------------------------- +;; Implicit perm on the wire +;; ---------------------------------------------------------------- + +(t/deftest token-with-exact-implicit-reaches-method + (let [profile (th/create-profile* 1 {:is-active true}) + out (call :token (:id profile) #{"admin:get-profiles"} + "get-profiles" {})] + (t/is (= [(:id profile)] (mapv :id (:items out)))))) + +(t/deftest token-with-other-implicit-rejected + (let [profile (th/create-profile* 1 {:is-active true})] + (t/is (= :missing-perms + (caught-code #(call :token (:id profile) #{"admin:get-teams"} + "get-profiles" {})))))) + +(t/deftest token-without-perms-rejected + (let [profile (th/create-profile* 1 {:is-active true})] + (t/is (= :missing-perms + (caught-code #(call :token (:id profile) #{} + "get-profiles" {})))))) + +(t/deftest session-without-superuser-still-rejected + (let [profile (th/create-profile* 1 {:is-active true})] + (t/is (= :superuser-required + (caught-code #(call :session (:id profile) #{} + "get-profiles" {})))))) + +;; ---------------------------------------------------------------- +;; Owner liveness (fail-closed) +;; ---------------------------------------------------------------- + +(defn- set-flags + [profile-id flags] + (db/update! th/*system* :profile flags {:id profile-id})) + +(t/deftest token-of-blocked-owner-rejected + (let [profile (th/create-profile* 1 {:is-active true})] + (set-flags (:id profile) {:is-blocked true}) + (t/is (= :profile-blocked + (caught-code #(call :token (:id profile) #{"admin:get-profiles"} + "get-profiles" {})))))) + +(t/deftest token-of-deleted-owner-rejected + (let [profile (th/create-profile* 1 {:is-active true})] + (set-flags (:id profile) {:deleted-at (ct/now)}) + (t/is (= :profile-inactive + (caught-code #(call :token (:id profile) #{"admin:get-profiles"} + "get-profiles" {})))))) + +(t/deftest token-of-inactive-owner-rejected + (let [profile (th/create-profile* 1)] + (t/is (= :profile-inactive + (caught-code #(call :token (:id profile) #{"admin:get-profiles"} + "get-profiles" {})))))) + +(t/deftest listed-session-of-blocked-profile-unaffected + ;; The liveness check hardens the granular token path only; + ;; superuser bypass keeps its historical behavior. + (let [profile (th/create-profile* 1 {:is-active true})] + (set-flags (:id profile) {:is-blocked true}) + (let [[f mdata] (raw-method "get-profiles") + cfg (assoc th/*system* ::auth/superusers #{(:id profile)}) + request (assoc (th/make-dummy-request) :params {}) + server (with-meta {} {::rpc/request-at (ct/now) + ::http/request request}) + wrapped (#'rpc/wrap cfg f mdata)] + (with-host "example.com" + (fn [] + (t/is (= [(:id profile)] + (mapv :id (:items (wrapped cfg (assoc server + ::rpc/profile-id (:id profile) + ::rpc/auth-type :session + ::rpc/token-perms #{}))))))))))) diff --git a/backend/test/backend_tests/rpc_admin_project_test.clj b/backend/test/backend_tests/rpc_admin_project_test.clj index 3c337463b5..6750320b8b 100644 --- a/backend/test/backend_tests/rpc_admin_project_test.clj +++ b/backend/test/backend_tests/rpc_admin_project_test.clj @@ -99,7 +99,7 @@ (t/deftest unlisted-token-without-scope-rejected (let [profile (th/create-profile* 1)] - (t/is (= :superuser-required + (t/is (= :missing-perms (caught-code #(call #{} :token (:id profile) #{} "get-projects" {})))))) diff --git a/backend/test/backend_tests/rpc_admin_teams_test.clj b/backend/test/backend_tests/rpc_admin_teams_test.clj index a2da2fb146..2ec6936e79 100644 --- a/backend/test/backend_tests/rpc_admin_teams_test.clj +++ b/backend/test/backend_tests/rpc_admin_teams_test.clj @@ -94,7 +94,7 @@ (t/deftest unlisted-token-without-scope-rejected (let [profile (th/create-profile* 1)] - (t/is (= :superuser-required + (t/is (= :missing-perms (caught-code #(call #{} :token (:id profile) #{} "get-teams" {})))))) diff --git a/backend/test/backend_tests/rpc_admin_tokens_test.clj b/backend/test/backend_tests/rpc_admin_tokens_test.clj new file mode 100644 index 0000000000..9e10846767 --- /dev/null +++ b/backend/test/backend_tests/rpc_admin_tokens_test.clj @@ -0,0 +1,226 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.rpc-admin-tokens-test + "Tests for the admin access-token issuance and management." + (:require + [app.auth :as-alias auth] + [app.common.data :as d] + [app.common.time :as ct] + [app.config :as cf] + [app.db :as db] + [app.http :as-alias http] + [app.rpc :as rpc] + [app.rpc.admin.perms :as perms] + [app.util.services :as sv] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;; ---------------------------------------------------------------- +;; Helpers +;; ---------------------------------------------------------------- + +(defn- with-host + [host f] + (with-redefs [cf/get (th/config-get-mock {:host host})] + (f))) + +(defn- raw-method + [cmd-name] + (some (fn [[f mdata]] + (when (= cmd-name (:app.util.services/name mdata)) + [f mdata])) + (sv/scan-ns 'app.rpc.admin.tokens))) + +(defn- call-as + [superusers profile-id auth-type token-perms cmd params] + (let [[f mdata] (raw-method cmd) + cfg (assoc th/*system* ::auth/superusers superusers) + request (assoc (th/make-dummy-request) + :params (d/without-qualified params)) + server (with-meta params {::rpc/request-at (ct/now) + ::http/request request}) + wrapped (#'rpc/wrap cfg f mdata)] + (with-host "example.com" + (fn [] + (wrapped cfg (assoc server + ::rpc/profile-id profile-id + ::rpc/auth-type auth-type + ::rpc/token-perms token-perms)))))) + +(defn- as-superuser + [profile] + (fn [cmd params] + (call-as #{(:id profile)} (:id profile) :session #{} cmd params))) + +(defn- caught-code + [thunk] + (try + (thunk) + ::no-throw + (catch clojure.lang.ExceptionInfo cause + (th/ex-code cause)))) + +(defn- token-row + [id] + (db/get* th/*pool* :access-token {:id id})) + +;; ---------------------------------------------------------------- +;; Issuance +;; ---------------------------------------------------------------- + +(t/deftest non-superuser-cannot-issue + (let [profile (th/create-profile* 1 {:is-active true})] + (t/is (= :superuser-required + (caught-code #(call-as #{} (:id profile) :session #{} + "create-admin-token" + {:name "support" :perms ["admin:get-teams"]})))))) + +(t/deftest superuser-issues-for-self-by-default + (let [profile (th/create-profile* 1 {:is-active true}) + run (as-superuser profile) + out (run "create-admin-token" {:name "support" + :perms ["admin:get-teams"]})] + (t/is (string? (:token out)) "the secret is returned once, at creation") + (t/is (= #{"admin:get-teams"} (set (:perms out)))) + (t/is (= "admin" (:type out))) + (t/is (= (:id profile) (:profile-id (token-row (:id out)))) + "the stored row belongs to the creator"))) + +(t/deftest superuser-issues-for-another-profile + (let [admin (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + run (as-superuser admin) + out (run "create-admin-token" {:name "support" + :profile-id (:id other) + :perms ["admin:get-teams"]})] + (t/is (= (:id other) (:profile-id (token-row (:id out))))))) + +(t/deftest unknown-perm-rejected + (let [profile (th/create-profile* 1 {:is-active true}) + run (as-superuser profile)] + (t/is (= :unknown-permission + (caught-code #(run "create-admin-token" {:name "support" + :perms ["admin:nope"]})))))) + +(t/deftest superuser-scope-never-grantable + (let [profile (th/create-profile* 1 {:is-active true}) + run (as-superuser profile)] + (t/is (= :superuser-not-grantable + (caught-code #(run "create-admin-token" {:name "root" + :perms ["superuser"]})))))) + +(t/deftest empty-perms-rejected + (let [profile (th/create-profile* 1 {:is-active true}) + run (as-superuser profile)] + (t/is (= :params-validation + (caught-code #(run "create-admin-token" {:name "support" + :perms []})))))) + +(t/deftest missing-owner-rejected + (let [profile (th/create-profile* 1 {:is-active true}) + run (as-superuser profile)] + (t/is (= :profile-not-found + (caught-code #(run "create-admin-token" {:name "support" + :profile-id (random-uuid) + :perms ["admin:get-teams"]})))))) + +(t/deftest expiration-is-optional-but-respected + (let [profile (th/create-profile* 1 {:is-active true}) + run (as-superuser profile) + plain (run "create-admin-token" {:name "no-expiry" + :perms ["admin:get-teams"]}) + dated (run "create-admin-token" {:name "dated" + :expiration (ct/duration "720h") + :perms ["admin:get-teams"]})] + (t/is (nil? (:expires-at plain))) + (t/is (ct/is-after? (:expires-at dated) (ct/now))))) + +;; ---------------------------------------------------------------- +;; Listing and revocation +;; ---------------------------------------------------------------- + +(t/deftest listing-hides-secret-and-shows-perms + (let [profile (th/create-profile* 1 {:is-active true}) + run (as-superuser profile) + created (run "create-admin-token" {:name "support" + :perms ["admin:get-teams"]}) + listed (run "get-admin-tokens" {}) + entry (some #(when (= (:id created) (:id %)) %) listed)] + (t/is (some? entry)) + (t/is (not (contains? entry :token)) "the secret never leaves creation") + (t/is (= #{"admin:get-teams"} (set (:perms entry)))))) + +(t/deftest revocation-deletes-the-row + (let [profile (th/create-profile* 1 {:is-active true}) + run (as-superuser profile) + created (run "create-admin-token" {:name "support" + :perms ["admin:get-teams"]})] + (run "revoke-admin-token" {:id (:id created)}) + (t/is (nil? (token-row (:id created)))) + (t/is (empty? (filter #(= (:id created) (:id %)) + (run "get-admin-tokens" {})))))) + +(t/deftest revoking-unknown-token-is-not-found + (let [profile (th/create-profile* 1 {:is-active true}) + run (as-superuser profile)] + (t/is (= :access-token-not-found + (caught-code #(run "revoke-admin-token" {:id (random-uuid)})))))) + +;; ---------------------------------------------------------------- +;; The permission vocabulary +;; ---------------------------------------------------------------- + +(t/deftest superuser-lists-grantable-permissions + (let [profile (th/create-profile* 1 {:is-active true}) + run (as-superuser profile) + out (run "get-admin-permissions" {})] + (t/is (= (sort out) (vec out)) "stable order for the panel") + (t/is (contains? (set out) "admin:get-teams")) + (t/is (not (contains? (set out) "superuser"))) + (t/is (not (contains? (set out) "admin:create-admin-token")) + "root-only methods are not offered"))) + +(t/deftest non-superuser-cannot-list-permissions + (let [profile (th/create-profile* 1 {:is-active true})] + (t/is (= :superuser-required + (caught-code #(call-as #{} (:id profile) :session #{} + "get-admin-permissions" {})))) + (t/is (= :superuser-required + (caught-code #(call-as #{} (:id profile) :token #{"admin:get-teams"} + "get-admin-permissions" {})))))) + +;; ---------------------------------------------------------------- +;; The issued token works where it should +;; ---------------------------------------------------------------- + +(t/deftest issued-token-reaches-its-method + (let [admin (th/create-profile* 1 {:is-active true}) + run (as-superuser admin) + created (run "create-admin-token" {:name "support" + :perms ["admin:get-profiles"]}) + [f mdata] (some (fn [[f mdata]] + (when (= "get-profiles" + (:app.util.services/name mdata)) + [f mdata])) + (apply sv/scan-ns perms/admin-namespaces)) + cfg (assoc th/*system* ::auth/superusers #{}) + request (assoc (th/make-dummy-request) :params {}) + server (with-meta {} {::rpc/request-at (ct/now) + ::http/request request}) + wrapped (#'rpc/wrap cfg f mdata)] + (with-host "example.com" + (fn [] + (let [out (wrapped cfg (assoc server + ::rpc/profile-id (:id admin) + ::rpc/auth-type :token + ::rpc/token-perms #{"admin:get-profiles"}))] + (t/is (seq (:items out)))) + (t/is (= (:id created) (:id (token-row (:id created)))) + "sanity: the issued row is the one the wrapper trusted"))))) diff --git a/backend/test/backend_tests/rpc_admin_users_test.clj b/backend/test/backend_tests/rpc_admin_users_test.clj index 49551ad433..33506d66a6 100644 --- a/backend/test/backend_tests/rpc_admin_users_test.clj +++ b/backend/test/backend_tests/rpc_admin_users_test.clj @@ -98,7 +98,7 @@ (t/deftest unlisted-token-without-scope-rejected (let [profile (th/create-profile* 1)] - (t/is (= :superuser-required + (t/is (= :missing-perms (caught-code #(call #{} :token (:id profile) #{} "get-profiles" {}))))))