diff --git a/src/app.module.ts b/src/app.module.ts index cb6e098..5125700 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/cli/bootstrap-owner.ts b/src/cli/bootstrap-owner.ts new file mode 100644 index 0000000..c41de59 --- /dev/null +++ b/src/cli/bootstrap-owner.ts @@ -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; +}); diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 36d46bf..181598b 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -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) {} diff --git a/src/identity/access.store.ts b/src/identity/access.store.ts new file mode 100644 index 0000000..a221e0b --- /dev/null +++ b/src/identity/access.store.ts @@ -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( + actor: Principal, + permission: Permission, + work: (tx: Prisma.TransactionClient, current: Principal) => Promise, + ): Promise { + 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; + } + } +} diff --git a/src/identity/audit.controller.ts b/src/identity/audit.controller.ts new file mode 100644 index 0000000..d2fb3e5 --- /dev/null +++ b/src/identity/audit.controller.ts @@ -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, + }); + } +} diff --git a/src/identity/identity.module.ts b/src/identity/identity.module.ts new file mode 100644 index 0000000..8ee3afe --- /dev/null +++ b/src/identity/identity.module.ts @@ -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 {} diff --git a/src/identity/role.store.ts b/src/identity/role.store.ts new file mode 100644 index 0000000..5191123 --- /dev/null +++ b/src/identity/role.store.ts @@ -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, + ); + }, + ); + } +} diff --git a/src/identity/roles.controller.ts b/src/identity/roles.controller.ts new file mode 100644 index 0000000..e8efe02 --- /dev/null +++ b/src/identity/roles.controller.ts @@ -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); + } +} diff --git a/src/identity/user.store.ts b/src/identity/user.store.ts new file mode 100644 index 0000000..9f1de3c --- /dev/null +++ b/src/identity/user.store.ts @@ -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; + }); + } +} diff --git a/src/identity/users.controller.ts b/src/identity/users.controller.ts new file mode 100644 index 0000000..5ad89c4 --- /dev/null +++ b/src/identity/users.controller.ts @@ -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 }; + } +} diff --git a/test/admin-policy.spec.ts b/test/admin-policy.spec.ts new file mode 100644 index 0000000..5f49657 --- /dev/null +++ b/test/admin-policy.spec.ts @@ -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', + ); + }); +}); diff --git a/test/helpers/identity-app.ts b/test/helpers/identity-app.ts new file mode 100644 index 0000000..2383cc4 --- /dev/null +++ b/test/helpers/identity-app.ts @@ -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>; diff --git a/test/helpers/test-database.ts b/test/helpers/test-database.ts new file mode 100644 index 0000000..b6f5d28 --- /dev/null +++ b/test/helpers/test-database.ts @@ -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; + let close: () => Promise; + 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; + } +} diff --git a/test/identity-auth.spec.ts b/test/identity-auth.spec.ts new file mode 100644 index 0000000..1522cf8 --- /dev/null +++ b/test/identity-auth.spec.ts @@ -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); + }); +}); diff --git a/test/identity-failures.spec.ts b/test/identity-failures.spec.ts new file mode 100644 index 0000000..0f2b260 --- /dev/null +++ b/test/identity-failures.spec.ts @@ -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'); + }); +}); diff --git a/test/identity-rbac.spec.ts b/test/identity-rbac.spec.ts new file mode 100644 index 0000000..ffc1b3c --- /dev/null +++ b/test/identity-rbac.spec.ts @@ -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); + }); +}); diff --git a/test/migration-integrity.spec.ts b/test/migration-integrity.spec.ts new file mode 100644 index 0000000..02a3bcc --- /dev/null +++ b/test/migration-integrity.spec.ts @@ -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); + }, + ); +});