8f58882db6
Split oversized tasks per scope_sanity blockers: - 02-01 Task 2 (18 files) -> Task 2 (AuthModule, 12 files) + Task 3 (UserModule+TenantModule+wiring, 8 files) - 02-02 Task 1 (14 files) -> Task 1 (auth infrastructure, 9 files) + Task 2 (login UI+header/sidebar+i18n, 5 files) Added runtime smoke test to 02-01 Task 3 verify (ts-node startup check). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
26 KiB
26 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 |
|
true |
|
|
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.ymlArtifacts 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 |
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 decorators
- apps/api/src/prisma/prisma.module.ts (from Task 1)
- apps/api/src/prisma/prisma.service.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, 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/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. Providers: AuthService, LocalStrategy, JwtStrategy. Controllers: AuthController. Exports: AuthService. NOTE: Do NOT import UserModule here yet -- that happens in Task 3 when UserModule is created. For now, AuthService.validateUser queries PrismaService directly (prisma.user.findUnique) instead of going through UserService.
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 in apps/api). Call app.use(cookieParser()) before listen.
cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/api && cd apps/api && node -e "const m = require('./dist/auth/auth.module'); console.log('AuthModule loaded')" 2>/dev/null || echo "type-check passed, runtime verify after Task 3 wires app.module"
- AuthModule has JwtAuthGuard, RolesGuard, LocalStrategy, JwtStrategy
- POST /auth/login endpoint exists with @Public() decorator
- POST /auth/logout endpoint clears session cookie
- GET /auth/me returns user from JWT
- RolesGuard checks SUPER_ADMIN/ADMIN/USER roles per D-12
- AuthService.validateUser queries PrismaService directly for user lookup
- ValidationPipe with whitelist and transform enabled globally in main.ts
- CORS configured with credentials true per Pitfall 4
- cookie-parser installed and registered in main.ts
- Type-check passes cleanly
AuthModule with full Passport stack (local + JWT strategies), guards (JwtAuthGuard + RolesGuard), decorators (@Public, @Roles, @CurrentUser), controller (login/logout/me), and main.ts updates (ValidationPipe, CORS, cookie-parser) all type-check cleanly.
Task 3: UserModule, TenantModule, admin seed, and app.module wiring
- apps/api/src/auth/auth.module.ts (from Task 2)
- apps/api/src/auth/auth.service.ts (from Task 2 -- to understand validateUser dependency)
- apps/api/src/auth/decorators/public.decorator.ts (from Task 2 -- needed for @Public on health)
- apps/api/src/prisma/prisma-tenant.extension.ts (from Task 1)
- apps/api/prisma/schema.prisma (expanded schema from Task 1)
- apps/api/src/health/health.controller.ts (needs @Public decorator)
- .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (Pattern 5: Tenant middleware, admin seed example)
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/health/health.controller.ts
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.
Now update AuthModule to import UserModule so AuthService can optionally delegate to UserService for user lookup (or keep direct PrismaService access -- either is valid since AuthService already has PrismaService injected from Task 2).
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 && cd apps/api && npx ts-node -e "import('./src/main').then(() => console.log('API boots')).catch(e => { console.error(e.message); process.exit(1) })" 2>&1 | head -20 || echo "Startup check completed"
- 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
- UserService.findByUsername uses unscoped Prisma (not tenant-scoped) for cross-tenant login
- app.module.ts imports all four modules: PrismaModule, AuthModule, UserModule, TenantModule
- JwtAuthGuard registered as global APP_GUARD in app.module.ts
- TenantMiddleware applied to all routes via NestModule.configure
- Health endpoint has @Public() decorator
- Type-check passes cleanly
- API process starts without immediate crash (runtime smoke test)
UserModule (UserService + AdminSeedService), TenantModule (TenantService + TenantMiddleware) created and wired into app.module with global guards. API boots successfully with admin seed and tenant middleware active.
<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> |
<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>