Files
tessera-ctl/.planning/phases/02-authentication-multi-tenancy/02-01-PLAN.md
T
schalli e8e89686f5 docs(02): create phase 2 authentication & multi-tenancy plans
5 plans across 4 waves covering all 9 requirements (AUTH-01..06, TNNT-01..03)
and 18 locked decisions (D-01..D-18):
- Plan 01 (W1): Backend auth foundation with Prisma schema, RLS, JWT, admin seed
- Plan 02 (W2): Frontend auth flow, login page, user/tenant CRUD admin pages
- Plan 03 (W2): SUS package verification, password reset, force-change interceptor
- Plan 04 (W3): LDAP sync service, per-tenant config, field mapping, admin UI
- Plan 05 (W4): Visual verification of complete auth and multi-tenancy system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 13:10:13 +02:00

24 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-authentication-multi-tenancy 01 execute 1
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/*
apps/api/src/prisma/prisma.module.ts
apps/api/src/prisma/prisma.service.ts
apps/api/src/prisma/prisma-tenant.extension.ts
apps/api/src/auth/auth.module.ts
apps/api/src/auth/auth.controller.ts
apps/api/src/auth/auth.service.ts
apps/api/src/auth/strategies/local.strategy.ts
apps/api/src/auth/strategies/jwt.strategy.ts
apps/api/src/auth/guards/jwt-auth.guard.ts
apps/api/src/auth/guards/roles.guard.ts
apps/api/src/auth/decorators/public.decorator.ts
apps/api/src/auth/decorators/roles.decorator.ts
apps/api/src/auth/decorators/current-user.decorator.ts
apps/api/src/auth/dto/login.dto.ts
apps/api/src/user/user.module.ts
apps/api/src/user/user.service.ts
apps/api/src/user/admin-seed.service.ts
apps/api/src/tenant/tenant.module.ts
apps/api/src/tenant/tenant.service.ts
apps/api/src/tenant/tenant.middleware.ts
apps/api/src/app.module.ts
apps/api/src/main.ts
apps/api/package.json
docker-compose.yml
docker-compose.dev.yml
true
AUTH-01
AUTH-03
AUTH-04
AUTH-05
TNNT-01
TNNT-03
truths artifacts key_links
Initial admin account is created from Docker ENV on first startup (D-05, D-07, D-13)
POST /auth/login with valid credentials returns httpOnly JWT cookie with 30-day expiry (D-02)
POST /auth/logout clears the session cookie
All non-public API routes reject requests without valid JWT (returns 401)
RoleGuard enforces SUPER_ADMIN / ADMIN / USER access levels on protected routes (D-12)
Every tenant-scoped database query is filtered by RLS using app.current_tenant session variable (D-11)
Tenant ID is extracted from JWT claim and set per request via TenantMiddleware (D-08)
path provides contains
apps/api/prisma/schema.prisma User, Tenant, PasswordResetToken, LdapConfig, LdapFieldMapping models with Role enum model User
path provides exports
apps/api/src/auth/auth.service.ts Login validation, JWT issuance, cookie management
AuthService
path provides exports
apps/api/src/prisma/prisma-tenant.extension.ts Prisma Client Extension that sets app.current_tenant per query
forTenant
path provides exports
apps/api/src/tenant/tenant.middleware.ts Extracts tenantId from JWT, creates tenant-scoped Prisma client
TenantMiddleware
path provides exports
apps/api/src/user/admin-seed.service.ts Creates initial Super-Admin from ENV on bootstrap
AdminSeedService
from to via pattern
apps/api/src/auth/strategies/jwt.strategy.ts apps/api/src/auth/guards/jwt-auth.guard.ts Passport JWT strategy registered as global APP_GUARD APP_GUARD.*JwtAuthGuard
from to via pattern
apps/api/src/tenant/tenant.middleware.ts apps/api/src/prisma/prisma-tenant.extension.ts Middleware calls forTenant() to create scoped client forTenant
from to via pattern
apps/api/src/user/admin-seed.service.ts apps/api/src/prisma/prisma.service.ts onApplicationBootstrap lifecycle hook creates admin onApplicationBootstrap
Backend authentication foundation: Prisma schema with User/Tenant/Role models, JWT httpOnly cookie auth via Passport strategies, global AuthGuard + RoleGuard, RLS tenant isolation via Prisma Client Extension, TenantMiddleware for per-request tenant context, and initial Super-Admin seed from Docker ENV.

This plan delivers AUTH-01 (admin seed per D-05/D-07/D-13), AUTH-03 (login/logout), AUTH-04 (30-day session per D-02), AUTH-05 (RBAC per D-12), TNNT-01 (RLS per D-11), TNNT-03 (tenant per request per D-08).

Purpose: Establish the entire backend auth and multi-tenancy infrastructure that all subsequent plans build on. Without this, no frontend auth or user management is possible.

Output: Working API with login/logout endpoints, global JWT protection, role-based guards, tenant-scoped database queries, and auto-seeded Super-Admin account.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-authentication-multi-tenancy/02-CONTEXT.md @.planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md @.planning/phases/01-foundation-portal-shell/01-01-SUMMARY.md @apps/api/prisma/schema.prisma @apps/api/src/app.module.ts @apps/api/src/main.ts @apps/api/src/health/health.controller.ts @apps/api/package.json @docker-compose.yml

Artifacts this phase produces

Artifact Plan Purpose
Expanded Prisma schema (User, Tenant, Role, PasswordResetToken, LdapConfig, LdapFieldMapping) 02-01 Database foundation for all auth/tenant features
RLS migration SQL (policies, app role, current_tenant_id function) 02-01 Tenant data isolation at database level
PrismaModule + PrismaService (singleton) 02-01 Shared database access across all modules
AuthModule (Passport local + JWT strategies, guards, decorators) 02-01 Login/logout, JWT cookie auth, RBAC
TenantModule (middleware, service) 02-01 Per-request tenant context injection
UserModule (service, admin seed) 02-01 User operations and initial admin creation
Login page with split-screen layout 02-02 User-facing login UI per D-01
Next.js middleware for route protection 02-02 Frontend auth gate per D-04
User CRUD endpoints + frontend admin pages 02-02 User management per AUTH-02
Tenant CRUD endpoints + admin pages 02-02 Tenant management per TNNT-02
Auth-wired header and sidebar-footer 02-02 Show logged-in user info
MailModule + password reset flow 02-03 Self-service password reset per D-03
Force-password-change interceptor 02-03 Per D-06
LDAP sync service + config UI 02-04 LDAP import per AUTH-06, D-14..D-18
Task 1: Prisma schema expansion, RLS migration, and PrismaModule - apps/api/prisma/schema.prisma (current Tenant-only schema) - apps/api/src/app.module.ts (current module imports) - apps/api/package.json (current dependencies) - .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (Prisma schema section, RLS SQL section, Package Legitimacy Audit) apps/api/prisma/schema.prisma apps/api/prisma/migrations/[timestamp]_auth_multi_tenancy/migration.sql apps/api/src/prisma/prisma.module.ts apps/api/src/prisma/prisma.service.ts apps/api/src/prisma/prisma-tenant.extension.ts apps/api/package.json docker-compose.yml docker-compose.dev.yml Install backend auth dependencies per RESEARCH.md standard stack. In apps/api run: pnpm add @nestjs/jwt @nestjs/passport passport passport-jwt passport-local argon2 class-validator class-transformer pnpm add -D @types/passport-jwt @types/passport-local
NOTE: Do NOT install ldapts, @nestjs-modules/mailer, or nodemailer yet -- those are SUS-flagged packages that require human verification in Plan 02-03.

Expand apps/api/prisma/schema.prisma with the full Phase 2 data model per RESEARCH.md code example:
- Role enum with SUPER_ADMIN, ADMIN, USER values
- Expand existing Tenant model: add isActive Boolean default true, add users User[] relation, add ldapConfig LdapConfig? relation
- User model with fields: id (uuid), username (unique), email (unique), passwordHash (String optional for LDAP users per A6), displayName (optional), role (Role default USER), isActive (Boolean default true), mustChangePassword (Boolean default false per D-06), ldapDn (optional), tenantId (String), tenant relation, createdAt, updatedAt, lastLoginAt (optional), passwordResetTokens relation. Indexes on tenantId, username, email.
- PasswordResetToken model: id (uuid), token (unique), userId, user relation with onDelete Cascade, expiresAt, usedAt (optional), createdAt
- LdapConfig model: id (uuid), tenantId (unique), tenant relation, serverUrl, baseDn, bindDn, bindPassword, searchFilter (default "(objectClass=person)"), syncIntervalMin (Int default 60), isActive (Boolean default true), lastSyncAt (optional), createdAt, updatedAt, fieldMappings relation
- LdapFieldMapping model: id (uuid), ldapConfigId, ldapConfig relation with onDelete Cascade, ldapField, tesseraField, isDefault (Boolean default false), createdAt. Unique constraint on [ldapConfigId, ldapField].

Add TESSERA_ADMIN_USER, TESSERA_ADMIN_EMAIL, TESSERA_ADMIN_PASSWORD, TESSERA_FORCE_CHANGE, and JWT_SECRET environment variables to docker-compose.yml api service per D-05.
Set JWT_SECRET to a development-only value like "tessera-dev-jwt-secret-change-in-production".
Add TESSERA_ADMIN_USER=admin, TESSERA_ADMIN_EMAIL=admin@tessera.local, TESSERA_ADMIN_PASSWORD=admin123 as defaults.
Add TESSERA_FORCE_CHANGE=false as default.

Run prisma migrate dev --name auth_multi_tenancy to generate migration.

After migration is generated, create a SECOND manual SQL migration for RLS setup. Create a new migration directory manually (prisma/migrations/[timestamp]_rls_policies/migration.sql) with:
- CREATE OR REPLACE FUNCTION current_tenant_id() that returns current_setting('app.current_tenant', true)
- ALTER TABLE "User" ENABLE ROW LEVEL SECURITY and FORCE ROW LEVEL SECURITY
- CREATE POLICY tenant_isolation_policy ON "User" USING (tenant_id = current_tenant_id()::uuid) -- note: Prisma maps tenantId to "tenantId" column, verify the actual column name from the generated migration and use that exact name
- Same RLS for PasswordResetToken (via user join or direct tenant_id -- use the approach that matches schema)
- Same RLS for LdapConfig and LdapFieldMapping (via tenantId)

IMPORTANT for RLS SQL: Use parameterized set_config, never string interpolation per RESEARCH.md anti-pattern guidance. The current_tenant_id() function uses current_setting which is safe.

Create apps/api/src/prisma/prisma.module.ts as a Global NestJS module exporting PrismaService.
Create apps/api/src/prisma/prisma.service.ts extending PrismaClient, implementing OnModuleInit (call this.$connect in onModuleInit). Register as injectable singleton.
Create apps/api/src/prisma/prisma-tenant.extension.ts with the forTenant function per RESEARCH.md Pattern 2: accepts PrismaClient and tenantId string, returns extended client that wraps $allOperations in a transaction calling SET app.current_tenant via $executeRawUnsafe with set_config. Use parameterized query: SELECT set_config('app.current_tenant', $1, true) with the tenantId as parameter to avoid SQL injection.
cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma validate && npx prisma migrate status - schema.prisma contains all 6 models (Tenant, User, Role enum, PasswordResetToken, LdapConfig, LdapFieldMapping) - Migration files exist under prisma/migrations/ - RLS migration SQL contains ENABLE ROW LEVEL SECURITY for User table - PrismaModule is @Global() and exports PrismaService - prisma-tenant.extension.ts exports forTenant function - docker-compose.yml api service has TESSERA_ADMIN_USER, TESSERA_ADMIN_EMAIL, TESSERA_ADMIN_PASSWORD, JWT_SECRET env vars Prisma schema has all Phase 2 models, migrations are generated, RLS policies are in migration SQL, PrismaModule/PrismaService/forTenant extension exist, Docker env vars configured. Task 2: AuthModule with Passport strategies, guards, and admin seed - apps/api/src/prisma/prisma.module.ts (from Task 1) - apps/api/src/prisma/prisma.service.ts (from Task 1) - apps/api/src/prisma/prisma-tenant.extension.ts (from Task 1) - apps/api/prisma/schema.prisma (expanded schema from Task 1) - .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (Pattern 1: JWT auth, Pattern 3: Global guard, Pattern 5: Tenant middleware, admin seed example, security domain) apps/api/src/auth/auth.module.ts apps/api/src/auth/auth.controller.ts apps/api/src/auth/auth.service.ts apps/api/src/auth/strategies/local.strategy.ts apps/api/src/auth/strategies/jwt.strategy.ts apps/api/src/auth/guards/jwt-auth.guard.ts apps/api/src/auth/guards/roles.guard.ts apps/api/src/auth/decorators/public.decorator.ts apps/api/src/auth/decorators/roles.decorator.ts apps/api/src/auth/decorators/current-user.decorator.ts apps/api/src/auth/dto/login.dto.ts apps/api/src/user/user.module.ts apps/api/src/user/user.service.ts apps/api/src/user/admin-seed.service.ts apps/api/src/tenant/tenant.module.ts apps/api/src/tenant/tenant.service.ts apps/api/src/tenant/tenant.middleware.ts apps/api/src/app.module.ts apps/api/src/main.ts Create the AuthModule per RESEARCH.md architecture:
auth/dto/login.dto.ts: LoginDto class with username (string, @IsNotEmpty) and password (string, @IsNotEmpty) validated via class-validator decorators.

auth/decorators/public.decorator.ts: Export IS_PUBLIC_KEY constant and Public() decorator using SetMetadata per RESEARCH.md Pattern 3.
auth/decorators/roles.decorator.ts: Export ROLES_KEY constant and Roles(...roles: Role[]) decorator using SetMetadata. Import Role enum from @prisma/client.
auth/decorators/current-user.decorator.ts: Export CurrentUser parameter decorator using createParamDecorator that extracts user from request object.

auth/strategies/local.strategy.ts: PassportLocalStrategy extending PassportStrategy(Strategy) from passport-local. The validate method receives username and password, calls AuthService.validateUser, throws UnauthorizedException if null.

auth/strategies/jwt.strategy.ts: JwtStrategy extending PassportStrategy(Strategy) from passport-jwt. Configure to extract JWT from cookie named "session" (use a custom extractor function that reads req.cookies.session). The secretOrKey comes from ConfigService JWT_SECRET. The validate method receives the decoded payload and returns the user object (sub, username, role, tenantId).

auth/guards/jwt-auth.guard.ts: JwtAuthGuard extending AuthGuard('jwt'). Override canActivate to check IS_PUBLIC_KEY metadata via Reflector -- if @Public(), return true, otherwise delegate to super.canActivate. Per RESEARCH.md Pattern 3.
auth/guards/roles.guard.ts: RolesGuard implementing CanActivate. Use Reflector to read ROLES_KEY. If no roles set, allow. Otherwise check if request.user.role is in the required roles array. Per D-12, roles are SUPER_ADMIN, ADMIN, USER.

auth/auth.service.ts: AuthService injectable. Dependencies: PrismaService, JwtService, ConfigService. Methods:
- validateUser(username, password): Find user by username (use unscoped prisma -- admin seed runs before tenant context exists), check isActive, verify password with argon2.verify, return user or null. Per Pitfall 6, always check isActive.
- login(user, response): Build JWT payload with sub=user.id, username=user.username, role=user.role, tenantId=user.tenantId. Sign with JwtService using expiresIn '30d' per D-02. Set httpOnly cookie named "session" on response: httpOnly true, secure only in production, sameSite 'lax', maxAge 30*24*60*60*1000, path '/'. Return user info object (id, username, role, displayName, tenantId, mustChangePassword).
- logout(response): Clear the "session" cookie with same path and domain settings.

auth/auth.controller.ts: AuthController with prefix 'auth'. 
- POST /auth/login: @Public() decorated, @UseGuards(AuthGuard('local')). Receives LoginDto body and @Req() request, @Res({ passthrough: true }) response. Calls authService.login(request.user, response). Returns the user info. Uses @HttpCode(200).
- POST /auth/logout: Calls authService.logout(response). Returns { message: 'Logged out' }.
- GET /auth/me: Returns request.user from JWT (for session check).

auth/auth.module.ts: Imports JwtModule.registerAsync with useFactory reading JWT_SECRET from ConfigService, signOptions expiresIn '30d'. Imports PassportModule. Imports UserModule. Providers: AuthService, LocalStrategy, JwtStrategy. Controllers: AuthController. Exports: AuthService.

user/user.service.ts: UserService injectable. Dependency: PrismaService. Methods:
- findByUsername(username): prisma.user.findUnique where username. Uses UNSCOPED prisma (not tenant-scoped) because login must work across tenants.
- findById(id): prisma.user.findUnique where id.
- create(data): prisma.user.create with argon2.hash for password.
- update(id, data): prisma.user.update. If data includes password, hash it.
- deactivate(id): prisma.user.update set isActive false.
- delete(id): prisma.user.delete.

user/admin-seed.service.ts: AdminSeedService implementing OnApplicationBootstrap per RESEARCH.md code example. In onApplicationBootstrap: read TESSERA_ADMIN_USER, TESSERA_ADMIN_EMAIL, TESSERA_ADMIN_PASSWORD, TESSERA_FORCE_CHANGE from ConfigService. If any of user/email/password missing, skip. Check if user exists by username. If exists, skip. Upsert default tenant (slug 'default', name 'Default'). Create user with role SUPER_ADMIN, password hashed with argon2, mustChangePassword from TESSERA_FORCE_CHANGE (default false), tenantId from default tenant. Per D-05, D-07, D-13.

user/user.module.ts: Providers: UserService, AdminSeedService. Exports: UserService.

tenant/tenant.service.ts: TenantService injectable. Dependency: PrismaService. Methods:
- findAll(): prisma.tenant.findMany.
- findById(id): prisma.tenant.findUnique.
- create(data): prisma.tenant.create.
- update(id, data): prisma.tenant.update.

tenant/tenant.middleware.ts: TenantMiddleware implementing NestMiddleware per RESEARCH.md Pattern 5. Dependency: PrismaService. In use(req, res, next): extract user from req.user (set by JWT auth). If no user, call next (public routes). Determine tenantId: if user.role is SUPER_ADMIN and x-tenant-id header exists, use header value; otherwise use user.tenantId. If no tenantId and not SUPER_ADMIN, throw ForbiddenException. Attach tenant-scoped prisma client to req (req.tenantPrisma = forTenant(prisma, tenantId) when tenantId exists, unscoped prisma for Super-Admin without header). Attach req.tenantId. Call next(). Per D-08, D-10.

tenant/tenant.module.ts: Providers: TenantService. Exports: TenantService.

Update app.module.ts: Import PrismaModule, AuthModule, UserModule, TenantModule. Register JwtAuthGuard as global APP_GUARD provider. Apply TenantMiddleware to all routes via configure method (implement NestModule, apply TenantMiddleware forRoutes('*')). Note: TenantMiddleware runs AFTER the AuthGuard, so req.user is available.

Update main.ts: Enable ValidationPipe globally with whitelist true and transform true. Update CORS to specify origin from ConfigService (default 'http://localhost:3000') and credentials true per Pitfall 4. Add cookie-parser middleware (install cookie-parser: pnpm add cookie-parser && pnpm add -D @types/cookie-parser). Call app.use(cookieParser()) before listen.

Mark health controller's check method with @Public() decorator so it remains accessible without auth.
cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/api - AuthModule registers JwtAuthGuard as global APP_GUARD - POST /auth/login endpoint exists with @Public() decorator - POST /auth/logout endpoint clears session cookie - GET /auth/me returns user from JWT - AdminSeedService creates Super-Admin from ENV on bootstrap per D-05/D-07/D-13 - TenantMiddleware extracts tenantId from JWT and supports Super-Admin tenant switching via x-tenant-id header per D-08/D-10 - RolesGuard checks SUPER_ADMIN/ADMIN/USER roles per D-12 - UserService.findByUsername uses unscoped Prisma (not tenant-scoped) for cross-tenant login - ValidationPipe with whitelist and transform enabled globally - CORS configured with credentials true per Pitfall 4 - Health endpoint has @Public() decorator - Type-check passes cleanly Full backend auth stack operational: login returns JWT cookie, global guard protects all routes except @Public(), admin auto-seeded from ENV, tenant context injected per request via RLS.

<threat_model>

Trust Boundaries

Boundary Description
Browser -> Next.js Untrusted user input (login form, admin forms)
Next.js -> NestJS API Optimistic frontend auth check; API must validate independently
NestJS API -> PostgreSQL RLS policies enforce tenant isolation at DB level
JWT cookie Session token crosses browser/server boundary on every request

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-02-01 Spoofing auth/login endpoint mitigate argon2 password verification; never reveal whether username or password is wrong (generic "Invalid credentials")
T-02-02 Tampering JWT cookie mitigate httpOnly + secure (prod) + sameSite=lax; signed with HS256 via @nestjs/jwt
T-02-03 Information Disclosure Cross-tenant data mitigate PostgreSQL RLS policies on all tenant-scoped tables; forTenant extension sets session variable per query
T-02-04 Elevation of Privilege RolesGuard mitigate Role checked from JWT claim; guard validates against required roles per route
T-02-05 Tampering SQL injection via RLS set_config mitigate Use parameterized $executeRawUnsafe with $1 placeholder, never string interpolation
T-02-06 Denial of Service login endpoint brute force accept Rate limiting deferred -- low risk in internal deployment; add @nestjs/throttler in future phase if needed
T-02-07 Spoofing Deactivated user JWT still valid mitigate AuthGuard checks user.isActive in DB on every request per Pitfall 6; short cache TTL acceptable
T-02-SC Tampering npm installs mitigate Package Legitimacy Audit in RESEARCH.md; blocking human checkpoint for SUS packages (ldapts, @nestjs-modules/mailer, nodemailer) in Plan 02-03
</threat_model>
After both tasks complete: 1. docker compose up -d and verify API starts without errors 2. Check logs for "Admin seeded" or equivalent bootstrap message 3. curl -X POST http://localhost:3001/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"admin123"}' -c cookies.txt returns 200 with user info and Set-Cookie header 4. curl http://localhost:3001/auth/me -b cookies.txt returns admin user data 5. curl http://localhost:3001/health returns 200 (public route) 6. curl http://localhost:3001/auth/me (no cookie) returns 401 7. pnpm turbo type-check passes for all packages

<success_criteria>

  • API boots and auto-seeds Super-Admin from Docker ENV (AUTH-01 per D-05/D-07/D-13)
  • Login endpoint validates credentials and returns 30-day httpOnly JWT cookie (AUTH-03, AUTH-04 per D-02)
  • All non-@Public() routes require valid JWT (AUTH-05)
  • RolesGuard enforces SUPER_ADMIN/ADMIN/USER levels (AUTH-05 per D-12)
  • Tenant-scoped queries use RLS via Prisma Client Extension (TNNT-01 per D-11)
  • TenantMiddleware sets tenant context from JWT per request (TNNT-03 per D-08)
  • Type-check passes across all workspace packages </success_criteria>
Create `.planning/phases/02-authentication-multi-tenancy/02-01-SUMMARY.md` when done