feat(rbac): enforce scoped administration and audited permission changes
This commit is contained in:
parent
30776b9181
commit
c67d686cbf
|
|
@ -1,6 +1,7 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { EnvironmentModule } from './config/environment.module';
|
import { EnvironmentModule } from './config/environment.module';
|
||||||
|
import { IdentityModule } from './identity/identity.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
|
||||||
@Module({ imports: [EnvironmentModule, HealthModule] })
|
@Module({ imports: [EnvironmentModule, HealthModule, IdentityModule] })
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import { Public } from '../identity/access.decorator';
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { HealthService } from './health.service';
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(private readonly health: HealthService) {}
|
constructor(private readonly health: HealthService) {}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>>;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue