From 4c1c2eb1fca3ef56052d4cc50c4c606869171317 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 24 May 2026 21:48:24 -0400 Subject: [PATCH] feat: scoped roles support --- README.md | 16 +++++ src/controllers/admin.ts | 2 +- src/lib/scopedRoles.ts | 65 +++++++++++++++++++ src/middleware/requireAdmin.ts | 11 +++- src/routes/admin.routes.ts | 41 ++++++------ src/routes/internal.routes.ts | 12 ++-- src/routes/systemConfig.routes.ts | 6 +- src/schemas/admin.requests.ts | 25 +++++++ src/schemas/admin.responses.ts | 5 +- src/schemas/internal.responses.ts | 6 +- src/schemas/me.response.ts | 23 ++++--- src/schemas/roles.schema.ts | 11 ++++ src/schemas/systemConfig.schema.ts | 6 +- src/schemas/user.schema.ts | 18 +++++ src/services/bootstrapPromotionService.ts | 5 +- src/services/bootstrapService.ts | 2 +- src/services/organizationService.ts | 5 +- tests/integration/admin/admin.spec.ts | 51 +++++++++++++++ .../systemConfig/systemConfig.spec.ts | 12 ++++ tests/unit/lib/scopedRoles.spec.ts | 30 +++++++++ tests/unit/middleware/requireAdmin.spec.ts | 49 ++++++++++++++ .../bootstrapPromotionService.spec.ts | 16 +++++ 22 files changed, 362 insertions(+), 55 deletions(-) create mode 100644 src/lib/scopedRoles.ts create mode 100644 src/schemas/admin.requests.ts create mode 100644 src/schemas/roles.schema.ts create mode 100644 src/schemas/user.schema.ts create mode 100644 tests/unit/lib/scopedRoles.spec.ts diff --git a/README.md b/README.md index 03d6019..e4b60ed 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,22 @@ Security notes: - Existing users are linked by verified email; new users are created only when `allowSignup` is enabled for that provider. +### Scoped Roles + +Global roles may be plain names such as `admin` or scoped names such as `admin:read` and +`admin:write`. The legacy `admin` role remains a broad administrator role and grants both scoped +admin checks. `admin:write` also satisfies `admin:read`; `admin:read` does not satisfy write checks. + +Use `available_roles` to publish the assignable role catalog and `default_roles` for new users. +Role names may contain letters, numbers, and hyphens, with optional colon-separated scope segments. +Whitespace, underscores, slashes, and backslashes are rejected. + +Admin routes are split by intent: + +- read routes accept `admin`, `admin:read`, or `admin:write` +- write routes accept `admin` or `admin:write` +- plain `admin` checks remain exact for backwards compatibility + ### Install & run ``` diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index 17588fb..ff90687 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -4,7 +4,6 @@ * See LICENSE file in the project root for full license information */ -import { CreateUserSchema, UpdateUserSchema } from '@seamless-auth/types'; import { Request, Response } from 'express'; import { Op, WhereOptions } from 'sequelize'; @@ -13,6 +12,7 @@ import { Credential } from '../models/credentials.js'; import { getSequelize } from '../models/index.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; +import { CreateUserSchema, UpdateUserSchema } from '../schemas/admin.requests.js'; import { AuthEventQuerySchema } from '../schemas/internal.query.js'; import { AuthEventService } from '../services/authEventService.js'; import { hardRevokeSession } from '../services/sessionService.js'; diff --git a/src/lib/scopedRoles.ts b/src/lib/scopedRoles.ts new file mode 100644 index 0000000..3e5a309 --- /dev/null +++ b/src/lib/scopedRoles.ts @@ -0,0 +1,65 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +export const ROLE_NAME_PATTERN = /^(?!.*[_/\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$/; + +function normalizeRole(value: string) { + return value.trim(); +} + +function samePrefix(left: string[], right: string[]) { + return left.length === right.length && left.every((part, index) => part === right[index]); +} + +export function roleGrantsAccess(grantedRole: string, requiredRole: string) { + const granted = normalizeRole(grantedRole); + const required = normalizeRole(requiredRole); + + if (!granted || !required) { + return false; + } + + if (granted === required) { + return true; + } + + if (granted.endsWith(':*')) { + const prefix = granted.slice(0, -2); + return required === prefix || required.startsWith(`${prefix}:`); + } + + if (!required.includes(':')) { + return false; + } + + if (!granted.includes(':')) { + return required.startsWith(`${granted}:`); + } + + const grantedParts = granted.split(':'); + const requiredParts = required.split(':'); + const grantedAction = grantedParts.at(-1); + const requiredAction = requiredParts.at(-1); + + return ( + grantedAction === 'write' && + requiredAction === 'read' && + samePrefix(grantedParts.slice(0, -1), requiredParts.slice(0, -1)) + ); +} + +export function hasScopedRole(grantedRoles: unknown, requiredRoles: string | string[]) { + if (!Array.isArray(grantedRoles)) { + return false; + } + + const required = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles]; + const granted = grantedRoles.filter((role): role is string => typeof role === 'string'); + + return required.some((requiredRole) => + granted.some((grantedRole) => roleGrantsAccess(grantedRole, requiredRole)), + ); +} diff --git a/src/middleware/requireAdmin.ts b/src/middleware/requireAdmin.ts index 9a6868d..47dc33d 100644 --- a/src/middleware/requireAdmin.ts +++ b/src/middleware/requireAdmin.ts @@ -6,12 +6,17 @@ import { NextFunction, Response } from 'express'; +import { hasScopedRole } from '../lib/scopedRoles.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; const logger = getLogger('requireAdmin'); -export function requireAdmin() { +export type AdminAccessLevel = 'read' | 'write'; + +export function requireAdmin(accessLevel?: AdminAccessLevel) { + const requiredRole = accessLevel ? `admin:${accessLevel}` : 'admin'; + return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { try { if (!req.user) { @@ -19,8 +24,8 @@ export function requireAdmin() { return res.status(401).json({ error: 'Unauthorized' }); } - if (!req.user.roles?.includes('admin')) { - logger.warn(`User ${req.user.id} attempted admin access without admin role`); + if (!hasScopedRole(req.user.roles, requiredRole)) { + logger.warn(`User ${req.user.id} attempted admin access without ${requiredRole} role`); return res.status(403).json({ error: 'Forbidden' }); } diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 3b705a2..91123b9 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -4,8 +4,6 @@ * See LICENSE file in the project root for full license information */ -import { CreateUserSchema, UpdateUserSchema } from '@seamless-auth/types'; - import { createUser, deleteUser, @@ -32,6 +30,7 @@ import { import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; import { UserIdParamSchema } from '../schemas/admin.query.js'; +import { CreateUserSchema, UpdateUserSchema } from '../schemas/admin.requests.js'; import { UserResponseSchema } from '../schemas/admin.responses.js'; import { InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { AuthEventQuerySchema, PaginationQuerySchema } from '../schemas/internal.query.js'; @@ -58,7 +57,7 @@ adminRouter.get( auth: 'access', summary: 'List organizations', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], }, listAdminOrganizations, ); @@ -69,7 +68,7 @@ adminRouter.post( auth: 'access', summary: 'Create organization', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('write')], schemas: { body: CreateOrganizationRequestSchema, }, @@ -83,7 +82,7 @@ adminRouter.get( auth: 'access', summary: 'Get organization', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], schemas: { params: OrganizationIdParamSchema, }, @@ -97,7 +96,7 @@ adminRouter.patch( auth: 'access', summary: 'Update organization', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('write')], schemas: { params: OrganizationIdParamSchema, body: UpdateOrganizationRequestSchema, @@ -112,7 +111,7 @@ adminRouter.get( auth: 'access', summary: 'List organization members', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], schemas: { params: OrganizationIdParamSchema, }, @@ -126,7 +125,7 @@ adminRouter.post( auth: 'access', summary: 'Add organization member', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('write')], schemas: { params: OrganizationIdParamSchema, body: AddOrganizationMemberRequestSchema, @@ -141,7 +140,7 @@ adminRouter.patch( auth: 'access', summary: 'Update organization member', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('write')], schemas: { params: OrganizationMemberParamSchema, body: UpdateOrganizationMemberRequestSchema, @@ -156,7 +155,7 @@ adminRouter.delete( auth: 'access', summary: 'Remove organization member', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('write')], schemas: { params: OrganizationMemberParamSchema, response: { @@ -173,7 +172,7 @@ adminRouter.get( auth: 'access', summary: 'List users (internal)', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], schemas: { response: { @@ -189,7 +188,7 @@ adminRouter.get( '/auth-events', { auth: 'access', - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], tags: ['Admin'], schemas: { query: AuthEventQuerySchema, @@ -207,7 +206,7 @@ adminRouter.get( auth: 'access', summary: 'Get credential count', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], schemas: { response: { @@ -224,7 +223,7 @@ adminRouter.post( { auth: 'access', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('write')], schemas: { body: CreateUserSchema, }, @@ -238,7 +237,7 @@ adminRouter.delete( auth: 'access', summary: 'Delete user', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('write')], schemas: { response: { @@ -256,7 +255,7 @@ adminRouter.patch( auth: 'access', summary: 'Update user', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('write')], schemas: { body: UpdateUserSchema, @@ -276,7 +275,7 @@ adminRouter.get( { auth: 'access', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], }, getUserDetail, ); @@ -286,7 +285,7 @@ adminRouter.get( { auth: 'access', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], }, getUserAnomalies, ); @@ -296,7 +295,7 @@ adminRouter.get( { auth: 'access', tags: ['Admin'], - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], schemas: { query: PaginationQuerySchema, }, @@ -308,7 +307,7 @@ adminRouter.get( '/sessions/:userId', { auth: 'access', - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], tags: ['Admin'], schemas: { params: UserIdParamSchema, @@ -325,7 +324,7 @@ adminRouter.delete( '/sessions/:userId/revoke-all', { auth: 'access', - middleware: [requireAdmin()], + middleware: [requireAdmin('write')], tags: ['Admin'], schemas: { params: UserIdParamSchema, diff --git a/src/routes/internal.routes.ts b/src/routes/internal.routes.ts index 01b68d0..80999e7 100644 --- a/src/routes/internal.routes.ts +++ b/src/routes/internal.routes.ts @@ -22,7 +22,7 @@ internalRouter.get( '/auth-events/summary', { auth: 'access', - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], tags: ['Internal'], schemas: { query: MetricsQuerySchema, @@ -35,7 +35,7 @@ internalRouter.get( '/auth-events/timeseries', { auth: 'access', - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], tags: ['Internal'], schemas: { query: MetricsQuerySchema, @@ -48,7 +48,7 @@ internalRouter.get( '/auth-events/login-stats', { auth: 'access', - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], tags: ['Internal'], }, getLoginStats, @@ -58,7 +58,7 @@ internalRouter.get( '/security/anomalies', { auth: 'access', - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], summary: 'Detect suspicious activity', tags: ['Internal'], }, @@ -69,7 +69,7 @@ internalRouter.get( '/metrics/dashboard', { auth: 'access', - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], summary: 'Dashboard metrics', tags: ['Internal'], }, @@ -80,7 +80,7 @@ internalRouter.get( '/auth-events/grouped', { auth: 'access', - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], summary: 'Auth Event metrics grouped', tags: ['Internal'], }, diff --git a/src/routes/systemConfig.routes.ts b/src/routes/systemConfig.routes.ts index 13efa26..44efae7 100644 --- a/src/routes/systemConfig.routes.ts +++ b/src/routes/systemConfig.routes.ts @@ -27,7 +27,7 @@ systemConfigRouter.get( summary: 'Get available roles', tags: ['SystemConfig'], - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], }, getAvailableRoles, ); @@ -39,7 +39,7 @@ systemConfigRouter.get( summary: 'Retrieve system configuration', tags: ['SystemConfig'], - middleware: [requireAdmin()], + middleware: [requireAdmin('read')], schemas: { response: { @@ -59,7 +59,7 @@ systemConfigRouter.patch( summary: 'Update system configuration', tags: ['SystemConfig'], - middleware: [requireAdmin()], + middleware: [requireAdmin('write')], schemas: { response: { diff --git a/src/schemas/admin.requests.ts b/src/schemas/admin.requests.ts new file mode 100644 index 0000000..e08fb6a --- /dev/null +++ b/src/schemas/admin.requests.ts @@ -0,0 +1,25 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +import { RoleNameSchema } from './roles.schema.js'; + +export const CreateUserSchema = z.object({ + email: z.email(), + phone: z.string(), + roles: z.array(RoleNameSchema).min(1), +}); + +export const UpdateUserSchema = z + .object({ + email: z.email().optional(), + phone: z.string().min(5).optional(), + emailVerified: z.boolean().optional(), + phoneVerified: z.boolean().optional(), + roles: z.array(RoleNameSchema).min(1).optional(), + }) + .strict(); diff --git a/src/schemas/admin.responses.ts b/src/schemas/admin.responses.ts index f7e2416..d279623 100644 --- a/src/schemas/admin.responses.ts +++ b/src/schemas/admin.responses.ts @@ -4,9 +4,10 @@ * See LICENSE file in the project root for full license information */ -import { UserSchema } from '@seamless-auth/types'; import { z } from 'zod'; +import { ApiUserSchema } from './user.schema.js'; + export const UserResponseSchema = z.object({ - user: UserSchema, + user: ApiUserSchema, }); diff --git a/src/schemas/internal.responses.ts b/src/schemas/internal.responses.ts index 1f97e2c..c1d59d8 100644 --- a/src/schemas/internal.responses.ts +++ b/src/schemas/internal.responses.ts @@ -4,11 +4,13 @@ * See LICENSE file in the project root for full license information */ -import { AuthEventSchema, UserSchema } from '@seamless-auth/types'; +import { AuthEventSchema } from '@seamless-auth/types'; import { z } from 'zod'; +import { ApiUserSchema } from './user.schema.js'; + export const UsersListResponseSchema = z.object({ - users: z.array(UserSchema), + users: z.array(ApiUserSchema), total: z.number(), }); diff --git a/src/schemas/me.response.ts b/src/schemas/me.response.ts index cd6b27d..25f71a4 100644 --- a/src/schemas/me.response.ts +++ b/src/schemas/me.response.ts @@ -4,13 +4,24 @@ * See LICENSE file in the project root for full license information */ -import { CredentialApiSchema, UserSchema } from '@seamless-auth/types'; +import { CredentialApiSchema } from '@seamless-auth/types'; import { z } from 'zod'; +import { RoleNameSchema } from './roles.schema.js'; + const CredentialWithPrfSchema = CredentialApiSchema.extend({ prfCapable: z.boolean().optional(), }); +const MeUserSchema = z.object({ + id: z.string(), + email: z.email(), + phone: z.string(), + roles: z.array(RoleNameSchema), + lastLogin: z.any().optional(), + activeOrganizationId: z.string().nullable().optional(), +}); + const OrganizationMembershipSchema = z.object({ id: z.string(), organizationId: z.string(), @@ -34,15 +45,7 @@ const OrganizationSchema = z.object({ }); export const MeResponseSchema = z.object({ - user: UserSchema.pick({ - id: true, - email: true, - phone: true, - roles: true, - lastLogin: true, - }).extend({ - activeOrganizationId: z.string().nullable().optional(), - }), + user: MeUserSchema, credentials: z.array(CredentialWithPrfSchema), organizations: z.array(OrganizationSchema).optional(), activeOrganization: OrganizationSchema.nullable().optional(), diff --git a/src/schemas/roles.schema.ts b/src/schemas/roles.schema.ts new file mode 100644 index 0000000..65e7a36 --- /dev/null +++ b/src/schemas/roles.schema.ts @@ -0,0 +1,11 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +import { ROLE_NAME_PATTERN } from '../lib/scopedRoles.js'; + +export const RoleNameSchema = z.string().trim().regex(ROLE_NAME_PATTERN); diff --git a/src/schemas/systemConfig.schema.ts b/src/schemas/systemConfig.schema.ts index e8c175e..62ebd29 100644 --- a/src/schemas/systemConfig.schema.ts +++ b/src/schemas/systemConfig.schema.ts @@ -6,6 +6,8 @@ import { z } from 'zod'; +import { RoleNameSchema } from './roles.schema.js'; + export const LoginMethodSchema = z.enum([ 'passkey', 'magic_link', @@ -33,8 +35,8 @@ export const OAuthProviderConfigSchema = z.object({ export const SystemConfigSchema = z.object({ app_name: z.string().min(3), - default_roles: z.array(z.string().regex(/^(?!.*[_/\\\s])[A-Za-z0-9-]{1,31}$/)).min(1), - available_roles: z.array(z.string().regex(/^(?!.*[_/\\\s])[A-Za-z0-9-]{1,31}$/)).min(1), + default_roles: z.array(RoleNameSchema).min(1), + available_roles: z.array(RoleNameSchema).min(1), login_methods: z.array(LoginMethodSchema).min(1), passkey_login_fallback_enabled: z.boolean(), oauth_providers: z.array(OAuthProviderConfigSchema), diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts new file mode 100644 index 0000000..db4da45 --- /dev/null +++ b/src/schemas/user.schema.ts @@ -0,0 +1,18 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +import { RoleNameSchema } from './roles.schema.js'; + +export const ApiUserSchema = z + .object({ + id: z.string(), + email: z.email(), + phone: z.string(), + roles: z.array(RoleNameSchema).default([]), + }) + .passthrough(); diff --git a/src/services/bootstrapPromotionService.ts b/src/services/bootstrapPromotionService.ts index f043f78..99b4e6e 100644 --- a/src/services/bootstrapPromotionService.ts +++ b/src/services/bootstrapPromotionService.ts @@ -9,6 +9,7 @@ import { Request } from 'express'; import { literal, Transaction } from 'sequelize'; import { getBootstrapCookie } from '../lib/bootstrapCookie.js'; +import { hasScopedRole } from '../lib/scopedRoles.js'; import { BootstrapInvite } from '../models/bootstrapInvites.js'; import { getSequelize } from '../models/index.js'; import { User } from '../models/users.js'; @@ -47,7 +48,7 @@ function isBootstrapEnabled(): boolean { } function userHasAdminRole(user: User): boolean { - return Array.isArray(user.roles) && user.roles.includes('admin'); + return hasScopedRole(user.roles, 'admin:write'); } function addAdminRole(user: User): void { @@ -120,7 +121,7 @@ export async function maybePromoteBootstrapAdmin(params: { return getSequelize().transaction(async (transaction: Transaction) => { const adminCount = await User.count({ - where: literal(`'admin' = ANY("roles")`), + where: literal(`"roles" && ARRAY['admin','admin:write']::varchar[]`), transaction, }); diff --git a/src/services/bootstrapService.ts b/src/services/bootstrapService.ts index 6b78788..a0d5563 100644 --- a/src/services/bootstrapService.ts +++ b/src/services/bootstrapService.ts @@ -68,7 +68,7 @@ export async function assertBootstrapAllowed(): Promise { } const adminCount = await User.count({ - where: literal(`'admin' = ANY("roles")`), + where: literal(`"roles" && ARRAY['admin','admin:write']::varchar[]`), }); if (adminCount > 0) { diff --git a/src/services/organizationService.ts b/src/services/organizationService.ts index 5c973e6..f187d3b 100644 --- a/src/services/organizationService.ts +++ b/src/services/organizationService.ts @@ -6,6 +6,7 @@ import { Op } from 'sequelize'; +import { hasScopedRole } from '../lib/scopedRoles.js'; import { OrganizationMembership } from '../models/organizationMemberships.js'; import { Organization } from '../models/organizations.js'; import { User } from '../models/users.js'; @@ -171,7 +172,7 @@ export async function findMembership(userId: string, organizationId: string) { } export function isOrganizationManager(user: User, membership?: OrganizationMembership | null) { - if (user.roles?.includes('admin')) return true; + if (hasScopedRole(user.roles, 'admin:write')) return true; return Boolean(membership?.roles?.some((role) => role === 'owner' || role === 'admin')); } @@ -184,7 +185,7 @@ export async function requireOrganizationAccess(user: User, organizationId: stri const membership = await findMembership(user.id, organizationId); - if (!membership && !user.roles?.includes('admin')) { + if (!membership && !hasScopedRole(user.roles, 'admin:read')) { return { organization: null, membership: null }; } diff --git a/tests/integration/admin/admin.spec.ts b/tests/integration/admin/admin.spec.ts index 7933c25..4f19f68 100644 --- a/tests/integration/admin/admin.spec.ts +++ b/tests/integration/admin/admin.spec.ts @@ -186,6 +186,44 @@ describe('POST /admin/users', () => { expect(res.body.user).toBeDefined(); }); + it('creates user with scoped roles', async () => { + (User.findOne as any).mockResolvedValue(null); + + (User.create as any).mockResolvedValue({ + id: 'user-1', + email: 'test@example.com', + phone: '+14155552671', + roles: ['admin:read'], + }); + + const res = await request(app) + .post('/admin/users') + .send({ + email: 'test@example.com', + phone: '+14155552671', + roles: ['admin:read'], + }); + + expect(res.status).toBe(201); + expect(User.create).toHaveBeenCalledWith( + expect.objectContaining({ + roles: ['admin:read'], + }), + ); + }); + + it('rejects scoped roles with invalid separators', async () => { + const res = await request(app) + .post('/admin/users') + .send({ + email: 'test@example.com', + phone: '+14155552671', + roles: ['admin/read'], + }); + + expect(res.status).toBe(400); + }); + it('returns 409 if user already exists', async () => { (User.findOne as any).mockResolvedValue(buildUser()); @@ -221,6 +259,19 @@ describe('PATCH /admin/users/:userId', () => { expect(user.update).toHaveBeenCalled(); }); + it('updates scoped roles successfully', async () => { + const user = buildUser(); + + (User.findByPk as any).mockResolvedValue(user); + + const res = await request(app) + .patch('/admin/users/user-1') + .send({ roles: ['admin:write'] }); + + expect(res.status).toBe(200); + expect(user.update).toHaveBeenCalledWith({ roles: ['admin:write'] }); + }); + it('returns 404 if user not found', async () => { (User.findByPk as any).mockResolvedValue(null); diff --git a/tests/integration/systemConfig/systemConfig.spec.ts b/tests/integration/systemConfig/systemConfig.spec.ts index 1699b36..88fe387 100644 --- a/tests/integration/systemConfig/systemConfig.spec.ts +++ b/tests/integration/systemConfig/systemConfig.spec.ts @@ -77,6 +77,18 @@ describe('PATCH /system-config/admin', () => { expect(invalidateSystemConfigCache).toHaveBeenCalled(); }); + it('accepts scoped role names', async () => { + (User.findAll as any).mockResolvedValue([]); + (SystemConfig.findAll as any).mockResolvedValue([]); + + const res = await request(app) + .patch('/system-config/admin') + .send({ available_roles: ['user', 'admin:read', 'admin:write'] }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + it('rejects invalid payload', async () => { const res = await request(app).patch('/system-config/admin').send({ invalid: true }); diff --git a/tests/unit/lib/scopedRoles.spec.ts b/tests/unit/lib/scopedRoles.spec.ts new file mode 100644 index 0000000..8c034b9 --- /dev/null +++ b/tests/unit/lib/scopedRoles.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { hasScopedRole, roleGrantsAccess } from '../../../src/lib/scopedRoles.js'; + +describe('scoped roles', () => { + it('matches exact roles', () => { + expect(roleGrantsAccess('admin', 'admin')).toBe(true); + expect(roleGrantsAccess('admin:read', 'admin:read')).toBe(true); + }); + + it('lets a broad legacy role grant scoped access', () => { + expect(roleGrantsAccess('admin', 'admin:read')).toBe(true); + expect(roleGrantsAccess('admin', 'admin:write')).toBe(true); + }); + + it('lets write satisfy read for the same role path', () => { + expect(roleGrantsAccess('admin:write', 'admin:read')).toBe(true); + expect(roleGrantsAccess('admin:users:write', 'admin:users:read')).toBe(true); + }); + + it('does not let read satisfy write or a broad role check', () => { + expect(roleGrantsAccess('admin:read', 'admin:write')).toBe(false); + expect(roleGrantsAccess('admin:read', 'admin')).toBe(false); + }); + + it('checks any granted role against any required role', () => { + expect(hasScopedRole(['user', 'admin:read'], ['admin:write', 'admin:read'])).toBe(true); + expect(hasScopedRole(['user'], ['admin:write', 'admin:read'])).toBe(false); + }); +}); diff --git a/tests/unit/middleware/requireAdmin.spec.ts b/tests/unit/middleware/requireAdmin.spec.ts index 5c22ab5..68c3582 100644 --- a/tests/unit/middleware/requireAdmin.spec.ts +++ b/tests/unit/middleware/requireAdmin.spec.ts @@ -81,6 +81,55 @@ describe('requireAdmin', () => { expect(next).toHaveBeenCalled(); }); + it('calls next for admin read user on read routes', async () => { + const { requireAdmin } = await import('../../../src/middleware/requireAdmin'); + + const middleware = requireAdmin('read'); + + req.clientId = 'client-1'; + req.user = { + id: 'user-1', + roles: ['admin:read'], + }; + + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('allows admin write users on read routes', async () => { + const { requireAdmin } = await import('../../../src/middleware/requireAdmin'); + + const middleware = requireAdmin('read'); + + req.clientId = 'client-1'; + req.user = { + id: 'user-1', + roles: ['admin:write'], + }; + + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('rejects admin read users on write routes', async () => { + const { requireAdmin } = await import('../../../src/middleware/requireAdmin'); + + const middleware = requireAdmin('write'); + + req.clientId = 'client-1'; + req.user = { + id: 'user-1', + roles: ['admin:read'], + }; + + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + it('returns 500 if unexpected error occurs', async () => { const { requireAdmin } = await import('../../../src/middleware/requireAdmin'); diff --git a/tests/unit/services/bootstrapPromotionService.spec.ts b/tests/unit/services/bootstrapPromotionService.spec.ts index a3ec442..5f78528 100644 --- a/tests/unit/services/bootstrapPromotionService.spec.ts +++ b/tests/unit/services/bootstrapPromotionService.spec.ts @@ -105,6 +105,22 @@ it('skips if user already admin', async () => { }); }); +it('skips if user already has admin write scope', async () => { + const user = baseUser(); + user.roles = ['admin:write']; + + const result = await maybePromoteBootstrapAdmin({ + user, + req: mockReq, + completionMethod: 'webauthn_registration', + }); + + expect(result).toEqual({ + promoted: false, + reason: 'already_admin', + }); +}); + it('returns missing_token when no cookie', async () => { (getBootstrapCookie as any).mockReturnValue(null);