feat(rbac): enforce scoped administration and audited permission changes

This commit is contained in:
mihir 2026-09-08 21:24:48 +05:30
parent 30776b9181
commit c67d686cbf
17 changed files with 1361 additions and 1 deletions

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { EnvironmentModule } from './config/environment.module';
import { IdentityModule } from './identity/identity.module';
import { HealthModule } from './health/health.module';
@Module({ imports: [EnvironmentModule, HealthModule] })
@Module({ imports: [EnvironmentModule, HealthModule, IdentityModule] })
export class AppModule {}

View File

@ -0,0 +1,40 @@
import 'reflect-metadata';
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { z } from 'zod';
import { AppModule } from '../app.module';
import { BootstrapService } from '../identity/bootstrap.service';
import { createUserSchema } from '../identity/identity.schemas';
async function main() {
const input = createUserSchema.safeParse({
email: process.env.OWNER_EMAIL,
name: process.env.OWNER_NAME,
password: process.env.OWNER_PASSWORD,
});
const name = z
.string()
.trim()
.min(1)
.max(160)
.safeParse(process.env.ORGANIZATION_NAME);
if (!input.success || !name.success)
throw new Error('Invalid bootstrap configuration');
const app = await NestFactory.createApplicationContext(AppModule, {
logger: false,
});
try {
const result = await app
.get(BootstrapService)
.createOwner(name.data, input.data);
console.log(JSON.stringify(result));
} finally {
await app.close();
}
}
void main().catch(() => {
console.error(
'Bootstrap failed. Check configuration, database availability, and whether an owner already exists.',
);
process.exitCode = 1;
});

View File

@ -1,6 +1,8 @@
import { Public } from '../identity/access.decorator';
import { Controller, Get } from '@nestjs/common';
import { HealthService } from './health.service';
@Public()
@Controller('health')
export class HealthController {
constructor(private readonly health: HealthService) {}

View File

@ -0,0 +1,45 @@
import {
ConflictException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { Prisma } from '../generated/prisma/client';
import { readPrincipal } from './session.store';
import type { Principal } from './identity.types';
import type { Permission } from './permissions';
@Injectable()
export class AccessStore {
constructor(private readonly db: DatabaseService) {}
async mutate<T>(
actor: Principal,
permission: Permission,
work: (tx: Prisma.TransactionClient, current: Principal) => Promise<T>,
): Promise<T> {
try {
return await this.db.$transaction(async (tx) => {
// Serialize administration within an organization and recheck permissions after locking.
await tx.$queryRaw`SELECT id FROM organizations WHERE id = ${actor.organizationId}::uuid FOR UPDATE`;
const current = await readPrincipal(tx, actor.sessionId);
if (
current.organizationId !== actor.organizationId ||
!current.permissions.includes(permission)
) {
throw new ForbiddenException();
}
return work(tx, current);
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(
'A record with these details already exists',
);
}
throw error;
}
}
}

View File

@ -0,0 +1,24 @@
import { Controller, Get, Query } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { SchemaPipe } from '../common/validation.pipe';
import { CurrentPrincipal, RequirePermission } from './access.decorator';
import { pageSchema, type PageInput } from './identity.schemas';
import type { Principal } from './identity.types';
@Controller('audit-events')
export class AuditController {
constructor(private readonly db: DatabaseService) {}
@Get()
@RequirePermission('audit.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.db.auditEvent.findMany({
where: { organizationId: actor.organizationId },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: page.limit,
skip: page.offset,
});
}
}

View File

@ -0,0 +1,51 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { DatabaseModule } from '../database/database.module';
import { AccessGuard } from './access.guard';
import { AccessStore } from './access.store';
import { AuthRateGuard } from './auth-rate.guard';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthStore } from './auth.store';
import { AuditController } from './audit.controller';
import { BootstrapService } from './bootstrap.service';
import { PasswordService } from './password.service';
import { RateLimitService } from './rate-limit.service';
import { RecoveryController } from './recovery.controller';
import { RecoveryMailer } from './recovery-mailer';
import { RecoveryService } from './recovery.service';
import { RecoveryStore } from './recovery.store';
import { RoleStore } from './role.store';
import { RolesController } from './roles.controller';
import { SessionStore } from './session.store';
import { UserStore } from './user.store';
import { UsersController } from './users.controller';
@Module({
imports: [DatabaseModule],
controllers: [
AuthController,
RecoveryController,
UsersController,
RolesController,
AuditController,
],
providers: [
AccessStore,
AuthRateGuard,
AuthService,
AuthStore,
BootstrapService,
PasswordService,
RateLimitService,
RecoveryMailer,
RecoveryService,
RecoveryStore,
RoleStore,
SessionStore,
UserStore,
{ provide: APP_GUARD, useClass: AccessGuard },
],
exports: [BootstrapService],
})
export class IdentityModule {}

