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>
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
---
|
||||
phase: 02-authentication-multi-tenancy
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- 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
|
||||
autonomous: true
|
||||
requirements:
|
||||
- AUTH-01
|
||||
- AUTH-03
|
||||
- AUTH-04
|
||||
- AUTH-05
|
||||
- TNNT-01
|
||||
- TNNT-03
|
||||
must_haves:
|
||||
truths:
|
||||
- "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)"
|
||||
artifacts:
|
||||
- path: "apps/api/prisma/schema.prisma"
|
||||
provides: "User, Tenant, PasswordResetToken, LdapConfig, LdapFieldMapping models with Role enum"
|
||||
contains: "model User"
|
||||
- path: "apps/api/src/auth/auth.service.ts"
|
||||
provides: "Login validation, JWT issuance, cookie management"
|
||||
exports: ["AuthService"]
|
||||
- path: "apps/api/src/prisma/prisma-tenant.extension.ts"
|
||||
provides: "Prisma Client Extension that sets app.current_tenant per query"
|
||||
exports: ["forTenant"]
|
||||
- path: "apps/api/src/tenant/tenant.middleware.ts"
|
||||
provides: "Extracts tenantId from JWT, creates tenant-scoped Prisma client"
|
||||
exports: ["TenantMiddleware"]
|
||||
- path: "apps/api/src/user/admin-seed.service.ts"
|
||||
provides: "Creates initial Super-Admin from ENV on bootstrap"
|
||||
exports: ["AdminSeedService"]
|
||||
key_links:
|
||||
- from: "apps/api/src/auth/strategies/jwt.strategy.ts"
|
||||
to: "apps/api/src/auth/guards/jwt-auth.guard.ts"
|
||||
via: "Passport JWT strategy registered as global APP_GUARD"
|
||||
pattern: "APP_GUARD.*JwtAuthGuard"
|
||||
- from: "apps/api/src/tenant/tenant.middleware.ts"
|
||||
to: "apps/api/src/prisma/prisma-tenant.extension.ts"
|
||||
via: "Middleware calls forTenant() to create scoped client"
|
||||
pattern: "forTenant"
|
||||
- from: "apps/api/src/user/admin-seed.service.ts"
|
||||
to: "apps/api/src/prisma/prisma.service.ts"
|
||||
via: "onApplicationBootstrap lifecycle hook creates admin"
|
||||
pattern: "onApplicationBootstrap"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
</context>
|
||||
|
||||
## 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 |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Prisma schema expansion, RLS migration, and PrismaModule</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma validate && npx prisma migrate status</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: AuthModule with Passport strategies, guards, and admin seed</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/api</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-authentication-multi-tenancy/02-01-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,374 @@
|
||||
---
|
||||
phase: 02-authentication-multi-tenancy
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 02-01
|
||||
files_modified:
|
||||
- apps/web/src/app/(auth)/layout.tsx
|
||||
- apps/web/src/app/(auth)/login/page.tsx
|
||||
- apps/web/src/app/(portal)/layout.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/web/src/app/(portal)/admin/users/page.tsx
|
||||
- apps/web/src/app/(portal)/admin/tenants/page.tsx
|
||||
- apps/web/src/app/layout.tsx
|
||||
- apps/web/src/middleware.ts
|
||||
- apps/web/src/lib/session.ts
|
||||
- apps/web/src/lib/auth-actions.ts
|
||||
- apps/web/src/lib/stores/auth-store.ts
|
||||
- apps/web/src/components/layout/header.tsx
|
||||
- apps/web/src/components/layout/sidebar-footer.tsx
|
||||
- apps/web/src/components/layout/sidebar.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/package.json
|
||||
- apps/api/src/user/user.controller.ts
|
||||
- apps/api/src/user/user.module.ts
|
||||
- apps/api/src/user/dto/create-user.dto.ts
|
||||
- apps/api/src/user/dto/update-user.dto.ts
|
||||
- apps/api/src/tenant/tenant.controller.ts
|
||||
- apps/api/src/tenant/tenant.module.ts
|
||||
- apps/api/src/tenant/dto/create-tenant.dto.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- AUTH-02
|
||||
- AUTH-03
|
||||
- AUTH-04
|
||||
- TNNT-02
|
||||
must_haves:
|
||||
truths:
|
||||
- "User sees a split-screen login page with branding left and form right (D-01)"
|
||||
- "Login page has no sidebar or header -- standalone layout (D-04)"
|
||||
- "User can log in with valid credentials and is redirected to dashboard"
|
||||
- "Logged-in user sees their name and role in header and sidebar footer"
|
||||
- "Unauthenticated browser navigation to any portal page redirects to /login"
|
||||
- "Admin can create, edit, and delete users with role assignment via admin/users page (D-12)"
|
||||
- "Super-Admin can create and manage tenants via admin/tenants page (D-10)"
|
||||
- "Remember-me checkbox on login controls session duration (D-02)"
|
||||
artifacts:
|
||||
- path: "apps/web/src/app/(auth)/login/page.tsx"
|
||||
provides: "Split-screen login page with branding and form"
|
||||
min_lines: 50
|
||||
- path: "apps/web/src/app/(auth)/layout.tsx"
|
||||
provides: "Standalone auth layout without sidebar/header"
|
||||
min_lines: 10
|
||||
- path: "apps/web/src/middleware.ts"
|
||||
provides: "JWT validation for route protection"
|
||||
min_lines: 20
|
||||
- path: "apps/web/src/app/(portal)/admin/users/page.tsx"
|
||||
provides: "User management admin page"
|
||||
min_lines: 50
|
||||
- path: "apps/web/src/app/(portal)/admin/tenants/page.tsx"
|
||||
provides: "Tenant management Super-Admin page"
|
||||
min_lines: 40
|
||||
- path: "apps/api/src/user/user.controller.ts"
|
||||
provides: "User CRUD REST endpoints"
|
||||
exports: ["UserController"]
|
||||
- path: "apps/api/src/tenant/tenant.controller.ts"
|
||||
provides: "Tenant CRUD REST endpoints"
|
||||
exports: ["TenantController"]
|
||||
key_links:
|
||||
- from: "apps/web/src/middleware.ts"
|
||||
to: "apps/web/src/lib/session.ts"
|
||||
via: "JWT verification using jose"
|
||||
pattern: "jwtVerify"
|
||||
- from: "apps/web/src/app/(auth)/login/page.tsx"
|
||||
to: "apps/api/src/auth/auth.controller.ts"
|
||||
via: "POST /auth/login fetch with credentials include"
|
||||
pattern: "fetch.*auth/login"
|
||||
- from: "apps/web/src/components/layout/header.tsx"
|
||||
to: "apps/web/src/lib/stores/auth-store.ts"
|
||||
via: "Zustand store for client-side user state"
|
||||
pattern: "useAuthStore"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Frontend authentication flow and user/tenant management: Split-screen login page (D-01, D-04), Next.js middleware for route protection, auth-wired header and sidebar, User CRUD API + admin page (AUTH-02), Tenant CRUD API + admin page (TNNT-02), and Zustand auth store for client-side user state.
|
||||
|
||||
This plan delivers the vertical slice where a user can log in, see their identity in the portal, and admins can manage users and tenants.
|
||||
|
||||
Purpose: Connect the backend auth (Plan 02-01) to the frontend portal shell (Phase 1), making the portal a real authenticated application.
|
||||
|
||||
Output: Working login flow, authenticated portal with user info displayed, admin pages for user and tenant management.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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-02-SUMMARY.md
|
||||
@.planning/phases/02-authentication-multi-tenancy/02-01-SUMMARY.md
|
||||
@apps/web/src/app/layout.tsx
|
||||
@apps/web/src/components/layout/header.tsx
|
||||
@apps/web/src/components/layout/sidebar-footer.tsx
|
||||
@apps/web/src/components/layout/app-shell.tsx
|
||||
@apps/web/src/messages/de.json
|
||||
@apps/web/src/messages/en.json
|
||||
@apps/web/package.json
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
See Plan 02-01 for the full artifacts table.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Login page, auth layout, Next.js middleware, auth store, and auth-wired portal components</name>
|
||||
<read_first>
|
||||
- apps/web/src/app/layout.tsx (root layout to understand provider structure)
|
||||
- apps/web/src/components/layout/header.tsx (user avatar placeholder to wire)
|
||||
- apps/web/src/components/layout/sidebar-footer.tsx (user info placeholder to wire)
|
||||
- apps/web/src/components/layout/app-shell.tsx (AppShell wrapper for portal routes)
|
||||
- apps/web/src/lib/stores/sidebar-store.ts (existing Zustand store pattern to follow)
|
||||
- apps/web/src/messages/de.json (existing i18n keys to extend)
|
||||
- apps/web/src/messages/en.json (existing i18n keys to extend)
|
||||
- apps/web/src/app/globals.css (design tokens for styling login page)
|
||||
- .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (Pattern 4: Next.js middleware, session.ts pattern, DAL pattern)
|
||||
</read_first>
|
||||
<files>
|
||||
apps/web/src/app/(auth)/layout.tsx
|
||||
apps/web/src/app/(auth)/login/page.tsx
|
||||
apps/web/src/app/(portal)/layout.tsx
|
||||
apps/web/src/app/(portal)/page.tsx
|
||||
apps/web/src/middleware.ts
|
||||
apps/web/src/lib/session.ts
|
||||
apps/web/src/lib/auth-actions.ts
|
||||
apps/web/src/lib/stores/auth-store.ts
|
||||
apps/web/src/components/layout/header.tsx
|
||||
apps/web/src/components/layout/sidebar-footer.tsx
|
||||
apps/web/src/messages/de.json
|
||||
apps/web/src/messages/en.json
|
||||
apps/web/package.json
|
||||
apps/web/src/app/layout.tsx
|
||||
</files>
|
||||
<action>
|
||||
Install frontend auth dependencies: cd apps/web and pnpm add jose zod
|
||||
|
||||
Create route group structure for auth separation per D-04:
|
||||
- Move existing page.tsx content into apps/web/src/app/(portal)/page.tsx (dashboard page)
|
||||
- Create apps/web/src/app/(portal)/layout.tsx that wraps children with AppShell component (import from @/components/layout/app-shell). This ensures all portal routes get header + sidebar.
|
||||
- Create apps/web/src/app/(auth)/layout.tsx as a STANDALONE layout: no sidebar, no header. Simple centered layout with min-h-screen bg-background. Per D-04.
|
||||
- Update apps/web/src/app/layout.tsx to remove AppShell wrapping if it was there (root layout should only have ThemeProvider and NextIntlClientProvider -- the route group layouts handle structural differences).
|
||||
|
||||
Create apps/web/src/lib/session.ts:
|
||||
- Export a function verifySession(token: string) that uses jose.jwtVerify with the JWT_SECRET env var (read from process.env.JWT_SECRET or SESSION_SECRET). Returns the decoded payload or null on failure. Use HS256 algorithm.
|
||||
- Export a function getSessionFromCookies(cookies) that reads the "session" cookie value.
|
||||
|
||||
Create apps/web/src/middleware.ts per RESEARCH.md Pattern 4:
|
||||
- Define publicRoutes array: ['/login', '/reset-password']
|
||||
- On every request, check if path starts with a public route prefix -- if yes, NextResponse.next()
|
||||
- Also skip _next/static, _next/image, favicon.ico, and API routes
|
||||
- Read "session" cookie from request
|
||||
- If no cookie, redirect to /login
|
||||
- Verify JWT with jose.jwtVerify using the secret from process.env.JWT_SECRET
|
||||
- If verification fails, redirect to /login (clear stale cookie)
|
||||
- If user payload has mustChangePassword true, redirect to /change-password (except if already on that page)
|
||||
- Export matcher config per RESEARCH.md: ['/((?!api|_next/static|_next/image|.*\\.png$).*)']
|
||||
|
||||
Create apps/web/src/lib/auth-actions.ts with server-side functions:
|
||||
- login(formData): Extract username and password. POST to API_URL/auth/login with credentials include. On success, the API sets the cookie directly (since both are behind Traefik in production). In development, handle the Set-Cookie from the API response and forward it. Return success/error.
|
||||
- logout(): POST to API_URL/auth/logout. Clear cookies. Redirect to /login.
|
||||
- fetchCurrentUser(): GET API_URL/auth/me with credentials include. Return user or null.
|
||||
NOTE: API_URL should be read from NEXT_PUBLIC_API_URL env var (already set in docker-compose.yml as http://localhost:3001).
|
||||
|
||||
Create apps/web/src/lib/stores/auth-store.ts following the existing sidebar-store.ts pattern:
|
||||
- Zustand store with user state (id, username, displayName, role, tenantId) or null
|
||||
- Actions: setUser, clearUser
|
||||
- No persist middleware (user state comes from API, not localStorage)
|
||||
|
||||
Create apps/web/src/app/(auth)/login/page.tsx per D-01 split-screen design:
|
||||
- 'use client' component
|
||||
- Full-screen split layout: LEFT side (hidden on mobile, flex-1 on md+) shows Tessera branding with primary yellow (#ffed00 / var(--primary)) background, large "Tessera" text, and tagline. RIGHT side (full width mobile, flex-1 desktop) shows login form on white/dark background.
|
||||
- Form fields: username input, password input, "Angemeldet bleiben" (Remember me) checkbox per D-02
|
||||
- Submit button with primary color
|
||||
- Error message display area
|
||||
- Form submission calls the login action, which POSTs to /auth/login on the API. On success, redirect to '/' (dashboard). On error, show error message.
|
||||
- All strings through useTranslations('auth') hook per UI-03 pattern
|
||||
- Include i18n keys for: auth.login, auth.username, auth.password, auth.rememberMe, auth.submit, auth.error.invalidCredentials, auth.branding.tagline
|
||||
|
||||
Update apps/web/src/components/layout/header.tsx:
|
||||
- Replace the user avatar placeholder div with a real user dropdown component
|
||||
- Import useAuthStore to get current user
|
||||
- Show user initial (first letter of displayName or username) in the avatar circle
|
||||
- On click, show a dropdown with: user display name, role badge, "Abmelden" (Logout) button
|
||||
- Logout button calls the logout action
|
||||
- Keep the existing hamburger, logo, breadcrumb, and ThemeToggle structure intact
|
||||
|
||||
Update apps/web/src/components/layout/sidebar-footer.tsx:
|
||||
- Replace user info placeholder with real user data from useAuthStore
|
||||
- Show user display name (or username) and role
|
||||
- When sidebar is collapsed, show only avatar initial
|
||||
|
||||
Add i18n keys to both de.json and en.json for all new strings:
|
||||
- auth namespace: login, username, password, rememberMe, submit, error.invalidCredentials, branding.tagline
|
||||
- header namespace: logout, role.SUPER_ADMIN, role.ADMIN, role.USER
|
||||
- admin namespace: users.title, users.create, users.edit, users.delete, users.name, users.email, users.role, users.status, users.actions, tenants.title, tenants.create, tenants.name, tenants.slug, tenants.status, tenants.actions
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/web</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- (auth) route group has standalone layout without AppShell per D-04
|
||||
- (portal) route group wraps children with AppShell (header + sidebar)
|
||||
- Login page is split-screen with branding left and form right per D-01
|
||||
- Login form has username, password, and remember-me checkbox per D-02
|
||||
- Next.js middleware validates JWT cookie and redirects unauthenticated to /login
|
||||
- middleware.ts allows /login and /reset-password without auth
|
||||
- Header shows real user initial and dropdown with logout per auth store
|
||||
- Sidebar footer shows real user name and role per auth store
|
||||
- All new UI strings use i18n t() function (no hardcoded text)
|
||||
- Both de.json and en.json have all new auth/admin translation keys
|
||||
- Type-check passes for @tessera/web
|
||||
</acceptance_criteria>
|
||||
<done>Login page renders split-screen layout; unauthenticated users are redirected to /login; authenticated users see their name in header and sidebar; all strings are internationalized.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: User CRUD API + admin page and Tenant CRUD API + admin page</name>
|
||||
<read_first>
|
||||
- apps/api/src/user/user.service.ts (from Plan 02-01, user operations)
|
||||
- apps/api/src/user/user.module.ts (from Plan 02-01)
|
||||
- apps/api/src/tenant/tenant.service.ts (from Plan 02-01, tenant operations)
|
||||
- apps/api/src/tenant/tenant.module.ts (from Plan 02-01)
|
||||
- apps/api/src/auth/decorators/roles.decorator.ts (Roles decorator)
|
||||
- apps/api/src/auth/decorators/current-user.decorator.ts (CurrentUser decorator)
|
||||
- apps/api/src/auth/guards/roles.guard.ts (RolesGuard)
|
||||
- .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (architecture diagram, project structure)
|
||||
</read_first>
|
||||
<files>
|
||||
apps/api/src/user/user.controller.ts
|
||||
apps/api/src/user/user.module.ts
|
||||
apps/api/src/user/dto/create-user.dto.ts
|
||||
apps/api/src/user/dto/update-user.dto.ts
|
||||
apps/api/src/tenant/tenant.controller.ts
|
||||
apps/api/src/tenant/tenant.module.ts
|
||||
apps/api/src/tenant/dto/create-tenant.dto.ts
|
||||
apps/web/src/app/(portal)/admin/users/page.tsx
|
||||
apps/web/src/app/(portal)/admin/tenants/page.tsx
|
||||
apps/web/src/components/layout/sidebar.tsx
|
||||
</files>
|
||||
<action>
|
||||
Create user DTOs:
|
||||
- create-user.dto.ts: CreateUserDto with fields: username (@IsString, @IsNotEmpty), email (@IsEmail), password (@IsString, @MinLength(8)), displayName (@IsString, @IsOptional), role (@IsEnum(Role), @IsOptional, default USER). Use class-validator decorators.
|
||||
- update-user.dto.ts: UpdateUserDto with all fields optional (PartialType of CreateUserDto using @nestjs/mapped-types). Add isActive (@IsBoolean, @IsOptional).
|
||||
|
||||
Create apps/api/src/user/user.controller.ts:
|
||||
- Prefix 'users'
|
||||
- GET /users: @Roles(Role.ADMIN, Role.SUPER_ADMIN) @UseGuards(RolesGuard). Returns list of users. For ADMIN, filter by tenant (use req.tenantPrisma if available, otherwise filter by user's tenantId). For SUPER_ADMIN, return all users.
|
||||
- GET /users/:id: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Return single user.
|
||||
- POST /users: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Body: CreateUserDto. Create user via UserService. ADMIN can only create users in own tenant; SUPER_ADMIN can specify tenantId.
|
||||
- PATCH /users/:id: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Body: UpdateUserDto. Update user. ADMIN can only update users in own tenant and cannot set role to SUPER_ADMIN.
|
||||
- DELETE /users/:id: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Delete user. ADMIN can only delete users in own tenant and cannot delete self.
|
||||
Update user.module.ts to add UserController to controllers array.
|
||||
|
||||
Create tenant DTOs:
|
||||
- create-tenant.dto.ts: CreateTenantDto with fields: name (@IsString, @IsNotEmpty), slug (@IsString, @IsNotEmpty, @Matches /^[a-z0-9-]+$/).
|
||||
|
||||
Create apps/api/src/tenant/tenant.controller.ts:
|
||||
- Prefix 'tenants'
|
||||
- GET /tenants: @Roles(Role.SUPER_ADMIN) @UseGuards(RolesGuard). Returns all tenants. Per D-10, only Super-Admin manages tenants.
|
||||
- GET /tenants/:id: @Roles(Role.SUPER_ADMIN). Return single tenant with user count.
|
||||
- POST /tenants: @Roles(Role.SUPER_ADMIN). Body: CreateTenantDto. Create tenant.
|
||||
- PATCH /tenants/:id: @Roles(Role.SUPER_ADMIN). Update tenant name, isActive.
|
||||
- DELETE /tenants/:id: @Roles(Role.SUPER_ADMIN). Delete tenant (only if no active users).
|
||||
Update tenant.module.ts to add TenantController to controllers array.
|
||||
|
||||
Create apps/web/src/app/(portal)/admin/users/page.tsx:
|
||||
- 'use client' component
|
||||
- Fetch users from GET /users API endpoint on mount
|
||||
- Display table with columns: Username, Email, Display Name, Role, Status (Active/Inactive), Actions
|
||||
- Create user button opens a form dialog/modal with CreateUserDto fields
|
||||
- Edit button opens pre-filled form dialog
|
||||
- Delete button with confirmation dialog
|
||||
- Role displayed as colored badge (SUPER_ADMIN = red, ADMIN = blue, USER = gray)
|
||||
- All strings through useTranslations('admin') per i18n pattern
|
||||
- Only visible to ADMIN and SUPER_ADMIN roles (check auth store)
|
||||
|
||||
Create apps/web/src/app/(portal)/admin/tenants/page.tsx:
|
||||
- 'use client' component
|
||||
- Fetch tenants from GET /tenants API endpoint
|
||||
- Display table with columns: Name, Slug, Status, User Count, Actions
|
||||
- Create tenant button opens form dialog
|
||||
- Edit and deactivate actions
|
||||
- Only visible to SUPER_ADMIN role (check auth store, show "Access denied" otherwise)
|
||||
- All strings through useTranslations('admin')
|
||||
|
||||
Update apps/web/src/components/layout/sidebar.tsx:
|
||||
- Add admin section to sidebar navigation: "Verwaltung" (Administration) category
|
||||
- Under Verwaltung: "Benutzer" (Users) link to /admin/users, "Mandanten" (Tenants) link to /admin/tenants
|
||||
- Verwaltung section only visible when user role is ADMIN or SUPER_ADMIN
|
||||
- Tenants link only visible when role is SUPER_ADMIN
|
||||
- Use auth store to check current user role
|
||||
- Add i18n keys: sidebar.admin, sidebar.users, sidebar.tenants
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- User CRUD endpoints exist at /users with proper role guards (ADMIN + SUPER_ADMIN)
|
||||
- ADMIN can only manage users in own tenant; SUPER_ADMIN can manage all
|
||||
- Tenant CRUD endpoints exist at /tenants with SUPER_ADMIN-only access per D-10
|
||||
- Admin users page shows user table with create/edit/delete actions
|
||||
- Tenants page shows tenant table with create/edit actions (SUPER_ADMIN only)
|
||||
- Sidebar shows "Verwaltung" section for ADMIN/SUPER_ADMIN roles
|
||||
- All new UI strings internationalized in de.json and en.json
|
||||
- Type-check passes for both API and web packages
|
||||
</acceptance_criteria>
|
||||
<done>Admin can create/edit/delete users with role assignment (AUTH-02); Super-Admin can create/manage tenants (TNNT-02); admin navigation visible in sidebar for authorized roles.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser -> Login form | Untrusted credentials input |
|
||||
| Next.js middleware -> JWT | Optimistic session check; API validates independently |
|
||||
| Admin UI -> API | Role-based access must be enforced server-side, not just UI-hidden |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-08 | Elevation of Privilege | User CRUD endpoints | mitigate | RolesGuard enforces ADMIN/SUPER_ADMIN; ADMIN cannot escalate to SUPER_ADMIN or modify other tenants |
|
||||
| T-02-09 | Tampering | Tenant CRUD | mitigate | SUPER_ADMIN-only via RolesGuard; tenant deletion blocked if active users exist |
|
||||
| T-02-10 | Information Disclosure | User list | mitigate | ADMIN sees only own-tenant users; SUPER_ADMIN sees all; enforced at controller level |
|
||||
| T-02-11 | Spoofing | Login form CSRF | mitigate | SameSite=lax cookie prevents cross-origin POST; form submission is same-origin |
|
||||
| T-02-SC | Tampering | npm installs (jose, zod) | accept | Both packages are VERIFIED in legitimacy audit with 87M+ and 201M+ weekly downloads |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After both tasks complete:
|
||||
1. Navigate to http://localhost:3000 -- should redirect to /login
|
||||
2. Login with admin/admin123 -- should redirect to dashboard, header shows "admin" user
|
||||
3. Navigate to /admin/users -- should show user table with the admin account
|
||||
4. Create a new user via the admin page -- should appear in the table
|
||||
5. Navigate to /admin/tenants -- should show "Default" tenant
|
||||
6. Click logout -- should redirect to /login
|
||||
7. Try accessing /admin/users directly without login -- should redirect to /login
|
||||
8. pnpm turbo type-check passes for all packages
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- User can log in via split-screen login page and see their identity in portal (AUTH-03, D-01, D-04)
|
||||
- Session persists across browser refresh via JWT cookie (AUTH-04, D-02)
|
||||
- Admin can CRUD users with role assignment (AUTH-02, D-12)
|
||||
- Super-Admin can CRUD tenants (TNNT-02, D-10)
|
||||
- Unauthenticated access redirects to /login
|
||||
- All new strings internationalized (DE/EN)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-authentication-multi-tenancy/02-02-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,316 @@
|
||||
---
|
||||
phase: 02-authentication-multi-tenancy
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 02-01
|
||||
files_modified:
|
||||
- apps/api/src/mail/mail.module.ts
|
||||
- apps/api/src/mail/mail.service.ts
|
||||
- apps/api/src/auth/auth.service.ts
|
||||
- apps/api/src/auth/auth.controller.ts
|
||||
- apps/api/src/auth/dto/reset-password.dto.ts
|
||||
- apps/api/src/auth/dto/change-password.dto.ts
|
||||
- apps/api/src/auth/interceptors/force-password-change.interceptor.ts
|
||||
- apps/api/src/app.module.ts
|
||||
- apps/api/package.json
|
||||
- apps/web/src/app/(auth)/reset-password/page.tsx
|
||||
- apps/web/src/app/(auth)/reset-password/[token]/page.tsx
|
||||
- apps/web/src/app/(portal)/change-password/page.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- docker-compose.yml
|
||||
- docker-compose.dev.yml
|
||||
autonomous: false
|
||||
requirements:
|
||||
- AUTH-03
|
||||
- AUTH-04
|
||||
user_setup:
|
||||
- service: smtp
|
||||
why: "Password reset emails require SMTP configuration"
|
||||
env_vars:
|
||||
- name: TESSERA_SMTP_HOST
|
||||
source: "Your SMTP server hostname"
|
||||
- name: TESSERA_SMTP_PORT
|
||||
source: "SMTP port (587 for STARTTLS, 465 for SSL)"
|
||||
- name: TESSERA_SMTP_USER
|
||||
source: "SMTP authentication username"
|
||||
- name: TESSERA_SMTP_PASSWORD
|
||||
source: "SMTP authentication password"
|
||||
- name: TESSERA_SMTP_FROM
|
||||
source: "Sender email address"
|
||||
dashboard_config:
|
||||
- task: "For development, MailHog is added to docker-compose.dev.yml automatically (no config needed)"
|
||||
location: "http://localhost:8025 for MailHog web UI"
|
||||
must_haves:
|
||||
truths:
|
||||
- "SUS-flagged packages (ldapts, @nestjs-modules/mailer, nodemailer) are verified by human before installation"
|
||||
- "User can request password reset via email with a time-limited token (D-03)"
|
||||
- "Admin can manually reset a user's password (D-03)"
|
||||
- "User with mustChangePassword=true is forced to change password before accessing any other page (D-06)"
|
||||
- "Password reset token expires after 1 hour and can only be used once"
|
||||
- "MailHog is available in development docker-compose for email testing"
|
||||
artifacts:
|
||||
- path: "apps/api/src/mail/mail.module.ts"
|
||||
provides: "NestJS mailer module with SMTP transport from ENV"
|
||||
exports: ["MailModule"]
|
||||
- path: "apps/api/src/mail/mail.service.ts"
|
||||
provides: "Email sending for password reset"
|
||||
exports: ["MailService"]
|
||||
- path: "apps/api/src/auth/interceptors/force-password-change.interceptor.ts"
|
||||
provides: "Global interceptor checking mustChangePassword"
|
||||
exports: ["ForcePasswordChangeInterceptor"]
|
||||
- path: "apps/web/src/app/(auth)/reset-password/page.tsx"
|
||||
provides: "Password reset request form"
|
||||
min_lines: 30
|
||||
- path: "apps/web/src/app/(auth)/reset-password/[token]/page.tsx"
|
||||
provides: "Password reset form with token validation"
|
||||
min_lines: 40
|
||||
key_links:
|
||||
- from: "apps/api/src/auth/auth.service.ts"
|
||||
to: "apps/api/src/mail/mail.service.ts"
|
||||
via: "Send password reset email with token link"
|
||||
pattern: "mailService.*sendPasswordReset"
|
||||
- from: "apps/api/src/auth/interceptors/force-password-change.interceptor.ts"
|
||||
to: "apps/api/src/auth/auth.controller.ts"
|
||||
via: "Returns 403 FORCE_PASSWORD_CHANGE on all routes except change-password"
|
||||
pattern: "FORCE_PASSWORD_CHANGE"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Package legitimacy verification for SUS-flagged packages, then password reset flow (self-service via email and admin manual reset per D-03), force-password-change interceptor (D-06), and MailModule with SMTP configuration.
|
||||
|
||||
This plan addresses security-critical password management: D-03 (password reset via email + admin reset), D-06 (force change on first login), and the package legitimacy gate for SUS packages identified in RESEARCH.md.
|
||||
|
||||
Purpose: Complete the authentication lifecycle -- users who forget passwords can self-recover, admins can force password changes, and the first login experience respects D-06.
|
||||
|
||||
Output: Working password reset email flow, force-change interceptor, MailHog in dev compose, verified SUS packages installed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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/02-authentication-multi-tenancy/02-01-SUMMARY.md
|
||||
@apps/api/src/auth/auth.service.ts
|
||||
@apps/api/src/auth/auth.controller.ts
|
||||
@apps/api/src/app.module.ts
|
||||
@apps/api/package.json
|
||||
@docker-compose.yml
|
||||
@docker-compose.dev.yml
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
See Plan 02-01 for the full artifacts table.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 1: Verify SUS-flagged npm packages before installation</name>
|
||||
<what-built>
|
||||
RESEARCH.md Package Legitimacy Audit flagged three packages as SUS (too-new publish dates on well-established packages):
|
||||
- ldapts (6+ yrs, 400K/wk downloads) -- flagged for recent publish
|
||||
- @nestjs-modules/mailer (5+ yrs, 350K/wk downloads) -- flagged for recent publish
|
||||
- nodemailer (14+ yrs, 17M/wk downloads) -- flagged for recent publish
|
||||
|
||||
All three are well-established packages with millions of cumulative downloads. The SUS flag is due to routine version updates, not suspicious origin. However, per the supply chain security protocol, human verification is required before installation.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Please verify each package is legitimate by checking their npm pages:
|
||||
|
||||
1. **ldapts**: Visit https://www.npmjs.com/package/ldapts
|
||||
- Verify publisher is the expected maintainer (ldapts org)
|
||||
- Verify recent publish is a routine version update (not a hijack)
|
||||
- Check for any security advisories
|
||||
|
||||
2. **@nestjs-modules/mailer**: Visit https://www.npmjs.com/package/@nestjs-modules/mailer
|
||||
- Verify publisher is nest-modules org
|
||||
- Verify recent publish is routine
|
||||
- Check for any security advisories
|
||||
|
||||
3. **nodemailer**: Visit https://www.npmjs.com/package/nodemailer
|
||||
- Verify publisher is andris (Andris Reinman, original author)
|
||||
- Verify recent publish is routine
|
||||
- Check for any security advisories
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to proceed with installation, or describe any concerns found</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: MailModule, password reset flow, force-change interceptor, and frontend pages</name>
|
||||
<read_first>
|
||||
- apps/api/src/auth/auth.service.ts (existing auth service from Plan 02-01)
|
||||
- apps/api/src/auth/auth.controller.ts (existing auth controller)
|
||||
- apps/api/src/app.module.ts (current module configuration)
|
||||
- apps/api/prisma/schema.prisma (PasswordResetToken model)
|
||||
- apps/web/src/app/(auth)/layout.tsx (auth layout from Plan 02-02)
|
||||
- .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (SMTP ENV vars, security domain, Pitfall 5)
|
||||
</read_first>
|
||||
<files>
|
||||
apps/api/src/mail/mail.module.ts
|
||||
apps/api/src/mail/mail.service.ts
|
||||
apps/api/src/auth/auth.service.ts
|
||||
apps/api/src/auth/auth.controller.ts
|
||||
apps/api/src/auth/dto/reset-password.dto.ts
|
||||
apps/api/src/auth/dto/change-password.dto.ts
|
||||
apps/api/src/auth/interceptors/force-password-change.interceptor.ts
|
||||
apps/api/src/app.module.ts
|
||||
apps/api/package.json
|
||||
apps/web/src/app/(auth)/reset-password/page.tsx
|
||||
apps/web/src/app/(auth)/reset-password/[token]/page.tsx
|
||||
apps/web/src/app/(portal)/change-password/page.tsx
|
||||
apps/web/src/messages/de.json
|
||||
apps/web/src/messages/en.json
|
||||
docker-compose.yml
|
||||
docker-compose.dev.yml
|
||||
</files>
|
||||
<action>
|
||||
First, install the SUS-flagged packages (approved by human in Task 1):
|
||||
cd apps/api && pnpm add @nestjs-modules/mailer nodemailer ldapts
|
||||
cd apps/api && pnpm add -D @types/nodemailer
|
||||
|
||||
Add MailHog service to docker-compose.dev.yml for development email testing:
|
||||
mailhog service using mailhog/mailhog image, ports 1025:1025 (SMTP) and 8025:8025 (web UI), on backend-net.
|
||||
Add TESSERA_SMTP_HOST, TESSERA_SMTP_PORT, TESSERA_SMTP_USER, TESSERA_SMTP_PASSWORD, TESSERA_SMTP_FROM, TESSERA_APP_URL env vars to docker-compose.yml api service with defaults pointing to MailHog: host=mailhog, port=1025, user empty, password empty, from="Tessera <tessera@tessera.local>", app_url=http://localhost:3000.
|
||||
|
||||
Create apps/api/src/mail/mail.module.ts:
|
||||
- Import MailerModule.forRootAsync from @nestjs-modules/mailer
|
||||
- Configure with useFactory reading TESSERA_SMTP_HOST, TESSERA_SMTP_PORT, TESSERA_SMTP_SECURE, TESSERA_SMTP_USER, TESSERA_SMTP_PASSWORD from ConfigService
|
||||
- Set defaults.from from TESSERA_SMTP_FROM env var
|
||||
- Export the module globally
|
||||
|
||||
Create apps/api/src/mail/mail.service.ts:
|
||||
- Injectable service wrapping MailerService from @nestjs-modules/mailer
|
||||
- Method sendPasswordResetEmail(email, token, locale): Compose email with subject (i18n-aware: "Passwort zuruecksetzen" / "Reset your password"), body containing the reset link: TESSERA_APP_URL/reset-password/TOKEN. Use plain text email (no HTML template needed for MVP). Include expiry notice (1 hour).
|
||||
- Method sendWelcomeEmail(email, username, locale): Optional welcome email for newly created users.
|
||||
|
||||
Create apps/api/src/auth/dto/reset-password.dto.ts:
|
||||
- RequestResetDto: email (@IsEmail, @IsNotEmpty)
|
||||
- ResetPasswordDto: token (@IsString, @IsNotEmpty), newPassword (@IsString, @MinLength(8))
|
||||
|
||||
Create apps/api/src/auth/dto/change-password.dto.ts:
|
||||
- ChangePasswordDto: currentPassword (@IsString), newPassword (@IsString, @MinLength(8))
|
||||
|
||||
Extend apps/api/src/auth/auth.service.ts with new methods:
|
||||
- requestPasswordReset(email): Find user by email. If not found, return success anyway (prevent email enumeration). Generate a random UUID token, create PasswordResetToken record with 1-hour expiry. Send email via MailService. Per D-03 self-service reset.
|
||||
- resetPassword(token, newPassword): Find PasswordResetToken by token. Validate not expired and not used. Hash new password with argon2, update user. Mark token as used (set usedAt). Set mustChangePassword to false.
|
||||
- changePassword(userId, currentPassword, newPassword): Verify current password with argon2. Hash new password. Update user. Set mustChangePassword to false.
|
||||
- adminResetPassword(userId, newPassword): Per D-03 admin reset. Hash new password, update user, optionally set mustChangePassword to true.
|
||||
|
||||
Extend apps/api/src/auth/auth.controller.ts with new endpoints:
|
||||
- POST /auth/request-reset: @Public(). Body: RequestResetDto. Calls requestPasswordReset. Always returns 200 (no email enumeration).
|
||||
- POST /auth/reset-password: @Public(). Body: ResetPasswordDto. Calls resetPassword. Returns success or error.
|
||||
- POST /auth/change-password: Body: ChangePasswordDto + @CurrentUser. Calls changePassword.
|
||||
- POST /auth/admin-reset-password/:userId: @Roles(Role.ADMIN, Role.SUPER_ADMIN) @UseGuards(RolesGuard). Calls adminResetPassword.
|
||||
|
||||
Create apps/api/src/auth/interceptors/force-password-change.interceptor.ts per Pitfall 5:
|
||||
- Global NestJS interceptor implementing NestInterceptor
|
||||
- In intercept(): Check if request.user exists and user.mustChangePassword is true
|
||||
- If true AND the current route is NOT /auth/change-password and NOT /auth/logout, throw ForbiddenException with body { statusCode: 403, message: 'FORCE_PASSWORD_CHANGE', error: 'Must change password before continuing' }
|
||||
- Register as global APP_INTERCEPTOR in app.module.ts
|
||||
|
||||
Update app.module.ts: Import MailModule. Register ForcePasswordChangeInterceptor as global APP_INTERCEPTOR.
|
||||
|
||||
Create apps/web/src/app/(auth)/reset-password/page.tsx:
|
||||
- 'use client' form page in the (auth) route group (no sidebar/header per D-04)
|
||||
- Simple form with email input and submit button
|
||||
- On submit, POST to /auth/request-reset
|
||||
- Show success message: "Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet" (prevents email enumeration)
|
||||
- Link back to /login
|
||||
- All strings via useTranslations('auth')
|
||||
|
||||
Create apps/web/src/app/(auth)/reset-password/[token]/page.tsx:
|
||||
- 'use client' form page
|
||||
- Form with new password and confirm password fields
|
||||
- On submit, POST to /auth/reset-password with token from URL params and new password
|
||||
- Show success or error message
|
||||
- On success, redirect to /login after 3 seconds
|
||||
- All strings via useTranslations('auth')
|
||||
|
||||
Create apps/web/src/app/(portal)/change-password/page.tsx:
|
||||
- 'use client' form page inside (portal) route group (has sidebar/header)
|
||||
- Form with current password, new password, confirm password
|
||||
- On submit, POST to /auth/change-password
|
||||
- On success, update auth store (clear mustChangePassword) and redirect to dashboard
|
||||
- All strings via useTranslations('auth')
|
||||
|
||||
Add i18n keys to de.json and en.json:
|
||||
- auth.resetPassword.title, auth.resetPassword.email, auth.resetPassword.submit, auth.resetPassword.success, auth.resetPassword.newPassword, auth.resetPassword.confirmPassword, auth.resetPassword.tokenExpired, auth.resetPassword.tokenInvalid, auth.resetPassword.success
|
||||
- auth.changePassword.title, auth.changePassword.currentPassword, auth.changePassword.newPassword, auth.changePassword.confirmPassword, auth.changePassword.submit, auth.changePassword.success, auth.changePassword.forceChangeNotice
|
||||
- auth.forgotPassword (link text on login page)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ldapts, @nestjs-modules/mailer, and nodemailer installed in apps/api/package.json
|
||||
- MailModule configures SMTP from ENV variables
|
||||
- POST /auth/request-reset sends password reset email (testable via MailHog)
|
||||
- POST /auth/reset-password validates token and resets password
|
||||
- POST /auth/change-password validates current password and changes to new
|
||||
- POST /auth/admin-reset-password/:userId allows ADMIN/SUPER_ADMIN to reset user passwords per D-03
|
||||
- ForcePasswordChangeInterceptor returns 403 FORCE_PASSWORD_CHANGE on all routes except change-password and logout per D-06
|
||||
- MailHog service added to docker-compose.dev.yml on port 8025
|
||||
- Reset password page exists at /reset-password with email form
|
||||
- Token reset page exists at /reset-password/[token] with new password form
|
||||
- Change password page exists at /change-password with current + new password form
|
||||
- All strings internationalized in de.json and en.json
|
||||
- Type-check passes for all packages
|
||||
</acceptance_criteria>
|
||||
<done>Password reset via email works end-to-end (D-03), admin can manually reset passwords (D-03), force-password-change interceptor enforces D-06, MailHog available for dev email testing.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Email link -> reset form | Token in URL must be validated server-side |
|
||||
| Public reset endpoint | Must not reveal whether email exists (enumeration prevention) |
|
||||
| npm registry -> project | SUS packages verified by human before install |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-12 | Information Disclosure | /auth/request-reset | mitigate | Always return 200 regardless of email existence; prevents enumeration |
|
||||
| T-02-13 | Repudiation | Password reset token | mitigate | Single-use token (mark usedAt after use); 1-hour expiry; UUID token not guessable |
|
||||
| T-02-14 | Elevation of Privilege | Force password change bypass | mitigate | Global interceptor on ALL routes except /auth/change-password and /auth/logout per Pitfall 5 |
|
||||
| T-02-15 | Tampering | Admin password reset | mitigate | Only ADMIN/SUPER_ADMIN via RolesGuard; ADMIN cannot reset SUPER_ADMIN passwords |
|
||||
| T-02-SC | Tampering | npm installs (ldapts, mailer, nodemailer) | mitigate | Blocking human checkpoint verifies package legitimacy before installation |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After both tasks complete:
|
||||
1. docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
2. Login as admin, navigate to /change-password, change password -- should succeed
|
||||
3. With TESSERA_FORCE_CHANGE=true, new login should redirect to /change-password
|
||||
4. Request password reset via /reset-password, check MailHog at http://localhost:8025 for email
|
||||
5. Click reset link in email, set new password, login with new password
|
||||
6. Admin resets another user's password via API
|
||||
7. pnpm turbo type-check passes
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SUS packages verified and installed (supply chain security gate passed)
|
||||
- Password reset via email works end-to-end with MailHog in dev (D-03)
|
||||
- Admin can manually reset user passwords (D-03)
|
||||
- Force-password-change interceptor blocks all routes until password changed (D-06)
|
||||
- Reset tokens are single-use with 1-hour expiry
|
||||
- No email enumeration possible via reset endpoint
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-authentication-multi-tenancy/02-03-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,340 @@
|
||||
---
|
||||
phase: 02-authentication-multi-tenancy
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- 02-02
|
||||
- 02-03
|
||||
files_modified:
|
||||
- apps/api/src/ldap/ldap.module.ts
|
||||
- apps/api/src/ldap/ldap.service.ts
|
||||
- apps/api/src/ldap/ldap-config.service.ts
|
||||
- apps/api/src/ldap/ldap-sync.scheduler.ts
|
||||
- apps/api/src/ldap/dto/ldap-config.dto.ts
|
||||
- apps/api/src/ldap/ldap.controller.ts
|
||||
- apps/api/src/app.module.ts
|
||||
- apps/web/src/app/(portal)/admin/ldap/page.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/components/layout/sidebar.tsx
|
||||
- docker-compose.dev.yml
|
||||
autonomous: true
|
||||
requirements:
|
||||
- AUTH-06
|
||||
- TNNT-01
|
||||
- TNNT-02
|
||||
- TNNT-03
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can configure LDAP connection per tenant (server URL, base DN, bind user, filter, field mapping) per D-18"
|
||||
- "Admin can click 'LDAP synchronisieren' to manually trigger user sync per D-14"
|
||||
- "Auto-sync runs on configurable interval per D-14"
|
||||
- "LDAP sync creates new local users with passwordHash=null (LDAP-only users)"
|
||||
- "Users removed from LDAP are deactivated (not deleted) in Tessera per D-15"
|
||||
- "Field mapping is configurable with defaults (displayName->Name, mail->Email, sAMAccountName->Username) per D-16"
|
||||
- "Custom field mappings can be added via extensible mapping table per D-17"
|
||||
- "LDAP sync respects tenant context -- each tenant syncs independently per D-18"
|
||||
artifacts:
|
||||
- path: "apps/api/src/ldap/ldap.service.ts"
|
||||
provides: "LDAP client connection, search, and user sync logic"
|
||||
exports: ["LdapService"]
|
||||
- path: "apps/api/src/ldap/ldap-config.service.ts"
|
||||
provides: "Per-tenant LDAP configuration CRUD"
|
||||
exports: ["LdapConfigService"]
|
||||
- path: "apps/api/src/ldap/ldap-sync.scheduler.ts"
|
||||
provides: "Cron-based auto-sync per tenant"
|
||||
exports: ["LdapSyncScheduler"]
|
||||
- path: "apps/api/src/ldap/ldap.controller.ts"
|
||||
provides: "LDAP config and sync REST endpoints"
|
||||
exports: ["LdapController"]
|
||||
- path: "apps/web/src/app/(portal)/admin/ldap/page.tsx"
|
||||
provides: "LDAP configuration UI with test connection and sync button"
|
||||
min_lines: 80
|
||||
key_links:
|
||||
- from: "apps/api/src/ldap/ldap.service.ts"
|
||||
to: "apps/api/src/user/user.service.ts"
|
||||
via: "Creates/updates/deactivates users during sync"
|
||||
pattern: "userService\\.create|userService\\.update|userService\\.deactivate"
|
||||
- from: "apps/api/src/ldap/ldap-sync.scheduler.ts"
|
||||
to: "apps/api/src/ldap/ldap.service.ts"
|
||||
via: "Cron triggers syncUsersForTenant per active config"
|
||||
pattern: "ldapService.*syncUsersForTenant"
|
||||
- from: "apps/api/src/ldap/ldap-sync.scheduler.ts"
|
||||
to: "apps/api/src/prisma/prisma-tenant.extension.ts"
|
||||
via: "Sets tenant context explicitly before each sync per Pitfall 2"
|
||||
pattern: "forTenant"
|
||||
---
|
||||
|
||||
<objective>
|
||||
LDAP user import and synchronization: LdapModule with ldapts client for directory sync, per-tenant LDAP configuration (D-18), manual sync button (D-14), auto-sync scheduler (D-14), configurable field mapping with defaults (D-16/D-17), deactivation of removed users (D-15), and admin UI for LDAP settings.
|
||||
|
||||
This plan delivers AUTH-06 (LDAP/AD user import) and reinforces TNNT-01/TNNT-02/TNNT-03 by ensuring LDAP operations respect tenant boundaries.
|
||||
|
||||
Purpose: Enable organizations using Active Directory or LDAP to import their user directory into Tessera without manual user creation, with per-tenant isolation.
|
||||
|
||||
Output: Working LDAP sync that imports users from configured LDAP server, admin UI for LDAP configuration with test-connection and sync-now buttons.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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/02-authentication-multi-tenancy/02-01-SUMMARY.md
|
||||
@.planning/phases/02-authentication-multi-tenancy/02-02-SUMMARY.md
|
||||
@.planning/phases/02-authentication-multi-tenancy/02-03-SUMMARY.md
|
||||
@apps/api/src/user/user.service.ts
|
||||
@apps/api/src/tenant/tenant.service.ts
|
||||
@apps/api/prisma/schema.prisma
|
||||
@apps/api/src/app.module.ts
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
See Plan 02-01 for the full artifacts table.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: LdapModule with sync service, config service, scheduler, and controller</name>
|
||||
<read_first>
|
||||
- apps/api/prisma/schema.prisma (LdapConfig, LdapFieldMapping models)
|
||||
- apps/api/src/user/user.service.ts (user create/update/deactivate methods)
|
||||
- apps/api/src/prisma/prisma-tenant.extension.ts (forTenant for background jobs per Pitfall 2)
|
||||
- apps/api/src/prisma/prisma.service.ts (PrismaService)
|
||||
- .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (LDAP anti-pattern: use LDAP for sync only not auth; Pitfall 2: tenant context in async; Pitfall 6: deactivated user JWT)
|
||||
</read_first>
|
||||
<files>
|
||||
apps/api/src/ldap/ldap.module.ts
|
||||
apps/api/src/ldap/ldap.service.ts
|
||||
apps/api/src/ldap/ldap-config.service.ts
|
||||
apps/api/src/ldap/ldap-sync.scheduler.ts
|
||||
apps/api/src/ldap/dto/ldap-config.dto.ts
|
||||
apps/api/src/ldap/ldap.controller.ts
|
||||
apps/api/src/app.module.ts
|
||||
docker-compose.dev.yml
|
||||
</files>
|
||||
<action>
|
||||
Install @nestjs/schedule for cron-based auto-sync: cd apps/api && pnpm add @nestjs/schedule
|
||||
|
||||
Create apps/api/src/ldap/dto/ldap-config.dto.ts:
|
||||
- CreateLdapConfigDto: serverUrl (@IsUrl), baseDn (@IsString, @IsNotEmpty), bindDn (@IsString, @IsNotEmpty), bindPassword (@IsString, @IsNotEmpty), searchFilter (@IsString, @IsOptional, default "(objectClass=person)"), syncIntervalMin (@IsInt, @IsOptional, @Min(0), default 60), isActive (@IsBoolean, @IsOptional, default true)
|
||||
- UpdateLdapConfigDto: PartialType of CreateLdapConfigDto
|
||||
- CreateFieldMappingDto: ldapField (@IsString, @IsNotEmpty), tesseraField (@IsString, @IsNotEmpty), isDefault (@IsBoolean, @IsOptional)
|
||||
|
||||
Create apps/api/src/ldap/ldap-config.service.ts:
|
||||
- Injectable service. Dependency: PrismaService.
|
||||
- getConfig(tenantId): Find LdapConfig by tenantId, include fieldMappings.
|
||||
- createConfig(tenantId, dto): Create LdapConfig for tenant. Also create default field mappings: displayName->displayName, mail->email, sAMAccountName->username (with isDefault=true). Per D-16.
|
||||
- updateConfig(tenantId, dto): Update LdapConfig.
|
||||
- addFieldMapping(configId, dto): Create LdapFieldMapping. Per D-17.
|
||||
- removeFieldMapping(mappingId): Delete LdapFieldMapping (only if isDefault=false -- do not allow deleting system defaults).
|
||||
- getAllActiveConfigs(): Find all LdapConfigs where isActive=true, include tenant and fieldMappings. Used by scheduler.
|
||||
|
||||
Create apps/api/src/ldap/ldap.service.ts:
|
||||
- Injectable service. Dependencies: PrismaService, UserService.
|
||||
- CRITICAL ANTI-PATTERN AVOIDANCE: LDAP is used for DIRECTORY SYNC ONLY, never for authentication. Users authenticate against local password hashes. Per RESEARCH.md anti-pattern guidance.
|
||||
- testConnection(config): Create ldapts.Client with config.serverUrl, attempt bind with config.bindDn and config.bindPassword. Return success/failure. Close client after test.
|
||||
- syncUsersForTenant(config, tenantId):
|
||||
1. Create ldapts.Client, bind with service account credentials.
|
||||
2. Search baseDn with searchFilter, request attributes from field mappings.
|
||||
3. For each LDAP entry, map fields to Tessera user fields using config.fieldMappings.
|
||||
4. For each mapped user: upsert into local DB. If user exists by ldapDn or username, update fields. If new, create with role USER, passwordHash null (LDAP-only user per A6), tenantId, ldapDn set.
|
||||
5. DEACTIVATION per D-15: Find all local users with tenantId and ldapDn not null. Any user whose ldapDn is NOT in the sync result set gets isActive set to false. Do NOT delete.
|
||||
6. IMPORTANT per Pitfall 2: This method receives tenantId explicitly. When called from scheduler, the tenant context must be set BEFORE any DB operations. Use forTenant(prisma, tenantId) to create scoped client for all DB operations within this sync.
|
||||
7. Update LdapConfig.lastSyncAt to now.
|
||||
8. Return sync result: { created: number, updated: number, deactivated: number, errors: string[] }.
|
||||
- Close client connection in finally block.
|
||||
- LDAP search filter: validate that user-provided searchFilter does not contain LDAP injection characters. Sanitize by escaping special characters per RFC 4515.
|
||||
|
||||
Create apps/api/src/ldap/ldap-sync.scheduler.ts:
|
||||
- Injectable service using @nestjs/schedule Cron decorator.
|
||||
- Dependencies: LdapService, LdapConfigService, PrismaService.
|
||||
- Runs every minute (Cron '* * * * *') but checks each config's syncIntervalMin to determine if sync is due.
|
||||
- In handleCron(): Get all active configs via ldapConfigService.getAllActiveConfigs(). For each config where syncIntervalMin > 0 and lastSyncAt is older than syncIntervalMin minutes ago (or lastSyncAt is null): call ldapService.syncUsersForTenant(config, config.tenantId). Per D-14 auto-sync.
|
||||
- CRITICAL per Pitfall 2: Each tenant sync is an independent operation with its own tenant context. Do NOT share database connections across tenant syncs. Create a new forTenant client per tenant.
|
||||
- Log sync results (created/updated/deactivated count).
|
||||
- Wrap each sync in try/catch -- one tenant's failure must not block others.
|
||||
|
||||
Create apps/api/src/ldap/ldap.controller.ts:
|
||||
- Prefix 'ldap'
|
||||
- GET /ldap/config: @Roles(Role.ADMIN, Role.SUPER_ADMIN) @UseGuards(RolesGuard). Get LDAP config for current tenant (from req.tenantId). Per D-18, config is per-tenant.
|
||||
- POST /ldap/config: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Body: CreateLdapConfigDto. Create LDAP config for current tenant.
|
||||
- PATCH /ldap/config: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Body: UpdateLdapConfigDto. Update LDAP config.
|
||||
- POST /ldap/test-connection: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Test LDAP connection with current config. Return success/failure with error message.
|
||||
- POST /ldap/sync: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Trigger manual sync per D-14 "LDAP synchronisieren" button. Return sync results.
|
||||
- POST /ldap/config/mappings: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Body: CreateFieldMappingDto. Add field mapping per D-17.
|
||||
- DELETE /ldap/config/mappings/:id: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Remove non-default field mapping.
|
||||
|
||||
Create apps/api/src/ldap/ldap.module.ts:
|
||||
- Import ScheduleModule.forRoot() from @nestjs/schedule
|
||||
- Providers: LdapService, LdapConfigService, LdapSyncScheduler
|
||||
- Controllers: LdapController
|
||||
- Imports: UserModule (for UserService)
|
||||
- Exports: LdapService
|
||||
|
||||
Update app.module.ts: Import LdapModule and ScheduleModule.forRoot().
|
||||
|
||||
Add test LDAP server to docker-compose.dev.yml for development testing:
|
||||
- openldap service using osixia/openldap:1.5.0 image
|
||||
- Environment: LDAP_ORGANISATION=Tessera, LDAP_DOMAIN=tessera.local, LDAP_ADMIN_PASSWORD=admin
|
||||
- Ports: 389:389, 636:636
|
||||
- Network: data-net (internal, same as PostgreSQL)
|
||||
- Add phpldapadmin service using osixia/phpldapadmin:0.9.0 for visual LDAP management in dev
|
||||
- phpldapadmin environment: PHPLDAPADMIN_LDAP_HOSTS=openldap
|
||||
- phpldapadmin ports: 6443:443
|
||||
- phpldapadmin network: backend-net
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/api</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- LdapService uses ldapts for DIRECTORY SYNC ONLY (never for authentication per anti-pattern)
|
||||
- LdapConfigService creates default field mappings (displayName->displayName, mail->email, sAMAccountName->username) per D-16
|
||||
- Custom field mappings can be added and removed per D-17
|
||||
- LDAP config is per-tenant per D-18
|
||||
- syncUsersForTenant deactivates (not deletes) users removed from LDAP per D-15
|
||||
- LdapSyncScheduler sets tenant context explicitly per sync per Pitfall 2
|
||||
- Manual sync endpoint POST /ldap/sync exists per D-14
|
||||
- Auto-sync cron checks syncIntervalMin per D-14
|
||||
- Test connection endpoint exists for LDAP config validation
|
||||
- OpenLDAP and phpLDAPadmin added to docker-compose.dev.yml
|
||||
- Type-check passes
|
||||
</acceptance_criteria>
|
||||
<done>LDAP sync creates/updates/deactivates users from configured LDAP directory per tenant with configurable field mapping and auto-sync interval.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: LDAP admin configuration UI page</name>
|
||||
<read_first>
|
||||
- apps/api/src/ldap/ldap.controller.ts (API endpoints from Task 1)
|
||||
- apps/api/src/ldap/dto/ldap-config.dto.ts (DTO fields)
|
||||
- apps/web/src/app/(portal)/admin/users/page.tsx (existing admin page pattern from Plan 02-02)
|
||||
- apps/web/src/components/layout/sidebar.tsx (admin section navigation)
|
||||
- apps/web/src/messages/de.json (existing i18n structure)
|
||||
</read_first>
|
||||
<files>
|
||||
apps/web/src/app/(portal)/admin/ldap/page.tsx
|
||||
apps/web/src/messages/de.json
|
||||
apps/web/src/messages/en.json
|
||||
apps/web/src/components/layout/sidebar.tsx
|
||||
</files>
|
||||
<action>
|
||||
Create apps/web/src/app/(portal)/admin/ldap/page.tsx:
|
||||
- 'use client' component
|
||||
- Only visible to ADMIN and SUPER_ADMIN roles (check auth store)
|
||||
- Fetches current LDAP config from GET /ldap/config on mount
|
||||
- Layout in sections:
|
||||
|
||||
Section 1 - Connection Settings:
|
||||
- Server URL input (e.g., ldap://ldap.example.com or ldaps://)
|
||||
- Base DN input (e.g., dc=example,dc=com)
|
||||
- Bind DN input (e.g., cn=admin,dc=example,dc=com)
|
||||
- Bind Password input (password type)
|
||||
- Search Filter input with default "(objectClass=person)"
|
||||
- "Verbindung testen" (Test Connection) button -- calls POST /ldap/test-connection, shows success/error toast
|
||||
- Save button to POST/PATCH /ldap/config
|
||||
|
||||
Section 2 - Field Mapping (per D-16, D-17):
|
||||
- Table showing current mappings: LDAP Field | Tessera Field | Default | Actions
|
||||
- Default mappings (isDefault=true) shown with lock icon, cannot be deleted
|
||||
- Custom mappings have a delete button
|
||||
- "Mapping hinzufuegen" (Add Mapping) button opens form row with ldapField and tesseraField inputs
|
||||
- Per D-17: extensible mapping table -- users can map any LDAP attribute to any Tessera field
|
||||
|
||||
Section 3 - Sync Settings:
|
||||
- Auto-sync interval input (minutes, 0 = disabled) per D-14
|
||||
- Last sync timestamp display (or "Noch nie synchronisiert" if null)
|
||||
- "LDAP synchronisieren" button per D-14 -- calls POST /ldap/sync
|
||||
- Sync result display: created/updated/deactivated counts, error list if any
|
||||
- Enable/disable toggle for the entire LDAP configuration
|
||||
|
||||
All strings via useTranslations('admin') with ldap namespace.
|
||||
|
||||
Update sidebar.tsx to add "LDAP" link under Verwaltung section:
|
||||
- Add "LDAP" navigation item linking to /admin/ldap
|
||||
- Only visible to ADMIN and SUPER_ADMIN roles
|
||||
- Add i18n key: sidebar.ldap
|
||||
|
||||
Add i18n keys to de.json and en.json:
|
||||
- admin.ldap.title ("LDAP-Konfiguration" / "LDAP Configuration")
|
||||
- admin.ldap.serverUrl, admin.ldap.baseDn, admin.ldap.bindDn, admin.ldap.bindPassword, admin.ldap.searchFilter
|
||||
- admin.ldap.testConnection, admin.ldap.testSuccess, admin.ldap.testFailed
|
||||
- admin.ldap.save, admin.ldap.saved
|
||||
- admin.ldap.fieldMapping.title, admin.ldap.fieldMapping.ldapField, admin.ldap.fieldMapping.tesseraField, admin.ldap.fieldMapping.default, admin.ldap.fieldMapping.add, admin.ldap.fieldMapping.remove
|
||||
- admin.ldap.sync.title, admin.ldap.sync.interval, admin.ldap.sync.intervalDisabled, admin.ldap.sync.lastSync, admin.ldap.sync.neverSynced, admin.ldap.sync.trigger, admin.ldap.sync.syncing, admin.ldap.sync.result (with created/updated/deactivated placeholders)
|
||||
- admin.ldap.enable, admin.ldap.disable
|
||||
- sidebar.ldap ("LDAP" for both languages)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/web</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- LDAP configuration page exists at /admin/ldap
|
||||
- Connection settings section has all required fields per D-18
|
||||
- Test connection button calls API and shows result
|
||||
- Field mapping table shows defaults per D-16 and allows adding custom mappings per D-17
|
||||
- Sync section has interval config and manual sync button per D-14
|
||||
- Sidebar shows LDAP link under Verwaltung for ADMIN/SUPER_ADMIN
|
||||
- All strings internationalized (de + en)
|
||||
- Type-check passes
|
||||
</acceptance_criteria>
|
||||
<done>LDAP admin page allows per-tenant LDAP configuration (D-18), field mapping (D-16/D-17), test connection, and manual sync trigger (D-14). All AUTH-06 requirements are user-accessible.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| NestJS API -> LDAP server | Service account binds to external LDAP; credentials must not leak |
|
||||
| LDAP directory -> Tessera DB | Untrusted directory data mapped to local users |
|
||||
| Scheduler -> DB | Background jobs must set tenant context explicitly |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-16 | Tampering | LDAP search filter injection | mitigate | Sanitize user-provided searchFilter by escaping special characters per RFC 4515 before ldapts search |
|
||||
| T-02-17 | Information Disclosure | LDAP bind credentials | mitigate | Store bindPassword in DB (encrypted at rest via PostgreSQL); never log or return in API responses |
|
||||
| T-02-18 | Spoofing | LDAP as auth backend | mitigate | LDAP used for DIRECTORY SYNC ONLY per anti-pattern; users authenticate against local hashes |
|
||||
| T-02-19 | Information Disclosure | Cross-tenant LDAP data | mitigate | Each tenant has independent LdapConfig; sync sets tenant context per operation per Pitfall 2 |
|
||||
| T-02-20 | Denial of Service | LDAP sync overload | accept | Sync runs sequentially per tenant; large directories may take time but won't crash the system |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After both tasks complete:
|
||||
1. docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d (includes OpenLDAP)
|
||||
2. Add test users to OpenLDAP via phpLDAPadmin at https://localhost:6443
|
||||
3. Login as admin, navigate to /admin/ldap
|
||||
4. Configure LDAP: serverUrl=ldap://openldap:389, baseDn=dc=tessera,dc=local, bindDn=cn=admin,dc=tessera,dc=local, bindPassword=admin
|
||||
5. Click "Verbindung testen" -- should show success
|
||||
6. Click "LDAP synchronisieren" -- should import test users
|
||||
7. Navigate to /admin/users -- should show imported LDAP users with role USER
|
||||
8. Remove a user from OpenLDAP, sync again -- user should be deactivated in Tessera
|
||||
9. pnpm turbo type-check passes
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- LDAP sync imports users from configured directory (AUTH-06)
|
||||
- Per-tenant LDAP configuration works (D-18)
|
||||
- Manual sync trigger works (D-14)
|
||||
- Auto-sync scheduler runs on configured interval (D-14)
|
||||
- Field mapping configurable with defaults (D-16/D-17)
|
||||
- Removed LDAP users are deactivated not deleted (D-15)
|
||||
- Admin UI provides full LDAP management
|
||||
- Tenant isolation maintained during background sync (TNNT-01/TNNT-03)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-authentication-multi-tenancy/02-04-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
phase: 02-authentication-multi-tenancy
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on:
|
||||
- 02-02
|
||||
- 02-03
|
||||
- 02-04
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements:
|
||||
- AUTH-01
|
||||
- AUTH-02
|
||||
- AUTH-03
|
||||
- AUTH-04
|
||||
- AUTH-05
|
||||
- AUTH-06
|
||||
- TNNT-01
|
||||
- TNNT-02
|
||||
- TNNT-03
|
||||
must_haves:
|
||||
truths:
|
||||
- "All phase 2 success criteria verified by human observation"
|
||||
- "Login flow works end-to-end in browser"
|
||||
- "Admin can manage users and tenants"
|
||||
- "LDAP sync functional against test server"
|
||||
- "Tenant data isolation confirmed"
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Visual and functional verification of the complete Phase 2 authentication and multi-tenancy system. Human confirms all success criteria from the ROADMAP are met through browser-based testing.
|
||||
|
||||
Purpose: Ensure the entire auth and multi-tenancy vertical slice works as expected before marking Phase 2 complete.
|
||||
|
||||
Output: Verified phase completion or issue list for gap closure.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/02-authentication-multi-tenancy/02-CONTEXT.md
|
||||
@.planning/phases/02-authentication-multi-tenancy/02-01-SUMMARY.md
|
||||
@.planning/phases/02-authentication-multi-tenancy/02-02-SUMMARY.md
|
||||
@.planning/phases/02-authentication-multi-tenancy/02-03-SUMMARY.md
|
||||
@.planning/phases/02-authentication-multi-tenancy/02-04-SUMMARY.md
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
No new artifacts. This plan verifies existing artifacts from Plans 02-01 through 02-04.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>
|
||||
Complete Phase 2: Authentication and Multi-Tenancy system including:
|
||||
- Split-screen login page with Tessera branding (D-01)
|
||||
- JWT httpOnly cookie session with 30-day expiry (D-02)
|
||||
- Password reset via email with MailHog (D-03)
|
||||
- Force password change on first login (D-06)
|
||||
- Initial admin seeded from Docker ENV (D-05, D-07)
|
||||
- Three-role RBAC: Super-Admin, Admin, User (D-12)
|
||||
- User CRUD admin page (AUTH-02)
|
||||
- Tenant management for Super-Admin (D-10, TNNT-02)
|
||||
- PostgreSQL RLS tenant data isolation (D-11, TNNT-01)
|
||||
- LDAP sync with configurable field mapping (AUTH-06, D-14..D-18)
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Start the full stack:
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
|
||||
**1. Login Flow (AUTH-03, D-01, D-04)**
|
||||
- Open http://localhost:3000 -- should redirect to /login
|
||||
- Verify login page is split-screen: branding left (yellow), form right
|
||||
- Verify NO sidebar or header on login page
|
||||
- Login with admin / admin123
|
||||
- Verify redirect to dashboard
|
||||
- Verify header shows admin user avatar/initial
|
||||
- Verify sidebar footer shows admin name and role
|
||||
|
||||
**2. Session Persistence (AUTH-04, D-02)**
|
||||
- Refresh the browser page
|
||||
- Verify you are still logged in (not redirected to login)
|
||||
- Open browser dev tools -> Application -> Cookies
|
||||
- Verify "session" cookie exists with httpOnly flag
|
||||
|
||||
**3. User Management (AUTH-02, D-12)**
|
||||
- Navigate to /admin/users (or click "Benutzer" in sidebar)
|
||||
- Verify user table shows the admin account
|
||||
- Create a new user with role "User"
|
||||
- Verify user appears in table
|
||||
- Edit the user (change display name)
|
||||
- Verify changes reflected
|
||||
- Try creating a user with role "Admin"
|
||||
|
||||
**4. Tenant Management (TNNT-02, D-10)**
|
||||
- Navigate to /admin/tenants (or click "Mandanten" in sidebar)
|
||||
- Verify "Default" tenant is listed
|
||||
- Create a new tenant
|
||||
- Verify it appears in the table
|
||||
|
||||
**5. Logout**
|
||||
- Click logout button in header
|
||||
- Verify redirect to /login
|
||||
- Try navigating to /admin/users directly -- should redirect to /login
|
||||
|
||||
**6. Password Reset (D-03)**
|
||||
- On login page, click "Passwort vergessen" link
|
||||
- Enter admin email, submit
|
||||
- Open MailHog at http://localhost:8025
|
||||
- Verify password reset email received
|
||||
- Click the reset link in the email
|
||||
- Set new password, verify success
|
||||
|
||||
**7. Force Password Change (D-06)**
|
||||
- (If TESSERA_FORCE_CHANGE was set to true for a user)
|
||||
- Login as that user -- should be redirected to change password page
|
||||
- Change password, verify normal access afterward
|
||||
|
||||
**8. LDAP Sync (AUTH-06, D-14..D-18)**
|
||||
- Navigate to /admin/ldap
|
||||
- Verify LDAP configuration form exists with all fields
|
||||
- Verify default field mappings are shown (displayName, mail, sAMAccountName)
|
||||
- If OpenLDAP is running with test data:
|
||||
- Configure connection and click "Verbindung testen"
|
||||
- Click "LDAP synchronisieren" and verify result counts
|
||||
|
||||
**9. i18n**
|
||||
- Switch language to English (locale switcher in sidebar)
|
||||
- Verify all auth-related pages show English text
|
||||
- Switch back to German
|
||||
|
||||
**10. Theme**
|
||||
- Toggle to dark mode
|
||||
- Verify login page and admin pages render correctly in dark mode
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if all checks pass, or describe any issues found</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
No new trust boundaries -- this plan verifies existing ones.
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
No new threats -- this plan verifies existing mitigations.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Human verifies all ROADMAP Phase 2 success criteria:
|
||||
1. Initial admin account is created automatically from Docker environment variables on first startup
|
||||
2. Admin can create, edit, and delete user accounts with role assignment (Admin/User)
|
||||
3. User can log in and log out, with session surviving browser refresh
|
||||
4. Admin can create and manage tenants, and each user's data is isolated per tenant via RLS
|
||||
5. Users can be imported from an LDAP/AD directory
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All 5 ROADMAP success criteria visually confirmed
|
||||
- All 18 locked decisions (D-01..D-18) implemented as specified
|
||||
- No blocking issues remain
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-authentication-multi-tenancy/02-05-SUMMARY.md` when done
|
||||
</output>
|
||||
Reference in New Issue
Block a user