✨ Add granular access tokens for the admin API

Admin methods now require an implicit admin:<method> permission
carried by an access token, with superuser bypassing every check.
Superusers can issue, list and revoke scoped tokens (including for
other profiles) from new admin commands and a panel screen; the
vocabulary is derived from the registered methods and validated on
issue. Token calls additionally require a live owner, and the share
link stays anonymous.

AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
Andrey Antukh 2026-11-02 12:39:27 +00:00
parent f5ca5755a5
commit 7704eef169
25 changed files with 1130 additions and 71 deletions

View File

@ -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:<method>` 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

View File

@ -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/<cmd-name>`. `/api/rpc/command/<cmd-name>` 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/<cmd-name>` (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/<cmd-name>`
(same wrapper as `main`, short names, no doc routes). Guarded by an
implicit per-method permission (`admin:<cmd-name>`, 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.

View File

@ -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:<method>` 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:<method>` 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.

View File

@ -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);
});
}

View File

@ -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"]
);
});
});

View File

@ -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 {

View File

@ -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();
}

View File

@ -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:<method>` 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 {}))))

View File

@ -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:<method>` 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]}]

View File

@ -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:<method>` 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)

View File

@ -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:<method>` 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]}]

View File

@ -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:<method>` 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]}]

View File

@ -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:<method>`). `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)))

View File

@ -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:<method>` 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}

View File

@ -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:<method>` 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}

View File

@ -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:<method>` 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}

View File

@ -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")))

View File

@ -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" {}))))))

View File

@ -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)}))))))

View File

@ -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" {}))))))

View File

@ -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:<method>` (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 #{})))))))))))

View File

@ -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" {}))))))

View File

@ -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" {}))))))

View File

@ -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")))))

View File

@ -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" {}))))))