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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
2 changes: 1 addition & 1 deletion src/controllers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down
65 changes: 65 additions & 0 deletions src/lib/scopedRoles.ts
Original file line number Diff line number Diff line change
@@ -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)),
);
}
11 changes: 8 additions & 3 deletions src/middleware/requireAdmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,26 @@

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) {
logger.error('Admin route hit without authenticated user');
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' });
}

Expand Down
41 changes: 20 additions & 21 deletions src/routes/admin.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -58,7 +57,7 @@ adminRouter.get(
auth: 'access',
summary: 'List organizations',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
},
listAdminOrganizations,
);
Expand All @@ -69,7 +68,7 @@ adminRouter.post(
auth: 'access',
summary: 'Create organization',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('write')],
schemas: {
body: CreateOrganizationRequestSchema,
},
Expand All @@ -83,7 +82,7 @@ adminRouter.get(
auth: 'access',
summary: 'Get organization',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
schemas: {
params: OrganizationIdParamSchema,
},
Expand All @@ -97,7 +96,7 @@ adminRouter.patch(
auth: 'access',
summary: 'Update organization',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('write')],
schemas: {
params: OrganizationIdParamSchema,
body: UpdateOrganizationRequestSchema,
Expand All @@ -112,7 +111,7 @@ adminRouter.get(
auth: 'access',
summary: 'List organization members',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
schemas: {
params: OrganizationIdParamSchema,
},
Expand All @@ -126,7 +125,7 @@ adminRouter.post(
auth: 'access',
summary: 'Add organization member',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('write')],
schemas: {
params: OrganizationIdParamSchema,
body: AddOrganizationMemberRequestSchema,
Expand All @@ -141,7 +140,7 @@ adminRouter.patch(
auth: 'access',
summary: 'Update organization member',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('write')],
schemas: {
params: OrganizationMemberParamSchema,
body: UpdateOrganizationMemberRequestSchema,
Expand All @@ -156,7 +155,7 @@ adminRouter.delete(
auth: 'access',
summary: 'Remove organization member',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('write')],
schemas: {
params: OrganizationMemberParamSchema,
response: {
Expand All @@ -173,7 +172,7 @@ adminRouter.get(
auth: 'access',
summary: 'List users (internal)',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],

schemas: {
response: {
Expand All @@ -189,7 +188,7 @@ adminRouter.get(
'/auth-events',
{
auth: 'access',
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
tags: ['Admin'],
schemas: {
query: AuthEventQuerySchema,
Expand All @@ -207,7 +206,7 @@ adminRouter.get(
auth: 'access',
summary: 'Get credential count',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],

schemas: {
response: {
Expand All @@ -224,7 +223,7 @@ adminRouter.post(
{
auth: 'access',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('write')],
schemas: {
body: CreateUserSchema,
},
Expand All @@ -238,7 +237,7 @@ adminRouter.delete(
auth: 'access',
summary: 'Delete user',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('write')],

schemas: {
response: {
Expand All @@ -256,7 +255,7 @@ adminRouter.patch(
auth: 'access',
summary: 'Update user',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('write')],

schemas: {
body: UpdateUserSchema,
Expand All @@ -276,7 +275,7 @@ adminRouter.get(
{
auth: 'access',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
},
getUserDetail,
);
Expand All @@ -286,7 +285,7 @@ adminRouter.get(
{
auth: 'access',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
},
getUserAnomalies,
);
Expand All @@ -296,7 +295,7 @@ adminRouter.get(
{
auth: 'access',
tags: ['Admin'],
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
schemas: {
query: PaginationQuerySchema,
},
Expand All @@ -308,7 +307,7 @@ adminRouter.get(
'/sessions/:userId',
{
auth: 'access',
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
tags: ['Admin'],
schemas: {
params: UserIdParamSchema,
Expand All @@ -325,7 +324,7 @@ adminRouter.delete(
'/sessions/:userId/revoke-all',
{
auth: 'access',
middleware: [requireAdmin()],
middleware: [requireAdmin('write')],
tags: ['Admin'],
schemas: {
params: UserIdParamSchema,
Expand Down
12 changes: 6 additions & 6 deletions src/routes/internal.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ internalRouter.get(
'/auth-events/summary',
{
auth: 'access',
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
tags: ['Internal'],
schemas: {
query: MetricsQuerySchema,
Expand All @@ -35,7 +35,7 @@ internalRouter.get(
'/auth-events/timeseries',
{
auth: 'access',
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
tags: ['Internal'],
schemas: {
query: MetricsQuerySchema,
Expand All @@ -48,7 +48,7 @@ internalRouter.get(
'/auth-events/login-stats',
{
auth: 'access',
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
tags: ['Internal'],
},
getLoginStats,
Expand All @@ -58,7 +58,7 @@ internalRouter.get(
'/security/anomalies',
{
auth: 'access',
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
summary: 'Detect suspicious activity',
tags: ['Internal'],
},
Expand All @@ -69,7 +69,7 @@ internalRouter.get(
'/metrics/dashboard',
{
auth: 'access',
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
summary: 'Dashboard metrics',
tags: ['Internal'],
},
Expand All @@ -80,7 +80,7 @@ internalRouter.get(
'/auth-events/grouped',
{
auth: 'access',
middleware: [requireAdmin()],
middleware: [requireAdmin('read')],
summary: 'Auth Event metrics grouped',
tags: ['Internal'],
},
Expand Down
Loading
Loading