Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/a-super-admins-key-copies-the-catalogue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---

"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/builder": patch
"create-nextly-app": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/eslint-plugin": patch
"@nextlyhq/module-specifiers": patch
"nextly": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/ui": patch

An API key created by a Super Admin copies the permission catalogue rather
than the role's rows. A Super Admin's power is a bypass, and the role only
holds the permissions that existed when the install was set up, so a key of
theirs held a stale subset at best and, where the first user came before the
grant, nothing: every request refused, starting with the first key an operator
minted to try an integration with. A `read-only` key of theirs now reads every
collection the install declares, including one added later, and still cannot
write; a `full-access` key holds every permission. A permission a package
stopped declaring is not inherited. Whether the creator is a Super Admin is
asked of the same resolver as the session bypass, so a role built on top of
Super Admin counts here exactly as it does everywhere else.

A plugin calling `ctx.services` as a user now sees that user's roles. The
caller was built with an empty role, so a code-defined rule such as
`req.user?.role === "editor"` refused every caller on the plugin path while
the same caller's own request passed it, and a negative rule granted what it
was written to refuse. The roles are resolved and the caller is built by the
one constructor every other authenticated path uses.

Losing the Super Admin role now takes effect at once. The cached answer to
"is this user a super admin" was not cleared when roles changed, so a demoted
user kept the session bypass until the entry aged out, and an API key's grants
resolved through that answer could be cached for five minutes of their own on
top of it. Role and permission invalidation clears it.

