feat/identity-access #1
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 { HealthService } from './health.service';
|
||||
|
||||
@Public()
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
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