104
src/identity/role.store.ts Normal file
View File

@ -0,0 +1,104 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from './access.store';
import { recordAudit } from './audit';
import type { Principal } from './identity.types';
import type { PageInput, RoleInput } from './identity.schemas';
function ensureGrantable(actor: Principal, permissions: string[]) {
if (
permissions.some((permission) => !actor.permissions.includes(permission))
) {
throw new ForbiddenException('Cannot grant permissions you do not hold');
}
}
@Injectable()
export class RoleStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
list(actor: Principal, page: PageInput) {
return this.db.role.findMany({
where: { organizationId: actor.organizationId },
orderBy: { id: 'asc' },
take: page.limit,
skip: page.offset,
});
}
save(actor: Principal, input: RoleInput, id?: string) {
return this.access.mutate(actor, 'roles.manage', async (tx, current) => {
ensureGrantable(current, input.permissions);
if (id) {
const role = await tx.role.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!role) throw new NotFoundException();
if (role.isSystem)
throw new ForbiddenException('System role is immutable');
ensureGrantable(current, role.permissions);
}
const role = id
? await tx.role.update({ where: { id }, data: input })
: await tx.role.create({
data: { ...input, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'role.updated' : 'role.created',
role.id,
);
return role;
});
}
assign(actor: Principal, userId: string, roleIds: string[]) {
return this.access.mutate(
actor,
'users.roles.assign',
async (tx, current) => {
const user = await tx.user.findFirst({
where: { id: userId, organizationId: actor.organizationId },
include: { roles: { include: { role: true } } },
});
if (!user) throw new NotFoundException();
if (user.isOwner || user.id === current.userId)
throw new ForbiddenException('Cannot change these role assignments');
ensureGrantable(
current,
user.roles.flatMap((assignment) => assignment.role.permissions),
);
const roles = await tx.role.findMany({
where: { id: { in: roleIds }, organizationId: actor.organizationId },
});
if (roles.length !== roleIds.length) throw new NotFoundException();
if (roles.some((role) => role.isSystem))
throw new ForbiddenException('System role cannot be assigned');
ensureGrantable(
current,
roles.flatMap((role) => role.permissions),
);
await tx.userRole.deleteMany({ where: { userId } });
await tx.userRole.createMany({
data: roleIds.map((roleId) => ({
userId,
roleId,
organizationId: actor.organizationId,
})),
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'user.roles_assigned',
userId,
);
},
);
}
}

View File

@ -0,0 +1,56 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import { CurrentPrincipal, RequirePermission } from './access.decorator';
import { RoleStore } from './role.store';
import { PERMISSIONS } from './permissions';
import {
pageSchema,
roleSchema,
type PageInput,
type RoleInput,
} from './identity.schemas';
import type { Principal } from './identity.types';
@Controller('roles')
export class RolesController {
constructor(private readonly roles: RoleStore) {}
@Get('permissions')
@RequirePermission('roles.read')
permissions() {
return PERMISSIONS;
}
@Get()
@RequirePermission('roles.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.roles.list(actor, page);
}
@Post()
@RequirePermission('roles.manage')
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(roleSchema)) input: RoleInput,
) {
return this.roles.save(actor, input);
}
@Patch(':id')
@RequirePermission('roles.manage')
update(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(roleSchema)) input: RoleInput,
) {
return this.roles.save(actor, input, id);
}
}

View File

@ -0,0 +1,91 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from './access.store';
import { PasswordService } from './password.service';
import { recordAudit } from './audit';
import type { Principal } from './identity.types';
import type { CreateUserInput, PageInput } from './identity.schemas';
const publicUser = {
id: true,
email: true,
name: true,
status: true,
isOwner: true,
createdAt: true,
} as const;
@Injectable()
export class UserStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
private readonly passwords: PasswordService,
) {}
list(actor: Principal, page: PageInput) {
return this.db.user.findMany({
where: { organizationId: actor.organizationId },
select: publicUser,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: page.limit,
skip: page.offset,
});
}
async create(actor: Principal, input: CreateUserInput) {
const passwordHash = await this.passwords.hash(input.password);
return this.access.mutate(actor, 'users.create', async (tx) => {
const user = await tx.user.create({
data: {
organizationId: actor.organizationId,
email: input.email,
name: input.name,
passwordHash,
},
select: publicUser,
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'user.created',
user.id,
);
return user;
});
}
async setStatus(
actor: Principal,
id: string,
status: 'ACTIVE' | 'SUSPENDED',
) {
return this.access.mutate(actor, 'users.approve', async (tx) => {
const user = await tx.user.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!user) throw new NotFoundException();
if (user.isOwner || user.id === actor.userId)
throw new ForbiddenException('Cannot change this account status');
const updated = await tx.user.update({
where: { id },
data: { status },
select: publicUser,
});
if (status === 'SUSPENDED') {
await tx.session.deleteMany({ where: { userId: id } });
await tx.recoveryToken.deleteMany({ where: { userId: id } });
}
await recordAudit(
tx,
actor.organizationId,
actor.userId,
`user.${status.toLowerCase()}`,
id,
);
return updated;
});
}
}

