From 4b05627f3d53a511c93d2a32f7a17455b26b6c1a Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 18 Jun 2026 13:28:04 +0200 Subject: [PATCH] feat(02-01): UserModule, TenantModule, admin seed, and app.module wiring - Create UserService with findByUsername (unscoped), create, update, deactivate, delete - Create AdminSeedService that seeds Super-Admin from Docker ENV on bootstrap (D-05/D-07/D-13) - Create TenantService with findAll, findById, create, update - Create TenantMiddleware extracting tenantId from JWT with Super-Admin tenant switching (D-08/D-10) - Wire PrismaModule, AuthModule, UserModule, TenantModule into AppModule - Register JwtAuthGuard and RolesGuard as global APP_GUARD providers - Apply TenantMiddleware to all routes via NestModule.configure - Add @Public() decorator to HealthController for unauthenticated access --- apps/api/src/app.module.ts | 33 ++++++++- apps/api/src/health/health.controller.ts | 2 + apps/api/src/tenant/tenant.middleware.ts | 54 ++++++++++++++ apps/api/src/tenant/tenant.module.ts | 8 +++ apps/api/src/tenant/tenant.service.ts | 23 ++++++ apps/api/src/user/admin-seed.service.ts | 68 ++++++++++++++++++ apps/api/src/user/user.module.ts | 9 +++ apps/api/src/user/user.service.ts | 90 ++++++++++++++++++++++++ 8 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/tenant/tenant.middleware.ts create mode 100644 apps/api/src/tenant/tenant.module.ts create mode 100644 apps/api/src/tenant/tenant.service.ts create mode 100644 apps/api/src/user/admin-seed.service.ts create mode 100644 apps/api/src/user/user.module.ts create mode 100644 apps/api/src/user/user.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5438160..36786d8 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,11 +1,40 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { AuthModule } from './auth/auth.module'; +import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; +import { RolesGuard } from './auth/guards/roles.guard'; import { HealthModule } from './health/health.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { TenantMiddleware } from './tenant/tenant.middleware'; +import { TenantModule } from './tenant/tenant.module'; +import { UserModule } from './user/user.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + AuthModule, + UserModule, + TenantModule, HealthModule, ], + providers: [ + // Global JWT guard: all routes require auth unless @Public() + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + // Global roles guard: checks @Roles() decorator + { + provide: APP_GUARD, + useClass: RolesGuard, + }, + ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + // TenantMiddleware runs AFTER AuthGuard (guards run first in NestJS pipeline) + consumer.apply(TenantMiddleware).forRoutes('*'); + } +} diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index 305f0e8..4ee051e 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -1,8 +1,10 @@ import { Controller, Get } from '@nestjs/common'; import type { HealthResponse } from '@tessera/shared'; +import { Public } from '../auth/decorators/public.decorator'; @Controller('health') export class HealthController { + @Public() @Get() check(): HealthResponse { return { diff --git a/apps/api/src/tenant/tenant.middleware.ts b/apps/api/src/tenant/tenant.middleware.ts new file mode 100644 index 0000000..c481d32 --- /dev/null +++ b/apps/api/src/tenant/tenant.middleware.ts @@ -0,0 +1,54 @@ +import { + ForbiddenException, + Injectable, + NestMiddleware, +} from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; +import { forTenant } from '../prisma/prisma-tenant.extension'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Extracts tenantId from the authenticated user's JWT claim and creates + * a tenant-scoped Prisma client for the request. + * + * Super-Admin can switch tenant context via x-tenant-id header (D-10). + * Per D-08: Tenant context from JWT, no URL-based routing. + */ +@Injectable() +export class TenantMiddleware implements NestMiddleware { + constructor(private prisma: PrismaService) {} + + use(req: Request, res: Response, next: NextFunction) { + const user = (req as any).user; + + // No user means public route (e.g., login, health) - skip tenant context + if (!user) { + return next(); + } + + // Determine tenant ID + let tenantId: string | undefined = user.tenantId; + + // Super-Admin can switch tenant via header + if (user.role === 'SUPER_ADMIN' && req.headers['x-tenant-id']) { + tenantId = req.headers['x-tenant-id'] as string; + } + + // Non-Super-Admin users MUST have a tenant + if (!tenantId && user.role !== 'SUPER_ADMIN') { + throw new ForbiddenException('No tenant context'); + } + + // Attach tenant-scoped Prisma client + if (tenantId) { + (req as any).tenantPrisma = forTenant(this.prisma, tenantId); + (req as any).tenantId = tenantId; + } else { + // Super-Admin without tenant header gets unscoped access + (req as any).tenantPrisma = this.prisma; + (req as any).tenantId = null; + } + + next(); + } +} diff --git a/apps/api/src/tenant/tenant.module.ts b/apps/api/src/tenant/tenant.module.ts new file mode 100644 index 0000000..aaf2737 --- /dev/null +++ b/apps/api/src/tenant/tenant.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TenantService } from './tenant.service'; + +@Module({ + providers: [TenantService], + exports: [TenantService], +}) +export class TenantModule {} diff --git a/apps/api/src/tenant/tenant.service.ts b/apps/api/src/tenant/tenant.service.ts new file mode 100644 index 0000000..a19819f --- /dev/null +++ b/apps/api/src/tenant/tenant.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class TenantService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.tenant.findMany(); + } + + async findById(id: string) { + return this.prisma.tenant.findUnique({ where: { id } }); + } + + async create(data: { name: string; slug: string }) { + return this.prisma.tenant.create({ data }); + } + + async update(id: string, data: { name?: string; slug?: string; isActive?: boolean }) { + return this.prisma.tenant.update({ where: { id }, data }); + } +} diff --git a/apps/api/src/user/admin-seed.service.ts b/apps/api/src/user/admin-seed.service.ts new file mode 100644 index 0000000..4281919 --- /dev/null +++ b/apps/api/src/user/admin-seed.service.ts @@ -0,0 +1,68 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as argon2 from 'argon2'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Creates the initial Super-Admin account from Docker ENV variables on first boot. + * Per D-05, D-07, D-13. + */ +@Injectable() +export class AdminSeedService implements OnApplicationBootstrap { + private readonly logger = new Logger(AdminSeedService.name); + + constructor( + private prisma: PrismaService, + private configService: ConfigService, + ) {} + + async onApplicationBootstrap() { + const username = this.configService.get('TESSERA_ADMIN_USER'); + const email = this.configService.get('TESSERA_ADMIN_EMAIL'); + const password = this.configService.get('TESSERA_ADMIN_PASSWORD'); + const forceChange = + this.configService.get('TESSERA_FORCE_CHANGE') === 'true'; + + if (!username || !email || !password) { + this.logger.log( + 'Admin seed skipped: TESSERA_ADMIN_USER, TESSERA_ADMIN_EMAIL, or TESSERA_ADMIN_PASSWORD not set', + ); + return; + } + + // Check if admin already exists + const exists = await this.prisma.user.findUnique({ + where: { username }, + }); + + if (exists) { + this.logger.log(`Admin user "${username}" already exists, skipping seed`); + return; + } + + // Upsert default tenant + const tenant = await this.prisma.tenant.upsert({ + where: { slug: 'default' }, + update: {}, + create: { name: 'Default', slug: 'default' }, + }); + + // Create Super-Admin user + const passwordHash = await argon2.hash(password); + await this.prisma.user.create({ + data: { + username, + email, + passwordHash, + role: 'SUPER_ADMIN', + tenantId: tenant.id, + mustChangePassword: forceChange, + isActive: true, + }, + }); + + this.logger.log( + `Admin user "${username}" seeded as SUPER_ADMIN in tenant "${tenant.slug}"`, + ); + } +} diff --git a/apps/api/src/user/user.module.ts b/apps/api/src/user/user.module.ts new file mode 100644 index 0000000..41e88ae --- /dev/null +++ b/apps/api/src/user/user.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AdminSeedService } from './admin-seed.service'; +import { UserService } from './user.service'; + +@Module({ + providers: [UserService, AdminSeedService], + exports: [UserService], +}) +export class UserModule {} diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts new file mode 100644 index 0000000..8d5700e --- /dev/null +++ b/apps/api/src/user/user.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import * as argon2 from 'argon2'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class UserService { + constructor(private prisma: PrismaService) {} + + /** + * Find user by username. Uses UNSCOPED Prisma (not tenant-scoped) + * because login must work across all tenants. + */ + async findByUsername(username: string) { + return this.prisma.user.findUnique({ where: { username } }); + } + + /** + * Find user by ID. + */ + async findById(id: string) { + return this.prisma.user.findUnique({ where: { id } }); + } + + /** + * Create a new user with hashed password. + */ + async create(data: { + username: string; + email: string; + password?: string; + displayName?: string; + role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER'; + tenantId: string; + mustChangePassword?: boolean; + ldapDn?: string; + }) { + const { password, ...rest } = data; + return this.prisma.user.create({ + data: { + ...rest, + passwordHash: password ? await argon2.hash(password) : null, + }, + }); + } + + /** + * Update user. If password is provided, hash it. + */ + async update( + id: string, + data: { + username?: string; + email?: string; + password?: string; + displayName?: string; + role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER'; + isActive?: boolean; + mustChangePassword?: boolean; + }, + ) { + const { password, ...rest } = data; + const updateData: any = { ...rest }; + + if (password) { + updateData.passwordHash = await argon2.hash(password); + } + + return this.prisma.user.update({ + where: { id }, + data: updateData, + }); + } + + /** + * Deactivate a user (soft delete). + */ + async deactivate(id: string) { + return this.prisma.user.update({ + where: { id }, + data: { isActive: false }, + }); + } + + /** + * Hard delete a user. + */ + async delete(id: string) { + return this.prisma.user.delete({ where: { id } }); + } +}