diff --git a/.changeset/permission-guard-conformance.md b/.changeset/permission-guard-conformance.md new file mode 100644 index 0000000..6dc7143 --- /dev/null +++ b/.changeset/permission-guard-conformance.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": patch +--- + +The adapter conformance suite covers permission guards, under the pending contract `permission-guards`. It holds a reference app's new `/api/permissions` routes to one behavior: exact matching, any-of and all-of, organization scope bound to the route, removal from an organization reaching the guard at the next token, and the live read when a token omits its permissions. The cases are skipped unless a run opts in with `SEAMLESS_VERIFY_CONTRACTS`, because they need the auth API at v0.23. The Express and Fastify reference apps add the routes only when the installed adapter has `requirePermission`. diff --git a/package-lock.json b/package-lock.json index a9eea4d..dd62924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.37.0", + "@seamless-auth/types": "^0.38.0", "adm-zip": "^0.6.1", "jose": "^6.2.15", "kleur": "^4.1.5", @@ -1431,9 +1431,9 @@ "peer": true }, "node_modules/@seamless-auth/types": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.37.0.tgz", - "integrity": "sha512-4rd7iEJPCKWPOIP5YKx+pyC9MgmsxOnnP/KR9DMYfCk5qLmAwMXBSRD9AazVYy0L6uvkBbRs3/o7+G/LlxXMxA==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.38.0.tgz", + "integrity": "sha512-qALF5GFQEv+9yucQdn179cpTdEMMH/PjtZWAHatWk1qUEOzb1ElOqoBZgC4/THAk6u4aLgDyuDt/dCD/BzloqA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index 425f259..31e48bf 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.37.0", + "@seamless-auth/types": "^0.38.0", "adm-zip": "^0.6.1", "jose": "^6.2.15", "kleur": "^4.1.5", diff --git a/verify/CONFORMANCE.md b/verify/CONFORMANCE.md index 0609a7e..3a66502 100644 --- a/verify/CONFORMANCE.md +++ b/verify/CONFORMANCE.md @@ -35,6 +35,7 @@ everyone. | Contract | Ticket | What it holds an adapter to | | --- | --- | --- | | `content-type-gate` | fells-code/seamless-auth-api#558 | A body-less unsafe request to `/auth/*` without `Content-Type: application/json` or `x-seamless-auth-transport` answers `415 unsupported_media_type` | +| `permission-guards` | fells-code/seamless-auth-server#218 | The `/api/permissions` routes guard by permission. Needs the auth API at v0.23 | ## The reference app @@ -56,6 +57,10 @@ everyone. | `GET /` | Any 2xx. The harness waits on it before running | | `/auth/*` | The adapter | | `GET /api/me` | An app route behind the adapter's own guard. `200 { "id": "" }` for a valid session cookie or `Authorization: Bearer `, `401` otherwise | +| `GET /api/permissions/any` | Pending `permission-guards`. Any of `conformance:read`, `conformance:other`. `200 { "id": "" }` when allowed | +| `GET /api/permissions/all` | Pending `permission-guards`. All of `conformance:read`, `conformance:write` | +| `GET /api/permissions/org` | Pending `permission-guards`. Organization scope, `conformance:org` | +| `GET /api/permissions/org/:organizationId` | Pending `permission-guards`. Organization scope bound to the route, `conformance:org` | | `GET /__captured/:recipient` | The most recent message the adapter delivered to `recipient` (an email address or phone number): `{ "token": "", "magicLinkUrl"?: "..." }`, or `null` | `/__captured` is a test hook, never part of a real deployment. The adapter must ask the @@ -104,6 +109,16 @@ session. sign-in flow's ephemeral cookie presented as the access cookie, and the signed-in user's id with a valid one. +**Permission guards** (pending `permission-guards`). +- No session answers `401`. A session without the permission answers + `403 { "error": "insufficient_permission" }`, naming nothing. +- Matching is exact: `conformance:write` does not satisfy `conformance:read`. +- By default a permission counts from `permissions` or `org_permissions`. Organization scope + counts only `org_permissions` and needs an active organization. The bound form also needs the + route's organization id to equal `org_id`. +- When a token carries `permissions_omitted: true`, the guard reads + `GET /users/me/permissions` with the caller's token and applies the same rules. + **Request guards** (cookie transport). - A request with `Sec-Fetch-Site: cross-site` and any method but `GET`, `HEAD` or `OPTIONS` answers `403 cross_site_request_blocked` and reaches nothing: a cross-site diff --git a/verify/adapter-app/server.mjs b/verify/adapter-app/server.mjs index fbe9062..39bd19f 100644 --- a/verify/adapter-app/server.mjs +++ b/verify/adapter-app/server.mjs @@ -1,7 +1,9 @@ import cookieParser from "cookie-parser"; import cors from "cors"; import express from "express"; -import createSeamlessAuthServer, { requireAuth } from "@seamless-auth/express"; +import createSeamlessAuthServer, * as seamless from "@seamless-auth/express"; + +const { requireAuth, requirePermission } = seamless; // The adapter strips OTP/magic-link secrets before responding to the browser, so // the conformance harness can't read codes from responses. These handlers receive @@ -65,6 +67,44 @@ app.get( (req, res) => res.json({ id: req.user.id }), ); +// The `permission-guards` pending contract. Only when the adapter has the guard, because a +// released-mode run installs the published package, which may predate it. Becomes +// unconditional once the contract leaves the pending list. +if (requirePermission) { + const session = requireAuth({ + cookieSecret: process.env.COOKIE_SIGNING_KEY, + authServerUrl: process.env.AUTH_SERVER_URL, + authServerIssuer: ISSUER, + audience: ISSUER, + }); + const me = (req, res) => res.json({ id: req.user.id }); + + app.get( + "/api/permissions/any", + session, + requirePermission(["conformance:read", "conformance:other"]), + me, + ); + app.get( + "/api/permissions/all", + session, + requirePermission(["conformance:read", "conformance:write"], { match: "all" }), + me, + ); + app.get( + "/api/permissions/org", + session, + requirePermission("conformance:org", { organization: true }), + me, + ); + app.get( + "/api/permissions/org/:organizationId", + session, + requirePermission("conformance:org", { organizationParam: "organizationId" }), + me, + ); +} + app.use( "/auth", createSeamlessAuthServer({ diff --git a/verify/adapter-fastify-app/server.mjs b/verify/adapter-fastify-app/server.mjs index 94eda63..bf190d6 100644 --- a/verify/adapter-fastify-app/server.mjs +++ b/verify/adapter-fastify-app/server.mjs @@ -1,6 +1,8 @@ import cors from "@fastify/cors"; import Fastify from "fastify"; -import seamlessAuth, { requireAuth } from "@seamless-auth/fastify"; +import seamlessAuth, * as seamless from "@seamless-auth/fastify"; + +const { requireAuth, requirePermission } = seamless; // The Fastify twin of adapter-app/server.mjs. Same routes, same env contract, // same capture transport: the point of this service is that a spec cannot tell @@ -67,6 +69,37 @@ app.get( async (req) => ({ id: req.user.id }), ); +// The `permission-guards` pending contract. Only when the adapter has the guard, because a +// released-mode run installs the published package, which may predate it. Becomes +// unconditional once the contract leaves the pending list. +if (requirePermission) { + const session = requireAuth({ + cookieSecret: process.env.COOKIE_SIGNING_KEY, + authServerUrl: process.env.AUTH_SERVER_URL, + authServerIssuer: ISSUER, + audience: ISSUER, + }); + const me = async (req) => ({ id: req.user.id }); + const guarded = (path, guard) => app.get(path, { preHandler: [session, guard] }, me); + + guarded( + "/api/permissions/any", + requirePermission(["conformance:read", "conformance:other"]), + ); + guarded( + "/api/permissions/all", + requirePermission(["conformance:read", "conformance:write"], { match: "all" }), + ); + guarded( + "/api/permissions/org", + requirePermission("conformance:org", { organization: true }), + ); + guarded( + "/api/permissions/org/:organizationId", + requirePermission("conformance:org", { organizationParam: "organizationId" }), + ); +} + // Registered under a prefix, which is how the plugin scopes its cookie and origin // hooks. @fastify/cookie comes with the plugin, so it is not registered here. // There is no `issuer` option on this adapter (the Express one takes one); the diff --git a/verify/harness/adapter/permissionGuards.spec.ts b/verify/harness/adapter/permissionGuards.spec.ts new file mode 100644 index 0000000..97ef749 --- /dev/null +++ b/verify/harness/adapter/permissionGuards.spec.ts @@ -0,0 +1,267 @@ +import { APIRequestContext } from "@playwright/test"; +import { randomUUID } from "node:crypto"; + +import { + bearer, + bearerSignup, + BEARER_TRANSPORT, + cookieSignup, + currentUserId, +} from "../lib/conformanceFlows"; +import { OWNER_EMAIL } from "../lib/env"; +import { expect, test } from "../lib/fixtures"; +import { ownerAccessToken, setUserRoles } from "../lib/flows"; +import { requireContract } from "../lib/pendingContracts"; +import { + ensurePermissionModel, + ORG_ROLE, + organizationWithMember, + OVERFLOW_ROLE, + READER_ROLE, + removeMember, + WRITER_ROLE, +} from "../lib/permissionFlows"; + +// The reference app's permission routes (see CONFORMANCE.md). Permissions travel in the access +// token, so every grant here is followed by a refresh before the guard can see it. +const ANY = "/api/permissions/any"; +const ALL = "/api/permissions/all"; +const ORG = "/api/permissions/org"; + +async function refresh(ctx: APIRequestContext) { + const res = await ctx.post("/auth/refresh"); + expect(res.ok(), `refresh -> ${res.status()}`).toBeTruthy(); +} + +async function expectRefused( + ctx: APIRequestContext, + path: string, + headers = {}, +) { + const res = await ctx.get(path, { headers }); + expect(res.status(), `GET ${path}`).toBe(403); + // Names nothing: the required permissions would map out the app's authorization model. + expect(await res.json()).toEqual({ error: "insufficient_permission" }); +} + +async function expectAllowed( + ctx: APIRequestContext, + path: string, + userId: string, + headers = {}, +) { + const res = await ctx.get(path, { headers }); + expect(res.status(), `GET ${path}`).toBe(200); + expect((await res.json()).id).toBe(userId); +} + +function claimsOf(token: string) { + return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()); +} + +test.describe("permission guards (adapter)", () => { + let admin: string; + + test.beforeEach(async ({ actor }) => { + requireContract("permission-guards"); + admin = await ownerAccessToken(actor.ctx, OWNER_EMAIL); + await ensurePermissionModel(actor.ctx, admin); + }); + + async function signedIn(adapterActor: { + ctx: APIRequestContext; + email: string; + }) { + await cookieSignup(adapterActor.ctx, adapterActor.email); + return currentUserId(adapterActor.ctx); + } + + test("refuses a request with no session with 401", async ({ + adapterActor, + }) => { + for (const path of [ANY, ALL, ORG, `${ORG}/${randomUUID()}`]) { + expect((await adapterActor.ctx.get(path)).status(), `GET ${path}`).toBe( + 401, + ); + } + }); + + test("refuses a session holding no permission, naming nothing", async ({ + adapterActor, + }) => { + await signedIn(adapterActor); + + for (const path of [ANY, ALL, ORG, `${ORG}/${randomUUID()}`]) { + await expectRefused(adapterActor.ctx, path); + } + }); + + test("any of: one permission is enough", async ({ actor, adapterActor }) => { + const userId = await signedIn(adapterActor); + await setUserRoles(actor.ctx, admin, userId, ["user", READER_ROLE]); + await refresh(adapterActor.ctx); + + await expectAllowed(adapterActor.ctx, ANY, userId); + }); + + test("all of: every permission is needed", async ({ + actor, + adapterActor, + }) => { + const userId = await signedIn(adapterActor); + await setUserRoles(actor.ctx, admin, userId, ["user", READER_ROLE]); + await refresh(adapterActor.ctx); + await expectRefused(adapterActor.ctx, ALL); + + await setUserRoles(actor.ctx, admin, userId, [ + "user", + READER_ROLE, + WRITER_ROLE, + ]); + await refresh(adapterActor.ctx); + await expectAllowed(adapterActor.ctx, ALL, userId); + }); + + test("matching is exact: write does not imply read", async ({ + actor, + adapterActor, + }) => { + const userId = await signedIn(adapterActor); + await setUserRoles(actor.ctx, admin, userId, ["user", WRITER_ROLE]); + await refresh(adapterActor.ctx); + + await expectRefused(adapterActor.ctx, ANY); + }); + + test("a grant is gone once the role is", async ({ actor, adapterActor }) => { + const userId = await signedIn(adapterActor); + await setUserRoles(actor.ctx, admin, userId, ["user", READER_ROLE]); + await refresh(adapterActor.ctx); + await expectAllowed(adapterActor.ctx, ANY, userId); + + await setUserRoles(actor.ctx, admin, userId, ["user"]); + await refresh(adapterActor.ctx); + await expectRefused(adapterActor.ctx, ANY); + }); + + test("accepts a bearer token in the same way", async ({ + actor, + adapterActor, + }) => { + const session = await bearerSignup(adapterActor.ctx, adapterActor.email); + const userId = await currentUserId(adapterActor.ctx, { + ...BEARER_TRANSPORT, + ...bearer(session.token), + }); + await expectRefused(adapterActor.ctx, ANY, bearer(session.token)); + + await setUserRoles(actor.ctx, admin, userId, ["user", READER_ROLE]); + const refreshed = await adapterActor.ctx.post("/auth/refresh", { + headers: { ...BEARER_TRANSPORT, ...bearer(session.refreshToken) }, + }); + expect(refreshed.ok(), `refresh -> ${refreshed.status()}`).toBeTruthy(); + const { token } = await refreshed.json(); + + await expectAllowed(adapterActor.ctx, ANY, userId, bearer(token)); + }); + + test.describe("organization scope", () => { + async function member( + actor: { ctx: APIRequestContext }, + adapterActor: { ctx: APIRequestContext; email: string }, + roles: string[] = [ORG_ROLE], + ) { + const userId = await signedIn(adapterActor); + const organizationId = await organizationWithMember( + actor.ctx, + admin, + userId, + roles, + ); + const switched = await adapterActor.ctx.post( + `/auth/organizations/${organizationId}/switch`, + ); + expect( + switched.ok(), + `switch -> ${switched.status()} ${await switched.text()}`, + ).toBeTruthy(); + return { userId, organizationId }; + } + + test("a route bound to the organization passes only for that organization", async ({ + actor, + adapterActor, + }) => { + const { userId, organizationId } = await member(actor, adapterActor); + + await expectAllowed(adapterActor.ctx, ORG, userId); + await expectAllowed(adapterActor.ctx, `${ORG}/${organizationId}`, userId); + await expectRefused(adapterActor.ctx, `${ORG}/${randomUUID()}`); + }); + + test("an organization grant counts on a default route", async ({ + actor, + adapterActor, + }) => { + const { userId } = await member(actor, adapterActor); + + await expectAllowed(adapterActor.ctx, ANY, userId); + }); + + test("an instance grant does not count on an organization route", async ({ + actor, + adapterActor, + }) => { + const { userId, organizationId } = await member(actor, adapterActor, [ + "member", + ]); + await setUserRoles(actor.ctx, admin, userId, ["user", READER_ROLE]); + await refresh(adapterActor.ctx); + + await expectAllowed(adapterActor.ctx, ANY, userId); + await expectRefused(adapterActor.ctx, ORG); + await expectRefused(adapterActor.ctx, `${ORG}/${organizationId}`); + }); + + test("removal from the organization reaches the guard at the next token", async ({ + actor, + adapterActor, + }) => { + const { userId, organizationId } = await member(actor, adapterActor); + await expectAllowed(adapterActor.ctx, ORG, userId); + + await removeMember(actor.ctx, admin, organizationId, userId); + await refresh(adapterActor.ctx); + + await expectRefused(adapterActor.ctx, ORG); + await expectRefused(adapterActor.ctx, `${ORG}/${organizationId}`); + }); + }); + + test("reads the live set when the token omits its permissions", async ({ + actor, + adapterActor, + }) => { + const session = await bearerSignup(adapterActor.ctx, adapterActor.email); + const userId = await currentUserId(adapterActor.ctx, { + ...BEARER_TRANSPORT, + ...bearer(session.token), + }); + await setUserRoles(actor.ctx, admin, userId, ["user", OVERFLOW_ROLE]); + const refreshed = await adapterActor.ctx.post("/auth/refresh", { + headers: { ...BEARER_TRANSPORT, ...bearer(session.refreshToken) }, + }); + expect(refreshed.ok(), `refresh -> ${refreshed.status()}`).toBeTruthy(); + const { token } = await refreshed.json(); + + const claims = claimsOf(token); + expect(claims.permissions_omitted, "a set over the budget is omitted").toBe( + true, + ); + expect(claims.permissions).toBeUndefined(); + + // Holds conformance:read through the live read, and not conformance:write. + await expectAllowed(adapterActor.ctx, ANY, userId, bearer(token)); + await expectRefused(adapterActor.ctx, ALL, bearer(token)); + }); +}); diff --git a/verify/harness/lib/pendingContracts.ts b/verify/harness/lib/pendingContracts.ts index 43acd55..8fe4532 100644 --- a/verify/harness/lib/pendingContracts.ts +++ b/verify/harness/lib/pendingContracts.ts @@ -10,6 +10,9 @@ export const PENDING_CONTRACTS = { // A body-less unsafe auth request needs a JSON content type or the transport // header, or it is refused with 415. 'content-type-gate': 'fells-code/seamless-auth-api#558', + // requirePermission on the reference app's /api/permissions routes, which also needs the + // auth API at v0.23. + 'permission-guards': 'fells-code/seamless-auth-server#218', } as const; export type PendingContract = keyof typeof PENDING_CONTRACTS; diff --git a/verify/harness/lib/permissionFlows.ts b/verify/harness/lib/permissionFlows.ts new file mode 100644 index 0000000..8bec86e --- /dev/null +++ b/verify/harness/lib/permissionFlows.ts @@ -0,0 +1,174 @@ +import { APIRequestContext, expect } from "@playwright/test"; + +import { bearer } from "./conformanceFlows"; + +// Roles and permissions the permission guard specs depend on, set up through the admin API. +// Everything here is idempotent, because several adapter projects share one instance. + +export const READ = "conformance:read"; +export const WRITE = "conformance:write"; +export const OTHER = "conformance:other"; +export const ORG = "conformance:org"; + +export const READER_ROLE = "conformance-reader"; +export const WRITER_ROLE = "conformance-writer"; +export const OVERFLOW_ROLE = "conformance-overflow"; +export const ORG_ROLE = "conformance-org-member"; + +// Long enough together to pass the 1 KiB budget, which is what makes a token omit its claims. +const OVERFLOW_PERMISSIONS = Array.from( + { length: 40 }, + (_, index) => `conformance:overflow:permission-with-a-long-name-${index}`, +); + +async function ensurePermission( + ctx: APIRequestContext, + admin: string, + name: string, +) { + const res = await ctx.post("/admin/permissions", { + headers: bearer(admin), + data: { name }, + }); + expect( + [201, 409], + `POST /admin/permissions ${name} -> ${res.status()}`, + ).toContain(res.status()); +} + +async function ensureInstanceRoles( + ctx: APIRequestContext, + admin: string, + roles: string[], +) { + const current = await ctx.get("/system-config/admin", { + headers: bearer(admin), + }); + expect( + current.ok(), + `GET /system-config/admin -> ${current.status()}`, + ).toBeTruthy(); + const available: string[] = (await current.json()).available_roles; + const missing = roles.filter((role) => !available.includes(role)); + + if (missing.length === 0) return; + + const res = await ctx.patch("/system-config/admin", { + headers: bearer(admin), + data: { available_roles: [...available, ...missing] }, + }); + expect( + res.ok(), + `PATCH /system-config/admin -> ${res.status()} ${await res.text()}`, + ).toBeTruthy(); +} + +async function ensureOrganizationRole( + ctx: APIRequestContext, + admin: string, + name: string, +) { + const res = await ctx.post("/admin/organization-roles", { + headers: bearer(admin), + data: { name }, + }); + expect( + [201, 409], + `POST /admin/organization-roles -> ${res.status()}`, + ).toContain(res.status()); +} + +async function setRolePermissions( + ctx: APIRequestContext, + admin: string, + kind: "instance" | "organization", + role: string, + permissions: string[], +) { + const res = await ctx.put(`/admin/roles/${kind}/${role}/permissions`, { + headers: bearer(admin), + data: { permissions }, + }); + expect( + res.ok(), + `PUT role permissions ${role} -> ${res.status()} ${await res.text()}`, + ).toBeTruthy(); +} + +/** The roles and mappings the specs use. `admin` is a stepped-up owner token. */ +export async function ensurePermissionModel( + ctx: APIRequestContext, + admin: string, +): Promise { + for (const name of [READ, WRITE, OTHER, ORG, ...OVERFLOW_PERMISSIONS]) { + await ensurePermission(ctx, admin, name); + } + + await ensureInstanceRoles(ctx, admin, [ + READER_ROLE, + WRITER_ROLE, + OVERFLOW_ROLE, + ]); + await ensureOrganizationRole(ctx, admin, ORG_ROLE); + + await setRolePermissions(ctx, admin, "instance", READER_ROLE, [READ]); + await setRolePermissions(ctx, admin, "instance", WRITER_ROLE, [WRITE]); + await setRolePermissions(ctx, admin, "instance", OVERFLOW_ROLE, [ + READ, + ...OVERFLOW_PERMISSIONS, + ]); + await setRolePermissions(ctx, admin, "organization", ORG_ROLE, [ORG, OTHER]); +} + +/** A new organization with the user as a member holding `roles`. Returns its id. */ +export async function organizationWithMember( + ctx: APIRequestContext, + admin: string, + userId: string, + roles: string[], +): Promise { + const created = await ctx.post("/admin/organizations", { + headers: bearer(admin), + data: { + name: `Conformance ${Date.now()} ${Math.random().toString(36).slice(2, 8)}`, + }, + }); + expect( + created.ok(), + `POST /admin/organizations -> ${created.status()}`, + ).toBeTruthy(); + const organizationId: string = (await created.json()).organization.id; + + const added = await ctx.post( + `/admin/organizations/${organizationId}/members`, + { + headers: bearer(admin), + // A member without this scope cannot switch to the organization. + data: { userId, roles, scopes: ["organization:read"] }, + }, + ); + expect( + added.ok(), + `add member -> ${added.status()} ${await added.text()}`, + ).toBeTruthy(); + + return organizationId; +} + +export async function removeMember( + ctx: APIRequestContext, + admin: string, + organizationId: string, + userId: string, +): Promise { + const res = await ctx.delete( + `/admin/organizations/${organizationId}/members/${userId}`, + { + headers: bearer(admin), + }, + ); + expect( + res.ok(), + `remove member -> ${res.status()} ${await res.text()}`, + ).toBeTruthy(); +}