View File

@ -0,0 +1,67 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import { CurrentPrincipal, RequirePermission } from './access.decorator';
import { UserStore } from './user.store';
import { RoleStore } from './role.store';
import {
createUserSchema,
statusSchema,
assignmentsSchema,
pageSchema,
type CreateUserInput,
type PageInput,
} from './identity.schemas';
import type { Principal } from './identity.types';
@Controller('users')
export class UsersController {
constructor(
private readonly users: UserStore,
private readonly roles: RoleStore,
) {}
@Get()
@RequirePermission('users.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.users.list(actor, page);
}
@Post()
@RequirePermission('users.create')
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(createUserSchema)) input: CreateUserInput,
) {
return this.users.create(actor, input);
}
@Patch(':id/status')
@RequirePermission('users.approve')
status(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(statusSchema))
input: { status: 'ACTIVE' | 'SUSPENDED' },
) {
return this.users.setStatus(actor, id, input.status);
}
@Patch(':id/roles')
@RequirePermission('users.roles.assign')
async assign(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(assignmentsSchema)) input: { roleIds: string[] },
) {
await this.roles.assign(actor, id, input.roleIds);
return { id };
}
}

127
test/admin-policy.spec.ts Normal file
View File

@ -0,0 +1,127 @@
import { UserStore } from '../src/identity/user.store';
import { RoleStore } from '../src/identity/role.store';
import { AccessStore } from '../src/identity/access.store';
import { DatabaseService } from '../src/database/database.service';
import { PasswordService } from '../src/identity/password.service';
import type { Principal } from '../src/identity/identity.types';
import type { Prisma } from '../src/generated/prisma/client';
describe('administration use-case policy', () => {
const actor: Principal = {
userId: 'actor',
organizationId: 'org',
sessionId: 'session',
permissions: ['roles.manage'],
};
const tx = {
user: { create: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
role: {
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
findMany: jest.fn(),
},
auditEvent: { create: jest.fn() },
session: { deleteMany: jest.fn() },
recoveryToken: { deleteMany: jest.fn() },
userRole: { deleteMany: jest.fn(), createMany: jest.fn() },
};
const access = { mutate: jest.fn() };
const passwords = { hash: jest.fn() };
const users = new UserStore(
{} as DatabaseService,
access as unknown as AccessStore,
passwords as unknown as PasswordService,
);
const roles = new RoleStore(
{} as DatabaseService,
access as unknown as AccessStore,
);
beforeEach(() => {
jest.resetAllMocks();
access.mutate.mockImplementation(
(
_actor,
_permission,
work: (tx: Prisma.TransactionClient, current: Principal) => unknown,
) => work(tx as unknown as Prisma.TransactionClient, actor),
);
});
it('creates pending users without passing plaintext to persistence', async () => {
passwords.hash.mockResolvedValue('encoded');
tx.user.create.mockResolvedValue({ id: 'new' });
await users.create(actor, {
email: 'a@example.com',
name: 'A',
password: 'secret',
});
expect(tx.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: {
organizationId: 'org',
email: 'a@example.com',
name: 'A',
passwordHash: 'encoded',
},
}),
);
expect(tx.auditEvent.create).toHaveBeenCalledWith({
data: {
organizationId: 'org',
actorId: 'actor',
action: 'user.created',
targetId: 'new',
},
});
});
it('hides users outside the organization scope', async () => {
tx.user.findFirst.mockResolvedValue(null);
await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow(
'Not Found',
);
expect(tx.user.update).not.toHaveBeenCalled();
});
it('revokes sessions and recovery on suspension', async () => {
tx.user.findFirst.mockResolvedValue({ id: 'employee', isOwner: false });
await users.setStatus(actor, 'employee', 'SUSPENDED');
expect(tx.session.deleteMany).toHaveBeenCalledWith({
where: { userId: 'employee' },
});
expect(tx.recoveryToken.deleteMany).toHaveBeenCalledWith({
where: { userId: 'employee' },
});
});
it('prevents modifying an existing role more powerful than the actor', async () => {
tx.role.findFirst.mockResolvedValue({
isSystem: false,
permissions: ['audit.read'],
});
await expect(
roles.save(actor, { name: 'Changed', permissions: [] }, 'role'),
).rejects.toThrow('Cannot grant');
expect(tx.role.update).not.toHaveBeenCalled();
});
it('allows only known role targets in the actor organization', async () => {
tx.role.findFirst.mockResolvedValue(null);
await expect(
roles.save(actor, { name: 'Changed', permissions: [] }, 'foreign'),
).rejects.toThrow('Not Found');
});
it('does not remove a target user permissions the actor cannot grant', async () => {
tx.user.findFirst.mockResolvedValue({
id: 'employee',
isOwner: false,
roles: [{ role: { permissions: ['audit.read'] } }],
});
await expect(roles.assign(actor, 'employee', [])).rejects.toThrow(
'Cannot grant',
);
expect(tx.userRole.deleteMany).not.toHaveBeenCalled();
});
it('hides missing assignment targets', async () => {
tx.user.findFirst.mockResolvedValue(null);
await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow(
'Not Found',
);
});
});