A caller that arrived on an API key is judged on the KEY's roles, not its
owner's, the way the REST path already judges one. A stored role rule reads
the caller's roles directly, so the owner's roles let a viewer-scoped key
minted by an administrator satisfy an administrators-only rule, and refused a
key holding the very role a rule names because its owner did not hold it.
2 changes: 2 additions & 0 deletions docs/guides/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ Nextly supports API keys via the `Authorization: Bearer nx_live_...` header (no
| `full-access` | All permissions of the creator's roles. |
| `role-based` | Permissions of an explicitly chosen role (independent of the creator). |

A key created by a Super Admin copies the whole permission catalogue rather than the role's rows, because the Super Admin's power is a bypass and the role only holds the permissions that existed when the install was set up: a `read-only` key of theirs can read every collection, including one added later, and still cannot write. A role that inherits Super Admin counts, the same way it counts everywhere else.

Keys can be time-bound (set an expiry date) and revoked at any time. The full key value is shown to the operator **once** at creation; the database only stores the SHA-256 hash.

### How verification works
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ vi.mock("../di/container", () => ({

// `services/lib/permissions` is pulled into routeHandler for super-admin
// guards. None of the tested branches hit those guards but the import must
// resolve.
vi.mock("../services/lib/permissions", () => ({
// resolve. Derived from the real module rather than written out: a closed
// literal breaks the moment the subject imports one more export, and did.
vi.mock("../services/lib/permissions", async importOriginal => ({
...(await importOriginal<typeof import("../services/lib/permissions")>()),
isSuperAdmin: vi.fn().mockResolvedValue(false),
containsSuperAdminRole: vi.fn().mockResolvedValue(false),
hasSuperAdminExcluding: vi.fn().mockResolvedValue(false),
Expand Down
5 changes: 4 additions & 1 deletion packages/nextly/src/api/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ vi.mock("../di/container", () => ({
container: { get: containerGet, has: containerHas },
}));

vi.mock("../services/lib/permissions", () => ({
vi.mock("../services/lib/permissions", async importOriginal => ({
// Derived from the real module, so an export the subject gains later is
// still there; a closed literal broke on exactly that.
...(await importOriginal<typeof import("../services/lib/permissions")>()),
// `readCaller` (via `authenticated-read.ts`) resolves this to build the
// caller it hands the dashboard service. Unmocked, it falls through to a
// real database lookup that has nothing to connect to in this suite.
Expand Down
157 changes: 151 additions & 6 deletions packages/nextly/src/domains/auth/__tests__/api-key-token-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,27 @@ import { randomUUID } from "crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createTestDb, type TestDb } from "../../../__tests__/fixtures/db";
import { listRoleSlugsForUser } from "../../../services/lib/permissions";
import {
isSuperAdmin,
listRoleSlugsForUser,
} from "../../../services/lib/permissions";
import type { Logger } from "../../../services/shared";
import {
ApiKeyService,
invalidateApiKeyPermissionsCache,
} from "../services/api-key-service";

// Mock listRoleSlugsForUser — it uses a global db singleton (not the test DB).
// We must mock it so tests do not depend on the runtime database connection.
// The path the subject imports: `api-key-service` reads
// `listRoleSlugsForUser` from `services/lib/permissions`, and a factory
// registered against any other specifier leaves the real one in place.
// Mock the RBAC resolvers — they read a global db singleton (not the test DB).
// We must mock them so tests do not depend on the runtime database connection.
// The path the subject imports: `api-key-service` reads `listRoleSlugsForUser`
// and `isSuperAdmin` from `services/lib/permissions`, and a factory registered
// against any other specifier leaves the real ones in place. `isSuperAdmin` is
// the canonical resolver (inheritance and its cache are proven in its own
// suite, and end to end in `plugin-route-key-scope.integration.test.ts`); here
// it answers by id, so what this suite proves is what the service does with
// the answer.
vi.mock("../../../services/lib/permissions", () => ({
isSuperAdmin: vi.fn(),
listRoleSlugsForUser: vi.fn(),
}));

Expand Down Expand Up @@ -48,6 +56,7 @@ function createTestAdapter(db: unknown) {
const KEY_A = "test-key-a";
const KEY_B = "test-key-b";
const KEY_CACHE = "test-key-cache";
const KEY_SUPER = "test-key-super";

// ─────────────────────────────────────────────────────────────────────────────
// Test suite
Expand Down Expand Up @@ -86,6 +95,8 @@ describe("ApiKeyService – Token Type Permission Resolution", () => {
beforeEach(async () => {
testDb = await createTestDb();
service = new ApiKeyService(createTestAdapter(testDb.db), noopLogger);
// Nobody is a super-admin unless a case says so.
vi.mocked(isSuperAdmin).mockResolvedValue(false);

// ── Users ────────────────────────────────────────────────────────────────
userId = randomUUID();
Expand Down Expand Up @@ -208,6 +219,7 @@ describe("ApiKeyService – Token Type Permission Resolution", () => {
invalidateApiKeyPermissionsCache(KEY_A);
invalidateApiKeyPermissionsCache(KEY_B);
invalidateApiKeyPermissionsCache(KEY_CACHE);
invalidateApiKeyPermissionsCache(KEY_SUPER);
await testDb.reset();
testDb.close();
vi.resetAllMocks();
Expand Down Expand Up @@ -373,6 +385,139 @@ describe("ApiKeyService – Token Type Permission Resolution", () => {
});
});

// ── a super-admin's key ────────────────────────────────────────────────
describe("a key created by a super-admin", () => {
/**
* A super-admin who holds NO permission rows, which is what an install
* looks like whose first user came before the setup grant, and what
* every install drifts toward: the role is granted the rows that exist
* at setup and never the ones a later collection adds. Their power is
* the bypass, so the session never notices; a key copies the rows.
*
* Whether they are one is the canonical resolver's answer, mocked by id
* above; no `user_roles` row is seeded because the service must not
* read one. It did, directly, and a role that INHERITS super-admin was
* a super-admin to every other gate and an ordinary user to its key.
*/
let superAdminId: string;

beforeEach(async () => {
superAdminId = randomUUID();
vi.mocked(isSuperAdmin).mockImplementation(
async id => id === superAdminId
);
await testDb.db.insert(testDb.schema.users).values({
id: superAdminId,
email: `super-${superAdminId}@example.com`,
isActive: true,
});
// A permission a package stopped declaring: kept in the table so a
// grant survives, and never copied to a key that inherits nothing else new.
await testDb.db.insert(testDb.schema.permissions).values({
id: randomUUID(),
name: "Read Legacy",
slug: "read-legacy",
action: "read",
resource: "legacy",
orphanedAt: new Date(),
});
});

it("read-only: holds every read-* permission the install declares, from the catalogue", async () => {
const slugs = await service.resolveApiKeyPermissions(
"read-only",
null,
superAdminId,
KEY_SUPER
);
expect([...slugs].sort()).toEqual([
"read-media",
"read-posts",
"read-users",
]);
});

it("read-only: still cannot write, whoever created it", async () => {
const slugs = await service.resolveApiKeyPermissions(
"read-only",
null,
superAdminId,
KEY_SUPER
);
expect(slugs.some(s => !s.startsWith("read-"))).toBe(false);
});

it("full-access: holds every permission the install declares", async () => {
const slugs = await service.resolveApiKeyPermissions(
"full-access",
null,
superAdminId,
KEY_SUPER
);
expect([...slugs].sort()).toEqual([
"create-posts",
"delete-posts",
"read-media",
"read-posts",
"read-users",
"update-posts",
]);
});

it("holds a permission added after the role was granted, which no role copy would", async () => {
// The drift the catalogue exists for: a collection added after setup.
await testDb.db.insert(testDb.schema.permissions).values({
id: randomUUID(),
name: "Read Events",
slug: "read-events",
action: "read",
resource: "events",
});
const slugs = await service.resolveApiKeyPermissions(
"read-only",
null,
superAdminId,
KEY_SUPER
);
expect(slugs).toContain("read-events");
});

it("never inherits a permission the install stopped declaring", async () => {
const slugs = await service.resolveApiKeyPermissions(
"full-access",
null,
superAdminId,
KEY_SUPER
);
expect(slugs).not.toContain("read-legacy");
});

it("is the control that an ordinary creator still copies their own rows only", async () => {
// The editor's read-only key is judged the way it was: their rows,
// not the catalogue. `read-media` is in the catalogue and not theirs.
const slugs = await service.resolveApiKeyPermissions(
"read-only",
null,
userId,
KEY_A
);
expect(slugs).not.toContain("read-media");
});

it("asks the resolver every other gate asks, for the owner and nobody else", async () => {
// The control on the question itself. A direct read of `user_roles`
// would answer these cases identically for a directly-assigned role
// and differently for an inherited one; only the call proves the
// service asks the canonical resolver rather than its own rows.
await service.resolveApiKeyPermissions(
"full-access",
null,
superAdminId,
KEY_SUPER
);
expect(vi.mocked(isSuperAdmin).mock.calls).toEqual([[superAdminId]]);
});
});
// ── role-based ─────────────────────────────────────────────────────────

describe("role-based token type", () => {
Expand Down
Loading
Loading