docs(phase-2): research authentication and multi-tenancy domain

This commit is contained in:
2026-06-18 13:00:15 +02:00
parent 45286d58f9
commit 4da1c99807
@@ -0,0 +1,845 @@
# Phase 2: Authentication & Multi-Tenancy - Research
**Researched:** 2026-06-18
**Domain:** Authentication, session management, RBAC, multi-tenant RLS, LDAP user sync
**Confidence:** HIGH
## Summary
Phase 2 implements user authentication (login/logout, session management, password reset), role-based access control (Super-Admin / Admin / User), multi-tenant data isolation via PostgreSQL Row-Level Security, and LDAP user import/sync. The existing NestJS 11 API and Next.js frontend (with portal shell, i18n, theming from Phase 1) provide the foundation.
**Keycloak is NOT recommended for MVP scope.** The phase requirements (local auth, LDAP sync, 3 roles, password reset) are well within custom NestJS auth capability. Keycloak requires 750MB-2GB JVM memory overhead and its primary value (SSO/OIDC, social logins, 2FA) is explicitly deferred to v2 (AUTH-V2-01..03). A clean auth abstraction layer enables adding Keycloak later for SSO without rearchitecting. [ASSUMED -- the user's CONTEXT.md leaves this as Claude's discretion; this recommendation should be confirmed.]
**Primary recommendation:** Build custom auth with NestJS Passport + JWT strategy, httpOnly session cookies on the frontend, Prisma Client Extensions for per-request RLS tenant context, argon2 for password hashing, and ldapts for LDAP sync. Use jose on the Next.js side for stateless session tokens.
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Login page as split-screen (left branding/image, right login form)
- **D-02:** Session duration 30 days with "Remember me" checkbox
- **D-03:** Password reset via email (self-service) + admin manual reset -- requires SMTP configuration
- **D-04:** Login page shows no sidebar/header -- standalone layout
- **D-05:** Initial admin via Docker ENV: TESSERA_ADMIN_USER, TESSERA_ADMIN_EMAIL, TESSERA_ADMIN_PASSWORD
- **D-06:** Force-password-change on first login optionally configurable via ENV: TESSERA_FORCE_CHANGE=true/false
- **D-07:** Admin created automatically on first container start if not exists
- **D-08:** Tenant assignment per user (no URL difference, no subdomain routing)
- **D-09:** One tenant per user for now -- multi-tenant membership comes later
- **D-10:** Super-Admin role: can see/manage all tenants, create tenants, switch between them, change global settings
- **D-11:** RLS on PostgreSQL level -- tenant ID set per request context
- **D-12:** Three roles: Super-Admin (platform-wide), Admin (tenant-specific), User (tenant-specific)
- **D-13:** Super-Admin created via initial admin account (first account = Super-Admin)
- **D-14:** Manual button ("LDAP synchronisieren") + configurable auto-sync interval
- **D-15:** Users removed from LDAP: deactivated in Tessera (not deleted)
- **D-16:** Field mapping configurable with defaults: displayName -> Name, mail -> Email, sAMAccountName -> Username
- **D-17:** Custom fields must also be mappable (extensible mapping table)
- **D-18:** LDAP configuration per tenant (Server-URL, Base-DN, Bind-User, Filter, Mapping)
### Claude's Discretion
- Tenant identification mechanism (JWT claim, middleware pattern, header) -- decided: JWT claim with NestJS middleware
- JWT vs session cookie implementation -- decided: JWT-based httpOnly cookies
- SMTP configuration structure (ENV variables for mail server)
- Keycloak usage -- decided: NO for MVP, custom auth instead (see Summary)
### Deferred Ideas (OUT OF SCOPE)
None -- discussion stayed within phase scope
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| AUTH-01 | Initial admin account created via Docker ENV at setup | NestJS lifecycle hook (onApplicationBootstrap) checks ENV vars and creates admin with argon2-hashed password if not exists |
| AUTH-02 | Admin can manually create, edit, and delete users | NestJS CRUD controller with Prisma, protected by RoleGuard requiring Admin or Super-Admin role |
| AUTH-03 | User can log in and log out | Passport local strategy validates credentials, issues JWT in httpOnly cookie; logout clears cookie |
| AUTH-04 | User session persists across browser refresh | JWT stored in httpOnly cookie with 30-day expiry; Next.js middleware validates on each request |
| AUTH-05 | Role-based access control (Admin, User) | NestJS custom RoleGuard decorator checks JWT role claim; three roles: Super-Admin, Admin, User |
| AUTH-06 | Users can be imported via LDAP/AD | ldapts client connects to configured LDAP server, syncs users to local DB; manual trigger + cron interval |
| TNNT-01 | Data isolated via PostgreSQL Row-Level Security per tenant | RLS policies on all tenant-scoped tables; Prisma Client Extension sets session variable per request |
| TNNT-02 | Admin can create and manage tenants | Tenant CRUD endpoints; Super-Admin can manage all tenants, Admin manages own tenant |
| TNNT-03 | Every request automatically assigned to correct tenant | NestJS TenantMiddleware extracts tenantId from JWT, sets PostgreSQL session variable via Prisma Extension |
</phase_requirements>
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Password hashing & validation | API / Backend | -- | Credentials never leave the server; argon2 runs server-side only |
| JWT token issuance | API / Backend | -- | Token signed with server secret, returned as httpOnly cookie |
| Session cookie management | API / Backend | Frontend Server (SSR) | API sets/clears cookie; Next.js middleware reads cookie for route protection |
| Route protection (frontend) | Frontend Server (SSR) | Browser / Client | Next.js middleware redirects unauthenticated users; client-side auth context for UI state |
| Route protection (API) | API / Backend | -- | NestJS AuthGuard validates JWT on every request |
| RBAC enforcement | API / Backend | Frontend Server (SSR) | Guards enforce roles server-side; frontend conditionally renders UI based on role |
| RLS tenant isolation | Database / Storage | API / Backend | PostgreSQL RLS policies enforce at DB level; middleware sets session variable |
| Tenant context injection | API / Backend | -- | Middleware extracts tenantId from JWT, sets on DB connection |
| LDAP sync | API / Backend | -- | Server-side LDAP client; never expose LDAP credentials to frontend |
| Password reset emails | API / Backend | -- | SMTP transport via nodemailer; tokens generated server-side |
| Login UI | Browser / Client | -- | Client component with form; Server Action or API call for submission |
| User management UI | Browser / Client | Frontend Server (SSR) | Admin pages with client-side forms; data fetched via API |
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| @nestjs/jwt | 11.0.2 | JWT token creation/verification in NestJS | Official NestJS JWT module; integrates with guards and DI [VERIFIED: npm registry] |
| @nestjs/passport | 11.0.5 | Passport.js integration for NestJS | Official NestJS auth integration; strategy-based auth pattern [VERIFIED: npm registry] |
| passport | 0.7.0 | Authentication middleware | De facto Node.js auth standard; 7.7M weekly downloads [VERIFIED: npm registry] |
| passport-jwt | 4.0.1 | JWT authentication strategy | Standard JWT strategy for Passport; extracts and verifies JWTs [VERIFIED: npm registry] |
| passport-local | 1.0.0 | Username/password authentication | Standard local strategy; used for login endpoint [VERIFIED: npm registry] |
| argon2 | 0.44.0 | Password hashing | Winner of Password Hashing Competition; memory-hard, resistant to GPU attacks [VERIFIED: npm registry] |
| jose | 6.2.3 | JWT operations for Next.js frontend | Edge-compatible JWT library; recommended by Next.js docs for middleware [CITED: nextjs.org/docs/app/guides/authentication] |
| zod | 4.4.3 | Schema validation | TypeScript-first validation; recommended by Next.js for form validation [CITED: nextjs.org/docs/app/guides/authentication] |
| class-validator | 0.15.1 | DTO validation in NestJS | Standard NestJS validation pipe integration [VERIFIED: npm registry] |
| class-transformer | 0.5.1 | DTO transformation in NestJS | Pairs with class-validator for request transformation [VERIFIED: npm registry] |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| ldapts | 8.1.8 | LDAP client for user sync | LDAP sync feature (AUTH-06); replaces deprecated ldapjs [VERIFIED: npm registry] |
| @nestjs-modules/mailer | 2.3.7 | Email sending for NestJS | Password reset emails (D-03) [VERIFIED: npm registry] |
| nodemailer | 9.0.1 | SMTP transport | Underlying transport for @nestjs-modules/mailer [VERIFIED: npm registry] |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Custom auth | Keycloak 26.6 | Keycloak adds 750MB-2GB RAM overhead for JVM; overkill for MVP scope (local auth + LDAP sync); recommended for v2 when SSO/OIDC needed [ASSUMED] |
| argon2 | bcrypt | bcrypt is simpler to install (no native deps); argon2 is more secure (memory-hard) and recommended for new projects [CITED: docs.nestjs.com/security/encryption-and-hashing] |
| ldapts | ldapjs | ldapjs is formally deprecated after maintainer abuse incident; ldapts is its TypeScript successor with promise-based API [CITED: socket.dev/blog/ldapjs-open-source-project-decommissioned] |
| jose (frontend) | jsonwebtoken | jose is Edge-compatible (works in Next.js middleware); jsonwebtoken requires Node.js runtime [CITED: nextjs.org/docs/app/guides/authentication] |
| class-validator | zod (backend) | class-validator integrates natively with NestJS ValidationPipe; zod used on frontend where NestJS decorators unavailable |
**Installation (API):**
```bash
cd apps/api
pnpm add @nestjs/jwt @nestjs/passport passport passport-jwt passport-local argon2 class-validator class-transformer @nestjs-modules/mailer nodemailer ldapts
pnpm add -D @types/passport-jwt @types/passport-local @types/nodemailer
```
**Installation (Web):**
```bash
cd apps/web
pnpm add jose zod
```
## Package Legitimacy Audit
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---------|----------|-----|-----------|-------------|---------|-------------|
| @nestjs/jwt | npm | 6+ yrs | 4M/wk | github.com/nestjs/jwt | OK | Approved |
| @nestjs/passport | npm | 6+ yrs | 3.8M/wk | github.com/nestjs/passport | OK | Approved |
| passport | npm | 12+ yrs | 7.7M/wk | github.com/jaredhanson/passport | OK | Approved |
| passport-jwt | npm | 9+ yrs | 3.8M/wk | github.com/mikenicholson/passport-jwt | OK | Approved |
| passport-local | npm | 12+ yrs | 2M/wk | github.com/jaredhanson/passport-local | OK | Approved |
| argon2 | npm | 8+ yrs | 1.5M/wk | github.com/ranisalt/node-argon2 | OK | Approved |
| jose | npm | 8+ yrs | 87M/wk | github.com/panva/jose | OK | Approved |
| zod | npm | 5+ yrs | 201M/wk | github.com/colinhacks/zod | OK | Approved |
| class-validator | npm | 7+ yrs | 10M/wk | github.com/typestack/class-validator | OK | Approved |
| class-transformer | npm | 7+ yrs | 10M/wk | github.com/typestack/class-transformer | OK | Approved |
| ldapts | npm | 6+ yrs | 400K/wk | github.com/ldapts/ldapts | SUS (too-new publish) | Flagged -- well-established package, recent publish is routine update; planner should add checkpoint |
| @nestjs-modules/mailer | npm | 5+ yrs | 350K/wk | github.com/nest-modules/mailer | SUS (too-new publish) | Flagged -- well-established package, recent publish is routine update; planner should add checkpoint |
| nodemailer | npm | 14+ yrs | 17M/wk | github.com/nodemailer/nodemailer | SUS (too-new publish) | Flagged -- ubiquitous email library, recent publish is routine update; planner should add checkpoint |
**Packages removed due to [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** ldapts, @nestjs-modules/mailer, nodemailer -- all flagged only for "too-new" publish dates (routine updates on well-established packages with millions of cumulative downloads). Planner should add checkpoint:human-verify before installing these.
*Note: ldapjs was initially considered but is formally deprecated. ldapts is its actively maintained TypeScript successor.*
## Architecture Patterns
### System Architecture Diagram
```
Browser/Desktop
|
v
[Next.js Frontend]
| (1) Login form POST /api/auth/login
| (2) Cookie: session=<JWT> (httpOnly, secure, sameSite=lax, 30d)
| (3) Next.js middleware reads cookie, validates JWT with jose
| - Valid: NextResponse.next()
| - Invalid/missing: redirect to /login
v
[NestJS API] (port 3001)
|
+--> [AuthModule]
| |-- PassportLocalStrategy (login: validate username+password via argon2)
| |-- PassportJwtStrategy (all other requests: extract JWT from cookie)
| |-- AuthGuard (global, extracts user from JWT)
| |-- RoleGuard (per-route, checks role claim)
| +-- AuthService (login, register, password-reset, token generation)
|
+--> [TenantModule]
| |-- TenantMiddleware (extracts tenantId from JWT, sets on request)
| |-- TenantService (CRUD, admin seeding)
| +-- PrismaExtension (SET app.current_tenant per query batch)
|
+--> [UserModule]
| |-- UserController (CRUD for admin)
| |-- UserService (create, update, deactivate, password management)
| +-- AdminSeedService (onApplicationBootstrap: create initial admin from ENV)
|
+--> [LdapModule]
| |-- LdapService (connect, search, sync users)
| |-- LdapConfigService (per-tenant LDAP settings)
| +-- LdapSyncScheduler (cron-based auto-sync)
|
+--> [MailModule]
|-- MailService (send password reset, send welcome emails)
+-- SMTP config from ENV
|
v
[PostgreSQL 16] (data-net, internal)
|-- RLS policies on all tenant-scoped tables
|-- Session variable: app.current_tenant
+-- argon2 password hashes stored in User table
```
### Recommended Project Structure
```
apps/api/src/
auth/
auth.module.ts
auth.controller.ts
auth.service.ts
strategies/
local.strategy.ts
jwt.strategy.ts
guards/
jwt-auth.guard.ts
roles.guard.ts
decorators/
public.decorator.ts
roles.decorator.ts
current-user.decorator.ts
dto/
login.dto.ts
register.dto.ts
reset-password.dto.ts
tenant/
tenant.module.ts
tenant.controller.ts
tenant.service.ts
tenant.middleware.ts
dto/
create-tenant.dto.ts
user/
user.module.ts
user.controller.ts
user.service.ts
admin-seed.service.ts
dto/
create-user.dto.ts
update-user.dto.ts
ldap/
ldap.module.ts
ldap.service.ts
ldap-config.service.ts
ldap-sync.scheduler.ts
dto/
ldap-config.dto.ts
mail/
mail.module.ts
mail.service.ts
prisma/
prisma.module.ts
prisma.service.ts
prisma-tenant.extension.ts
apps/web/src/
app/
(auth)/ # Route group: no AppShell layout
login/
page.tsx # Split-screen login page
reset-password/
page.tsx # Password reset request page
reset-password/[token]/
page.tsx # Password reset form
layout.tsx # Standalone layout (no sidebar/header)
(portal)/ # Route group: AppShell layout
layout.tsx # AppShell wrapper
page.tsx # Dashboard (existing)
admin/
users/
page.tsx # User management
tenants/
page.tsx # Tenant management (Super-Admin only)
ldap/
page.tsx # LDAP configuration
lib/
session.ts # JWT encrypt/decrypt with jose
dal.ts # Data Access Layer (verifySession, getUser)
auth-actions.ts # Server Actions for login/logout/register
stores/
auth-store.ts # Zustand store for client-side auth state
middleware.ts # Next.js middleware for route protection
```
### Pattern 1: JWT httpOnly Cookie Authentication
**What:** Issue JWT tokens stored in httpOnly cookies rather than localStorage. The cookie is automatically sent with every request, and JavaScript cannot access it (XSS protection).
**When to use:** All authenticated requests between frontend and API.
```typescript
// Source: NestJS docs + Next.js auth guide pattern
// apps/api/src/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private userService: UserService,
) {}
async validateUser(username: string, password: string): Promise<User | null> {
const user = await this.userService.findByUsername(username);
if (!user || !user.isActive) return null;
const valid = await argon2.verify(user.passwordHash, password);
return valid ? user : null;
}
async login(user: User, res: Response) {
const payload = {
sub: user.id,
username: user.username,
role: user.role,
tenantId: user.tenantId,
};
const token = this.jwtService.sign(payload, { expiresIn: '30d' });
res.cookie('session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
path: '/',
});
return { user: { id: user.id, username: user.username, role: user.role } };
}
}
```
### Pattern 2: Prisma Client Extension for RLS Tenant Context
**What:** A Prisma Client Extension that wraps every query in a transaction, setting the PostgreSQL session variable `app.current_tenant` before executing the actual query. This activates RLS policies automatically.
**When to use:** Every database query on tenant-scoped tables.
```typescript
// Source: github.com/prisma/prisma-client-extensions/tree/main/row-level-security
// apps/api/src/prisma/prisma-tenant.extension.ts
import { PrismaClient } from '@prisma/client';
export function forTenant(prisma: PrismaClient, tenantId: string) {
return prisma.$extends({
query: {
$allOperations({ args, query }) {
return prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(
`SELECT set_config('app.current_tenant', '${tenantId}', true)`
);
return query(args);
});
},
},
});
}
```
### Pattern 3: NestJS Global Auth Guard with @Public() Decorator
**What:** Register the JWT AuthGuard globally so ALL routes require authentication by default. Routes that should be public (login, health) use a custom `@Public()` decorator to skip the guard.
**When to use:** Applied once at app bootstrap; individual routes opt out.
```typescript
// apps/api/src/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// apps/api/src/auth/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) { super(); }
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]
);
if (isPublic) return true;
return super.canActivate(context);
}
}
// Register globally in app.module.ts
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }]
```
### Pattern 4: Next.js Middleware for Frontend Route Protection
**What:** Next.js middleware intercepts every navigation request, checks for a valid session cookie, and redirects to /login if missing or expired. This is an optimistic check -- the API still validates the JWT on every request.
**When to use:** Runs on every frontend route.
```typescript
// Source: nextjs.org/docs/app/guides/authentication
// apps/web/src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
const publicRoutes = ['/login', '/reset-password'];
const secret = new TextEncoder().encode(process.env.SESSION_SECRET);
export async function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
if (publicRoutes.some(route => path.startsWith(route))) {
return NextResponse.next();
}
const session = req.cookies.get('session')?.value;
if (!session) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
try {
await jwtVerify(session, secret, { algorithms: ['HS256'] });
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};
```
### Pattern 5: Tenant Middleware with RLS Activation
**What:** NestJS middleware that extracts tenantId from the authenticated user's JWT, creates a tenant-scoped Prisma client, and attaches it to the request. Super-Admin can switch tenants via header.
**When to use:** Every authenticated API request.
```typescript
// apps/api/src/tenant/tenant.middleware.ts
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private prisma: PrismaService) {}
async use(req: Request, res: Response, next: NextFunction) {
const user = req.user; // Set by JWT auth guard
if (!user) return next();
// Super-Admin can switch tenant via header
let tenantId = user.tenantId;
if (user.role === 'SUPER_ADMIN' && req.headers['x-tenant-id']) {
tenantId = req.headers['x-tenant-id'] as string;
}
if (!tenantId && user.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('No tenant context');
}
// Attach tenant-scoped Prisma client
req.tenantPrisma = tenantId
? forTenant(this.prisma, tenantId)
: this.prisma; // Super-Admin without tenant header gets unscoped access
req.tenantId = tenantId;
next();
}
}
```
### Anti-Patterns to Avoid
- **LDAP as authentication backend:** Never bind user credentials directly to LDAP for auth. Use LDAP for directory sync only; authenticate against local password hashes. This prevents credential exposure if the app server is compromised. [CITED: blog.lithnet.io/2018/03/the-ldap-authentication-anti-pattern.html]
- **Manual tenant filtering in queries:** Never write `WHERE tenant_id = ?` in application code. Use RLS at the database level so even application bugs cannot leak cross-tenant data. [CITED: aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/]
- **JWT in localStorage:** Never store JWTs in localStorage; XSS attacks can steal them. Use httpOnly cookies instead. [CITED: nextjs.org/docs/app/guides/authentication]
- **Auth checks only in layouts:** Next.js layouts do not re-render on navigation (partial rendering). Always verify auth in page components and Server Actions, not just layouts. [CITED: nextjs.org/docs/app/guides/authentication]
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Password hashing | Custom hash function | argon2 (or bcrypt) | Cryptographic hashing has subtle timing-attack vectors; argon2 is memory-hard, peer-reviewed |
| JWT creation/verification | Manual crypto | @nestjs/jwt + jose | Token format, expiry, signature verification have many edge cases |
| LDAP protocol | Raw TCP/TLS socket handling | ldapts | LDAP protocol is complex (ASN.1/BER encoding, referrals, paging) |
| Email sending | Raw SMTP socket | @nestjs-modules/mailer + nodemailer | SMTP has authentication, TLS negotiation, encoding, bounce handling |
| Input validation | Manual if/else chains | class-validator (API) + zod (frontend) | Schema-based validation is declarative, composable, testable |
| CSRF protection | Custom token system | SameSite cookies + httpOnly | Browser-native SameSite=lax on cookies prevents CSRF for same-site APIs |
| SQL injection in RLS | String interpolation | Parameterized queries via Prisma | `set_config()` must use parameterized calls, never string interpolation |
**Key insight:** Authentication and multi-tenancy are security-critical domains where custom implementations almost always have exploitable flaws. Use battle-tested libraries for all cryptographic and protocol operations.
## Common Pitfalls
### Pitfall 1: RLS Policies Not Applied on Prisma Direct Queries
**What goes wrong:** Prisma's `$executeRaw` and `$queryRaw` bypass the Client Extension's query hook, meaning RLS context might not be set for raw queries.
**Why it happens:** The Prisma Client Extension wraps `$allOperations` but raw queries have a different code path.
**How to avoid:** Always wrap raw queries inside the same transaction that sets the tenant context. Test that raw queries on tenant-scoped tables return only current tenant's data.
**Warning signs:** Raw queries returning data from multiple tenants in test environments.
### Pitfall 2: Tenant Context Lost in Background/Async Operations
**What goes wrong:** Background jobs (cron-based LDAP sync, scheduled tasks) have no HTTP request context, so no JWT and no tenant context.
**Why it happens:** The TenantMiddleware runs per HTTP request; async operations bypass this.
**How to avoid:** Every background job MUST explicitly include tenantId in its payload and set the RLS context before any database operation. The LDAP sync scheduler must iterate over tenants and set context per tenant.
**Warning signs:** LDAP sync that processes all tenants with a single database connection.
### Pitfall 3: Super-Admin RLS Bypass Leaks Data
**What goes wrong:** Super-Admin needs to see all tenants' data, but bypassing RLS incorrectly could leak data to regular users.
**Why it happens:** Using a superuser DB connection that bypasses RLS for Super-Admin routes, then accidentally sharing that connection with regular user requests.
**How to avoid:** Create two database roles: one with BYPASSRLS (for Super-Admin operations) and one without (for tenant-scoped operations). Never share the BYPASSRLS connection for regular requests. Alternative: Super-Admin queries iterate over tenants explicitly rather than bypassing RLS.
**Warning signs:** A single database connection pool used for both Super-Admin and regular users.
### Pitfall 4: Cookie Not Sent Cross-Origin in Development
**What goes wrong:** In development, the Next.js frontend (port 3000) and NestJS API (port 3001) are on different origins. The browser may not send cookies cross-origin.
**Why it happens:** SameSite=lax cookies are not sent on cross-origin POST requests. Secure flag requires HTTPS.
**How to avoid:** In development: set `secure: false`, ensure CORS credentials are enabled on the API (`app.enableCors({ origin: 'http://localhost:3000', credentials: true })`), and the frontend must send `credentials: 'include'` with fetch requests. In production behind Traefik, both are same-origin.
**Warning signs:** Login succeeds but subsequent requests get 401 in development.
### Pitfall 5: Force-Password-Change State Not Checked Everywhere
**What goes wrong:** User with `mustChangePassword: true` can access the API directly (bypassing the frontend redirect) and use the application without changing their password.
**Why it happens:** Force-change is only checked in the frontend; API endpoints don't enforce it.
**How to avoid:** Add a global NestJS interceptor that checks `mustChangePassword` on every request and returns 403 with a specific error code for all endpoints except the change-password endpoint. The frontend then redirects to the change-password page.
**Warning signs:** Users with force-change flag accessing regular API endpoints.
### Pitfall 6: LDAP Sync Race Condition with User Deactivation
**What goes wrong:** A user is actively logged in while LDAP sync deactivates their account. The existing JWT remains valid until expiry (30 days).
**Why it happens:** JWT tokens are stateless; deactivation only affects future token validation if the user is checked against the DB.
**How to avoid:** On every API request, the AuthGuard must verify `user.isActive` in the database (not just JWT validity). This adds one DB query per request but prevents deactivated users from continuing to operate. Cache this check with a short TTL (e.g., 5 minutes) for performance.
**Warning signs:** Deactivated LDAP users still able to use the application.
## Code Examples
### PostgreSQL RLS Setup (Migration SQL)
```sql
-- Source: Prisma Client Extensions RLS example + AWS RLS multi-tenancy guide
-- Create a limited database role for the application (no BYPASSRLS)
CREATE ROLE tessera_app LOGIN PASSWORD 'app_password';
GRANT USAGE ON SCHEMA public TO tessera_app;
-- Create function to get current tenant
CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS TEXT AS $$
SELECT current_setting('app.current_tenant', true);
$$ LANGUAGE sql STABLE;
-- Enable RLS on tenant-scoped tables
ALTER TABLE "User" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "User" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "User"
USING (tenant_id = current_tenant_id()::uuid);
-- Grant table access to app role
GRANT SELECT, INSERT, UPDATE, DELETE ON "User" TO tessera_app;
```
### Initial Admin Seed Service
```typescript
// apps/api/src/user/admin-seed.service.ts
@Injectable()
export class AdminSeedService implements OnApplicationBootstrap {
constructor(
private prisma: PrismaService,
private configService: ConfigService,
) {}
async onApplicationBootstrap() {
const username = this.configService.get('TESSERA_ADMIN_USER');
const email = this.configService.get('TESSERA_ADMIN_EMAIL');
const password = this.configService.get('TESSERA_ADMIN_PASSWORD');
const forceChange = this.configService.get('TESSERA_FORCE_CHANGE') === 'true';
if (!username || !email || !password) return; // ENV not set, skip
const exists = await this.prisma.user.findUnique({ where: { username } });
if (exists) return; // Already seeded
// Create default tenant
const tenant = await this.prisma.tenant.upsert({
where: { slug: 'default' },
update: {},
create: { name: 'Default', slug: 'default' },
});
// Create Super-Admin user
const hash = await argon2.hash(password);
await this.prisma.user.create({
data: {
username,
email,
passwordHash: hash,
role: 'SUPER_ADMIN',
tenantId: tenant.id,
mustChangePassword: forceChange,
isActive: true,
},
});
}
}
```
### Prisma Schema (Expanded for Phase 2)
```prisma
// apps/api/prisma/schema.prisma
model Tenant {
id String @id @default(uuid())
name String
slug String @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
ldapConfig LdapConfig?
}
enum Role {
SUPER_ADMIN
ADMIN
USER
}
model User {
id String @id @default(uuid())
username String @unique
email String @unique
passwordHash String? // Null for LDAP-only users
displayName String?
role Role @default(USER)
isActive Boolean @default(true)
mustChangePassword Boolean @default(false)
ldapDn String? // LDAP distinguished name if imported
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
passwordResetTokens PasswordResetToken[]
@@index([tenantId])
@@index([username])
@@index([email])
}
model PasswordResetToken {
id String @id @default(uuid())
token String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
}
model LdapConfig {
id String @id @default(uuid())
tenantId String @unique
tenant Tenant @relation(fields: [tenantId], references: [id])
serverUrl String // ldap://ldap.example.com or ldaps://
baseDn String // dc=example,dc=com
bindDn String // cn=admin,dc=example,dc=com
bindPassword String // Encrypted at rest
searchFilter String @default("(objectClass=person)")
syncIntervalMin Int @default(60) // Minutes between auto-syncs, 0 = disabled
isActive Boolean @default(true)
lastSyncAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fieldMappings LdapFieldMapping[]
}
model LdapFieldMapping {
id String @id @default(uuid())
ldapConfigId String
ldapConfig LdapConfig @relation(fields: [ldapConfigId], references: [id], onDelete: Cascade)
ldapField String // e.g., "displayName", "mail", "sAMAccountName"
tesseraField String // e.g., "displayName", "email", "username"
isDefault Boolean @default(false) // True for system-provided defaults
createdAt DateTime @default(now())
@@unique([ldapConfigId, ldapField])
}
```
### SMTP Configuration ENV Variables
```bash
# Docker ENV for SMTP (D-03)
TESSERA_SMTP_HOST=smtp.example.com
TESSERA_SMTP_PORT=587
TESSERA_SMTP_SECURE=false # true for port 465
TESSERA_SMTP_USER=tessera@example.com
TESSERA_SMTP_PASSWORD=secret
TESSERA_SMTP_FROM="Tessera <tessera@example.com>"
TESSERA_APP_URL=http://localhost # For password reset link generation
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| ldapjs | ldapts | 2023 (ldapjs deprecated) | Must use ldapts for LDAP; promise-based API, full TypeScript |
| jsonwebtoken (backend+frontend) | jose (Edge-compatible) + @nestjs/jwt (backend) | 2024 (Next.js middleware needs Edge) | jose works in Next.js middleware; jsonwebtoken requires Node.js runtime |
| bcrypt default | argon2 recommended | 2023+ (industry shift) | argon2 is PHC winner; NestJS docs recommend either, argon2 preferred for new projects |
| Prisma middleware for RLS | Prisma Client Extensions | 2023 (Prisma 4.16+) | Extensions provide type-safe per-request client; middleware is deprecated for this pattern |
| Next.js middleware.ts | Next.js proxy.ts (v16+) | 2026 (Next.js 16) | proxy.ts replaces middleware.ts for route interception; check actual project Next.js version |
**Deprecated/outdated:**
- ldapjs: Formally deprecated, maintainer stepped down. Use ldapts.
- Prisma middleware ($use): Replaced by Client Extensions. Never use $use for RLS.
- nest-keycloak-connect: Not needed when not using Keycloak for MVP.
**Important version note:** The project currently uses Next.js 15.3 (not 16.x as STACK.md recommends). Next.js 15.3 uses `middleware.ts`, not `proxy.ts`. All frontend auth patterns should use `middleware.ts`. If the project upgrades to Next.js 16+ during this phase, the file would be renamed to `proxy.ts`. [VERIFIED: apps/web/package.json shows next@^15.3.0]
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Keycloak NOT recommended for MVP; custom auth instead | Summary, Alternatives | If user wants Keycloak, entire auth architecture changes; +750MB RAM, different token flow |
| A2 | argon2 over bcrypt for password hashing | Standard Stack | Low risk -- bcrypt is equally acceptable; argon2 may need system deps (gcc, make) in Docker |
| A3 | SMTP configuration via ENV variables (not UI-configurable) | Code Examples | If user expects UI-based SMTP config, need additional settings page |
| A4 | Session cookie shared between Next.js and NestJS (same domain via Traefik) | Architecture | If deployed on separate domains, cookie sharing breaks; CORS complexity increases |
| A5 | Super-Admin uses separate BYPASSRLS DB role | Pitfall 3 | Alternative: Super-Admin iterates tenants explicitly; both approaches valid |
| A6 | User.passwordHash nullable for LDAP-only users | Code Examples | LDAP users who later need local password would need migration |
## Open Questions
1. **Next.js version: 15.3 vs 16.x**
- What we know: package.json pins next@^15.3.0; STACK.md recommends 16.2.x
- What's unclear: Whether to upgrade to 16.x in this phase or keep 15.3
- Recommendation: Stay on 15.3 for this phase (use `middleware.ts`). Upgrade to 16 can happen in a dedicated tech-debt phase.
2. **Super-Admin DB access pattern**
- What we know: Super-Admin must see all tenants' data; RLS blocks cross-tenant access by default
- What's unclear: Whether to use a BYPASSRLS database role or iterate tenants explicitly
- Recommendation: Use a separate database role with BYPASSRLS for Super-Admin operations, with strict separation from the tenant-scoped connection pool.
3. **Redis for session invalidation**
- What we know: JWT cookies are stateless (cannot be revoked server-side without extra state)
- What's unclear: Whether to add Redis for session revocation (e.g., logout-everywhere, deactivation)
- Recommendation: For MVP, check `user.isActive` on every API request (DB query with short cache TTL). Add Redis session store in a later phase if needed.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | API + Frontend | Yes | 24.16.0 | -- |
| Docker | Container orchestration | Yes | 29.5.3 | -- |
| Docker Compose | Service orchestration | Yes | v5.1.4 | -- |
| PostgreSQL | Database (via Docker) | Yes (container) | 16-alpine | -- |
| Redis | Session cache (optional) | No | -- | Skip; use DB-based isActive checks |
| SMTP server | Password reset emails | Not verified | -- | Use MailHog/Mailpit in Docker for dev; real SMTP via ENV in prod |
| LDAP server | LDAP sync testing | Not verified | -- | Use Docker OpenLDAP for testing |
**Missing dependencies with no fallback:** None -- all critical dependencies available.
**Missing dependencies with fallback:**
- Redis: Not available but not required for MVP. Use DB-based checks.
- SMTP: Need to add MailHog/Mailpit container to docker-compose.dev.yml for development testing.
- LDAP: Need to add test LDAP container (e.g., osixia/openldap) to docker-compose.dev.yml for LDAP sync testing.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Vitest 3.x (recommended by STACK.md, not yet installed) |
| Config file | none -- see Wave 0 |
| Quick run command | `pnpm turbo test --filter=@tessera/api` |
| Full suite command | `pnpm turbo test` |
### Phase Requirements to Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| AUTH-01 | Admin seed from ENV vars creates Super-Admin | integration | `vitest run tests/auth/admin-seed.test.ts` | No -- Wave 0 |
| AUTH-02 | User CRUD (create, update, delete) | integration | `vitest run tests/user/user-crud.test.ts` | No -- Wave 0 |
| AUTH-03 | Login returns session cookie; logout clears it | integration | `vitest run tests/auth/login-logout.test.ts` | No -- Wave 0 |
| AUTH-04 | Session cookie persists; middleware validates | unit | `vitest run tests/auth/session.test.ts` | No -- Wave 0 |
| AUTH-05 | Role guard blocks unauthorized access | unit | `vitest run tests/auth/role-guard.test.ts` | No -- Wave 0 |
| AUTH-06 | LDAP sync creates/deactivates users | integration | `vitest run tests/ldap/ldap-sync.test.ts` | No -- Wave 0 |
| TNNT-01 | RLS blocks cross-tenant data access | integration | `vitest run tests/tenant/rls-isolation.test.ts` | No -- Wave 0 |
| TNNT-02 | Tenant CRUD (create, update) | integration | `vitest run tests/tenant/tenant-crud.test.ts` | No -- Wave 0 |
| TNNT-03 | Tenant middleware sets context from JWT | unit | `vitest run tests/tenant/tenant-middleware.test.ts` | No -- Wave 0 |
### Sampling Rate
- **Per task commit:** `pnpm turbo test --filter=@tessera/api`
- **Per wave merge:** `pnpm turbo test`
- **Phase gate:** Full suite green before `/gsd-verify-work`
### Wave 0 Gaps
- [ ] `apps/api/vitest.config.ts` -- Vitest config for NestJS API
- [ ] `apps/api/tests/setup.ts` -- Test setup with Prisma test client and test database
- [ ] `apps/web/vitest.config.ts` -- Vitest config for Next.js frontend
- [ ] Framework install: `pnpm add -D vitest @vitest/coverage-v8 --filter=@tessera/api --filter=@tessera/web`
- [ ] Test database: Docker Compose test service or in-memory SQLite for unit tests
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | Yes | argon2 password hashing, local strategy + JWT, account lockout |
| V3 Session Management | Yes | httpOnly cookies, 30-day expiry, SameSite=lax, secure flag in production |
| V4 Access Control | Yes | NestJS RoleGuard (SUPER_ADMIN / ADMIN / USER), PostgreSQL RLS |
| V5 Input Validation | Yes | class-validator (API DTOs), zod (frontend forms) |
| V6 Cryptography | Yes | argon2 (hashing), jose/HS256 (JWT signing) -- never hand-roll |
### Known Threat Patterns for NestJS + PostgreSQL + JWT
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| SQL injection via RLS bypass | Tampering | Parameterized queries via Prisma; never use string interpolation in set_config |
| JWT token theft via XSS | Information Disclosure | httpOnly cookies prevent JS access; Content-Security-Policy headers |
| Cross-tenant data leakage | Information Disclosure | PostgreSQL RLS as defense-in-depth; never rely on application-level filtering alone |
| Brute-force login | Elevation of Privilege | Rate limiting on login endpoint (NestJS throttler); account lockout after N attempts |
| LDAP injection | Tampering | Parameterize LDAP search filters; validate input before constructing filter strings |
| Password reset token reuse | Repudiation | Single-use tokens with expiry; mark as used after first use; short TTL (1 hour) |
| Credential exposure in LDAP bind | Information Disclosure | Use LDAP for sync only (not auth); bind with service account, not user credentials |
| Session fixation | Spoofing | Issue new JWT on login (never reuse); clear old cookies on authentication |
## Sources
### Primary (HIGH confidence)
- [NestJS Authentication Docs](https://docs.nestjs.com/security/authentication) -- Guards, strategies, JWT setup
- [NestJS Encryption & Hashing Docs](https://docs.nestjs.com/security/encryption-and-hashing) -- bcrypt/argon2 recommendation
- [Next.js Authentication Guide](https://nextjs.org/docs/app/guides/authentication) -- Middleware, sessions, DAL pattern
- [Prisma Client Extensions RLS Example](https://github.com/prisma/prisma-client-extensions/tree/main/row-level-security) -- Official RLS multi-tenancy pattern
- [AWS: Multi-tenant data isolation with PostgreSQL RLS](https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/) -- RLS policy patterns
### Secondary (MEDIUM confidence)
- [passport-ldapauth on Passport.js](https://www.passportjs.org/packages/passport-ldapauth/) -- LDAP strategy reference
- [Keycloak Memory Sizing](https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing) -- 750MB-2GB overhead data
- [nest-keycloak-connect npm](https://www.npmjs.com/package/nest-keycloak-connect) -- NestJS Keycloak integration patterns
- [ldapts npm](https://www.npmjs.com/package/ldapts) -- LDAP client replacing deprecated ldapjs
- [LDAPjs Decommissioned](https://socket.dev/blog/ldapjs-open-source-project-decommissioned-after-maintainer-receives-abusive-email) -- Deprecation context
### Tertiary (LOW confidence)
- [NestJS + Prisma + RLS Guide](https://js.elitedev.in/js/how-to-build-multi-tenant-saas-with-nestjs-prisma-and-postgresql-row-level-security/) -- Community guide (patterns verified against Prisma official example)
- [LDAP Authentication Anti-Pattern](https://blog.lithnet.io/2018/03/the-ldap-authentication-anti-pattern.html) -- Why not to use LDAP bind for auth
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH -- all packages verified on npm registry with high download counts
- Architecture: HIGH -- patterns sourced from official NestJS/Next.js/Prisma docs and confirmed against codebase
- Pitfalls: HIGH -- documented in research PITFALLS.md and confirmed against community guides
- Keycloak decision: MEDIUM -- recommendation to skip Keycloak is reasoned but user has discretion
**Research date:** 2026-06-18
**Valid until:** 2026-07-18 (stable domain, 30 days)