View File

@ -0,0 +1,68 @@
import 'reflect-metadata';
import { testDatabase } from './test-database';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../../src/app.module';
import { ENVIRONMENT } from '../../src/config/environment.module';
import { parseEnvironment } from '../../src/config/environment';
import { DatabaseService } from '../../src/database/database.service';
import { BootstrapService } from '../../src/identity/bootstrap.service';
import { RecoveryMailer } from '../../src/identity/recovery-mailer';
import { configureApp } from '../../src/configure-app';
export const ownerPassword = 'correct horse battery staple';
export async function identityApp() {
const database = await testDatabase();
const env = parseEnvironment({
DATABASE_URL: database.connectionUrl,
NODE_ENV: 'test',
});
const mailer = {
assertConfigured: jest.fn(),
send: jest.fn().mockResolvedValue(undefined),
};
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(ENVIRONMENT)
.useValue(env)
.overrideProvider(RecoveryMailer)
.useValue(mailer)
.compile();
const app = module.createNestApplication();
configureApp(app, env);
await app.init();
const db = app.get(DatabaseService);
const owner = await app.get(BootstrapService).createOwner('Mani Candles', {
email: 'owner@example.com',
name: 'Owner',
password: ownerPassword,
});
const api = () => request(app.getHttpServer());
const login = async (
email = 'owner@example.com',
password = ownerPassword,
organizationId = owner.organizationId,
) =>
api().post('/api/v1/auth/login').send({ organizationId, email, password });
const response = await login();
if (response.status !== 200)
throw new Error(`Fixture login failed: ${response.status}`);
const token = response.body.accessToken as string;
return {
app,
db,
owner,
token,
api,
login,
mailer,
async clearLimits() {
await db.rateLimit.deleteMany();
},
async close() {
await app.close();
await database.close();
},
};
}
export type IdentityApp = Awaited<ReturnType<typeof identityApp>>;

View File

@ -0,0 +1,70 @@
import { randomBytes } from 'node:crypto';
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { Client } from 'pg';
import { PGlite } from '@electric-sql/pglite';
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
export const legacyOrganizationId = '00000000-0000-4000-8000-000000000001';
export async function testDatabase() {
let connectionUrl: string;
let execute: (sql: string) => Promise<unknown>;
let close: () => Promise<void>;
if (process.env.TEST_DATABASE_URL) {
// CI supplies a disposable PostgreSQL administrator URL, never a production URL.
const administrator = new Client({
connectionString: process.env.TEST_DATABASE_URL,
});
await administrator.connect();
const name = 'mani_test_' + randomBytes(8).toString('hex');
await administrator.query(`CREATE DATABASE "${name}"`);
const url = new URL(process.env.TEST_DATABASE_URL);
url.pathname = '/' + name;
connectionUrl = url.toString();
const client = new Client({ connectionString: connectionUrl });
await client.connect();
execute = (sql) => client.query(sql);
close = async () => {
await client.end();
await administrator.query(`DROP DATABASE "${name}" WITH (FORCE)`);
await administrator.end();
};
} else {
const pg = await PGlite.create();
const server = new PGLiteSocketServer({
db: pg,
port: 0,
host: '127.0.0.1',
maxConnections: 1,
});
await server.start();
connectionUrl = `postgresql://postgres:postgres@${server.getServerConn()}/postgres`;
execute = (sql) => pg.exec(sql);
close = async () => {
await server.stop();
await pg.close();
};
}
try {
const migrations = (await readdir('prisma/migrations'))
.filter((path) => /^\d/.test(path))
.sort();
for (const path of migrations) {
await execute(
await readFile(
join('prisma/migrations', path, 'migration.sql'),
'utf8',
),
);
if (path === '202609080001_create_organizations') {
await execute(
`INSERT INTO organizations (id, name, updated_at) VALUES ('${legacyOrganizationId}', 'Existing organization', NOW())`,
);
}
}
return { connectionUrl, close, execute };
} catch (error) {
await close();
throw error;
}
}

160
test/identity-auth.spec.ts Normal file
View File

@ -0,0 +1,160 @@
import {
identityApp,
type IdentityApp,
ownerPassword,
} from './helpers/identity-app';
import { hashToken } from '../src/identity/tokens';
describe('authentication with migrated PostgreSQL engine', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
it('stores only hashed session tokens and never exposes password hashes', async () => {
const session = await ctx.db.session.findUniqueOrThrow({
where: { tokenHash: hashToken(ctx.token) },
});
expect(session.tokenHash).not.toBe(ctx.token);
const me = await ctx
.api()
.get('/api/v1/auth/me')
.auth(ctx.token, { type: 'bearer' })
.expect(200);
expect(me.body.organizationId).toBe(ctx.owner.organizationId);
expect(me.text).not.toContain('password');
expect(me.headers['cache-control']).toBe('no-store');
});
it('rejects missing, malformed, unknown and expired sessions', async () => {
await ctx.api().get('/api/v1/users').expect(401);
await ctx
.api()
.get('/api/v1/users')
.set('Authorization', 'Bearer bad')
.expect(401);
await ctx
.api()
.get('/api/v1/users')
.auth('a'.repeat(43), { type: 'bearer' })
.expect(401);
const login = await ctx.login();
await ctx.db.session.update({
where: { tokenHash: hashToken(login.body.accessToken) },
data: { expiresAt: new Date(0) },
});
await ctx
.api()
.get('/api/v1/auth/me')
.auth(login.body.accessToken, { type: 'bearer' })
.expect(401);
});
it('returns the same credential error for unknown email and wrong password', async () => {
const missing = await ctx.login('missing@example.com');
const wrong = await ctx.login('owner@example.com', 'wrong password');
expect(missing.status).toBe(401);
expect(wrong.body).toEqual(missing.body);
});
it('validates payloads without echoing secrets and rejects mass assignment', async () => {
const response = await ctx
.api()
.post('/api/v1/auth/login')
.send({
organizationId: ctx.owner.organizationId,
email: 'owner@example.com',
password: 'SECRET',
isOwner: true,
})
.expect(400);
expect(response.text).not.toContain('SECRET');
});
it('revokes a session on logout', async () => {
const login = await ctx.login();
const bearer = login.body.accessToken;
await ctx
.api()
.post('/api/v1/auth/logout')
.auth(bearer, { type: 'bearer' })
.expect(204);
await ctx
.api()
.get('/api/v1/auth/me')
.auth(bearer, { type: 'bearer' })
.expect(401);
});
it('limits repeated attempts per account in durable storage', async () => {
for (let i = 0; i < 10; i++)
expect((await ctx.login('unknown@example.com')).status).toBe(401);
expect((await ctx.login('unknown@example.com')).status).toBe(429);
}, 15000);
it('enforces IP throttling even for malformed input', async () => {
for (let i = 0; i < 30; i++)
await ctx.api().post('/api/v1/auth/login').send({}).expect(400);
await ctx.api().post('/api/v1/auth/login').send({}).expect(429);
});
it('recovery consumes a token once and revokes existing sessions', async () => {
await ctx
.api()
.post('/api/v1/auth/recovery/request')
.send({
organizationId: ctx.owner.organizationId,
email: 'owner@example.com',
})
.expect(202);
const recoveryToken = ctx.mailer.send.mock.calls.at(-1)![1] as string;
const newPassword = 'a replacement secure passphrase';
await ctx
.api()
.post('/api/v1/auth/recovery/reset')
.send({ token: recoveryToken, password: newPassword })
.expect(204);
await ctx
.api()
.post('/api/v1/auth/recovery/reset')
.send({ token: recoveryToken, password: newPassword })
.expect(400);
await ctx
.api()
.get('/api/v1/auth/me')
.auth(ctx.token, { type: 'bearer' })
.expect(401);
expect((await ctx.login('owner@example.com', ownerPassword)).status).toBe(
401,
);
const login = await ctx.login('owner@example.com', newPassword);
expect(login.status).toBe(200);
ctx.token = login.body.accessToken;
});
it('revokes all sessions and records security events without secrets', async () => {
const login = await ctx.login(
'owner@example.com',
'a replacement secure passphrase',
);
await ctx
.api()
.post('/api/v1/auth/logout-all')
.auth(ctx.token, { type: 'bearer' })
.expect(204);
await ctx
.api()
.get('/api/v1/auth/me')
.auth(login.body.accessToken, { type: 'bearer' })
.expect(401);
const events = await ctx.db.auditEvent.findMany();
expect(events.map((event) => event.action)).toEqual(
expect.arrayContaining([
'auth.login',
'auth.login_failed',
'auth.password_reset',
'auth.logout_all',
]),
);
expect(JSON.stringify(events)).not.toContain(ctx.token);
expect(JSON.stringify(events)).not.toContain(ownerPassword);
});
});

View File

@ -0,0 +1,140 @@
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { AuthStore } from '../src/identity/auth.store';
import { RecoveryStore } from '../src/identity/recovery.store';
import { AccessStore } from '../src/identity/access.store';
import { RateLimitService } from '../src/identity/rate-limit.service';
import { SessionStore } from '../src/identity/session.store';
import { issueToken, hashToken } from '../src/identity/tokens';
describe('identity transactional failure paths', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
it('does not issue a session using an obsolete password hash', async () => {
const issued = issueToken();
await expect(
ctx.app
.get(AuthStore)
.createSession(
ctx.owner.userId,
'outdated',
issued.tokenHash,
new Date(Date.now() + 60000),
),
).rejects.toThrow();
expect(
await ctx.db.session.findUnique({
where: { tokenHash: issued.tokenHash },
}),
).toBeNull();
});
it('rejects a session whose account is no longer active even without explicit revocation', async () => {
await ctx.db.user.update({
where: { id: ctx.owner.userId },
data: { status: 'SUSPENDED' },
});
await expect(
ctx.app.get(SessionStore).authenticate(ctx.token),
).rejects.toThrow();
await ctx.db.user.update({
where: { id: ctx.owner.userId },
data: { status: 'ACTIVE' },
});
});
it('rejects expired recovery tokens without changing credentials', async () => {
const issued = issueToken();
await ctx.app
.get(RecoveryStore)
.create(ctx.owner.userId, issued.tokenHash, new Date(0));
await expect(
ctx.app.get(RecoveryStore).reset(issued.tokenHash, 'new-hash'),
).rejects.toThrow('Invalid or expired');
expect((await ctx.login()).status).toBe(200);
});
it('rechecks account eligibility when storing and consuming recovery tokens', async () => {
const issued = issueToken();
await ctx.app
.get(RecoveryStore)
.create(ctx.owner.userId, issued.tokenHash, new Date(Date.now() + 60000));
await ctx.db.user.update({
where: { id: ctx.owner.userId },
data: { status: 'SUSPENDED' },
});
expect(
await ctx.app
.get(RecoveryStore)
.create(ctx.owner.userId, issueToken().tokenHash, new Date()),
).toBe(false);
await expect(
ctx.app.get(RecoveryStore).reset(issued.tokenHash, 'new-hash'),
).rejects.toThrow();
await ctx.db.user.update({
where: { id: ctx.owner.userId },
data: { status: 'ACTIVE' },
});
});
it('resets expired rate-limit windows', async () => {
const limits = ctx.app.get(RateLimitService);
await limits.consume('test-bucket', 1, 60);
await expect(limits.consume('test-bucket', 1, 60)).rejects.toThrow(
'Too many',
);
await ctx.db.rateLimit.update({
where: { key: hashToken('test-bucket') },
data: { expiresAt: new Date(0) },
});
await expect(limits.consume('test-bucket', 1, 60)).resolves.toBeUndefined();
});
it('rechecks current permissions inside administration transactions', async () => {
const actor = await ctx.app.get(SessionStore).authenticate(ctx.token);
const ownerRole = await ctx.db.role.findFirstOrThrow({
where: { isSystem: true },
});
const original = ownerRole.permissions;
await ctx.db.role.update({
where: { id: ownerRole.id },
data: { permissions: [] },
});
const work = jest.fn();
await expect(
ctx.app.get(AccessStore).mutate(actor, 'users.create', work),
).rejects.toThrow();
expect(work).not.toHaveBeenCalled();
await ctx.db.role.update({
where: { id: ownerRole.id },
data: { permissions: original },
});
});
it('preserves state when an audited transaction fails', async () => {
const actor = await ctx.app.get(SessionStore).authenticate(ctx.token);
await expect(
ctx.app.get(AccessStore).mutate(actor, 'roles.manage', async (tx) => {
await tx.role.create({
data: {
organizationId: actor.organizationId,
name: 'Rolled back',
permissions: [],
},
});
throw new Error('audit unavailable');
}),
).rejects.toThrow('audit unavailable');
expect(await ctx.db.role.count({ where: { name: 'Rolled back' } })).toBe(0);
});
it('uses the same public recovery response for an unknown account', async () => {
const response = await ctx
.api()
.post('/api/v1/auth/recovery/request')
.send({
organizationId: ctx.owner.organizationId,
email: 'unknown@example.com',
})
.expect(202);
expect(response.body.message).toContain('If the account is eligible');
});
});

215
test/identity-rbac.spec.ts Normal file
View File

@ -0,0 +1,215 @@
import {
identityApp,
type IdentityApp,
ownerPassword,
} from './helpers/identity-app';
import { BootstrapService } from '../src/identity/bootstrap.service';
describe('organization-scoped administration', () => {
let ctx: IdentityApp;
let userId: string;
let roleId: string;
let userToken: string;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
const auth = () => ({ type: 'bearer' as const });
it('bootstraps exactly one owner', async () => {
await expect(
ctx.app.get(BootstrapService).createOwner('Other', {
email: 'other@example.com',
name: 'Other',
password: ownerPassword,
}),
).rejects.toThrow('Owner already exists');
expect(await ctx.db.user.count({ where: { isOwner: true } })).toBe(1);
});
it('creates pending accounts with normalized emails and rejects duplicates', async () => {
const payload = {
name: 'Employee',
email: 'EMPLOYEE@example.com',
password: ownerPassword,
};
const response = await ctx
.api()
.post('/api/v1/users')
.auth(ctx.token, auth())
.send(payload)
.expect(201);
userId = response.body.id;
expect(response.body.status).toBe('PENDING');
expect(response.body.email).toBe('employee@example.com');
expect(response.text).not.toContain('passwordHash');
await ctx
.api()
.post('/api/v1/users')
.auth(ctx.token, auth())
.send(payload)
.expect(409);
expect((await ctx.login('employee@example.com')).status).toBe(401);
});
it('approves accounts but leaves them without implicit permissions', async () => {
await ctx
.api()
.patch(`/api/v1/users/${userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'ACTIVE' })
.expect(200);
const login = await ctx.login('employee@example.com');
expect(login.status).toBe(200);
userToken = login.body.accessToken;
await ctx.api().get('/api/v1/users').auth(userToken, auth()).expect(403);
});
it('creates and assigns roles and reflects permission changes on existing sessions', async () => {
const role = await ctx
.api()
.post('/api/v1/roles')
.auth(ctx.token, auth())
.send({ name: 'Reader', permissions: ['users.read'] })
.expect(201);
roleId = role.body.id;
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(ctx.token, auth())
.send({ roleIds: [roleId] })
.expect(200);
await ctx.api().get('/api/v1/users').auth(userToken, auth()).expect(200);
await ctx
.api()
.patch(`/api/v1/roles/${roleId}`)
.auth(ctx.token, auth())
.send({ name: 'Reader', permissions: [] })
.expect(200);
await ctx.api().get('/api/v1/users').auth(userToken, auth()).expect(403);
});
it('prevents delegated administrators from granting permissions they do not hold', async () => {
await ctx
.api()
.patch(`/api/v1/roles/${roleId}`)
.auth(ctx.token, auth())
.send({
name: 'Delegated',
permissions: ['roles.manage', 'users.roles.assign'],
})
.expect(200);
await ctx
.api()
.post('/api/v1/roles')
.auth(userToken, auth())
.send({ name: 'Escalation', permissions: ['audit.read'] })
.expect(403);
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(userToken, auth())
.send({ roleIds: [] })
.expect(403);
});
it('protects owner accounts and system roles', async () => {
const systemRole = await ctx.db.role.findFirstOrThrow({
where: { isSystem: true },
});
await ctx
.api()
.patch(`/api/v1/roles/${systemRole.id}`)
.auth(ctx.token, auth())
.send({ name: 'Changed', permissions: [] })
.expect(403);
await ctx
.api()
.patch(`/api/v1/users/${ctx.owner.userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'SUSPENDED' })
.expect(403);
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(ctx.token, auth())
.send({ roleIds: [systemRole.id] })
.expect(403);
});
it('rejects cross-organization targets and database-level cross-organization assignments', async () => {
const other = await ctx.db.organization.create({ data: { name: 'Other' } });
const otherRole = await ctx.db.role.create({
data: { organizationId: other.id, name: 'External', permissions: [] },
});
await ctx
.api()
.patch(`/api/v1/roles/${otherRole.id}`)
.auth(ctx.token, auth())
.send({ name: 'Hijacked', permissions: [] })
.expect(404);
await ctx
.api()
.patch(`/api/v1/users/${userId}/roles`)
.auth(ctx.token, auth())
.send({ roleIds: [otherRole.id] })
.expect(404);
await expect(
ctx.db.userRole.create({
data: {
userId,
roleId: otherRole.id,
organizationId: ctx.owner.organizationId,
},
}),
).rejects.toThrow();
const roles = await ctx
.api()
.get('/api/v1/roles')
.auth(ctx.token, auth())
.expect(200);
expect(
roles.body.some((role: { id: string }) => role.id === otherRole.id),
).toBe(false);
});
it('suspends accounts and permanently revokes their sessions', async () => {
await ctx
.api()
.patch(`/api/v1/users/${userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'SUSPENDED' })
.expect(200);
await ctx.api().get('/api/v1/auth/me').auth(userToken, auth()).expect(401);
expect((await ctx.login('employee@example.com')).status).toBe(401);
await ctx
.api()
.patch(`/api/v1/users/${userId}/status`)
.auth(ctx.token, auth())
.send({ status: 'ACTIVE' })
.expect(200);
await ctx.api().get('/api/v1/auth/me').auth(userToken, auth()).expect(401);
});
it('lists permissions and audit events with pagination validation', async () => {
await ctx
.api()
.get('/api/v1/roles/permissions')
.auth(ctx.token, auth())
.expect(200);
const events = await ctx
.api()
.get('/api/v1/audit-events?limit=2')
.auth(ctx.token, auth())
.expect(200);
expect(events.body).toHaveLength(2);
expect(
events.body.every(
(event: { organizationId: string }) =>
event.organizationId === ctx.owner.organizationId,
),
).toBe(true);
await ctx
.api()
.get('/api/v1/users?limit=1000')
.auth(ctx.token, auth())
.expect(400);
});
});

View File

@ -0,0 +1,99 @@
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { legacyOrganizationId } from './helpers/test-database';
import { RecoveryStore } from '../src/identity/recovery.store';
import { issueToken } from '../src/identity/tokens';
describe('migration integrity', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
it('preserves organization rows from the foundation migration', async () => {
expect(
await ctx.db.organization.findUnique({
where: { id: legacyOrganizationId },
}),
).toMatchObject({ name: 'Existing organization' });
});
it('enforces normalized emails in the database', async () => {
await expect(
ctx.db.user.create({
data: {
organizationId: ctx.owner.organizationId,
email: 'UPPER@example.com',
name: 'Bad',
passwordHash: 'hash',
},
}),
).rejects.toThrow();
});
it('prevents a second owner even when the API is bypassed', async () => {
await expect(
ctx.db.user.create({
data: {
organizationId: ctx.owner.organizationId,
email: 'second@example.com',
name: 'Second',
passwordHash: 'hash',
isOwner: true,
},
}),
).rejects.toThrow();
});
it('rejects audit modification and deletion', async () => {
const audit = await ctx.db.auditEvent.findFirstOrThrow();
await expect(
ctx.db.auditEvent.update({
where: { id: audit.id },
data: { action: 'tampered' },
}),
).rejects.toThrow();
await expect(
ctx.db.auditEvent.delete({ where: { id: audit.id } }),
).rejects.toThrow();
expect(
await ctx.db.auditEvent.findUnique({ where: { id: audit.id } }),
).not.toBeNull();
});
it('discards failed-delivery recovery tokens', async () => {
const token = issueToken();
const store = ctx.app.get(RecoveryStore);
await store.create(
ctx.owner.userId,
token.tokenHash,
new Date(Date.now() + 60000),
);
await store.discard(token.tokenHash);
expect(
await ctx.db.recoveryToken.findUnique({
where: { tokenHash: token.tokenHash },
}),
).toBeNull();
});
const nativeOnly = process.env.TEST_DATABASE_URL ? it : it.skip;
nativeOnly(
'allows exactly one winner for concurrent recovery token consumption on native PostgreSQL',
async () => {
const token = issueToken();
const store = ctx.app.get(RecoveryStore);
await store.create(
ctx.owner.userId,
token.tokenHash,
new Date(Date.now() + 60000),
);
const results = await Promise.allSettled([
store.reset(token.tokenHash, 'hash-one'),
store.reset(token.tokenHash, 'hash-two'),
]);
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1);
expect(
results.filter((result) => result.status === 'rejected'),
).toHaveLength(1);
},
);
});