Files
tower/docs/superpowers/plans/2026-06-02-phase1-tenants-auth-audit.md
2026-06-09 02:02:40 +05:30

14 KiB

Phase 1 — Tenants, JWT Auth, Audit Log

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Introduce multi-tenancy and JWT-based admin authentication across the entire TOWER stack. Every existing model, endpoint, and queue payload is retrofitted to be tenant-scoped. An AuditEvent log captures every privileged action. The web app gains a /login page and a bearer-token cookie session.

Architecture: All Prisma models gain a non-nullable tenantId foreign key. The API mounts a global JwtAuthGuard that decodes the bearer token, populates request.tenantContext, and rejects unauthenticated requests (with @Public() opt-out for /health). The web app stores the JWT in an HTTP-only tower_token cookie and forwards it on the server side via an apiFetch helper. New Auth and Audit modules live under apps/api/src/modules/.

Tech Stack: NestJS 11, Passport JWT, bcryptjs (pure JS, no native binding), Prisma 6, Next.js 16, React 19, Jest 29, TypeScript 5.7.


File Map

Created

apps/api/prisma/seed.ts                                                        # default tenant + OWNER admin
apps/api/prisma/migrations/20260602070712_phase1_tenants_auth_audit/migration.sql  # hand-edited for backfill
apps/api/src/common/tenant-context.ts
apps/api/src/modules/auth/password.util.ts                                    # bcryptjs hash/verify
apps/api/src/modules/auth/password.util.spec.ts
apps/api/src/modules/auth/public.decorator.ts                                # @Public()
apps/api/src/modules/auth/current-admin.decorator.ts                          # @CurrentAdmin()
apps/api/src/modules/auth/current-tenant.decorator.ts                        # @CurrentTenantContext()
apps/api/src/modules/auth/roles.decorator.ts                                  # @Roles(...)
apps/api/src/modules/auth/jwt.strategy.ts
apps/api/src/modules/auth/jwt-auth.guard.ts
apps/api/src/modules/auth/roles.guard.ts
apps/api/src/modules/auth/auth.service.ts
apps/api/src/modules/auth/auth.service.spec.ts
apps/api/src/modules/auth/auth.controller.ts
apps/api/src/modules/auth/auth.module.ts
apps/api/src/modules/auth/dto/login.dto.ts
apps/api/src/modules/audit/audit.types.ts                                     # AuditAction constants
apps/api/src/modules/audit/audit.service.ts
apps/api/src/modules/audit/audit.service.spec.ts
apps/api/src/modules/audit/audit.module.ts                                    # @Global()
packages/types/src/auth.ts                                                    # AdminRole, JwtPayload, LoginRequest/Response
apps/web/app/_lib/api.ts                                                      # server-side apiFetch with bearer
apps/web/app/_lib/auth-context.tsx                                            # client AuthProvider
apps/web/app/_lib/auth-context.test.tsx
apps/web/app/_lib/sidebar.tsx                                                 # auth-aware nav + Sign out
apps/web/app/login/page.tsx
apps/web/app/login/page.test.tsx
apps/web/app/api/auth/login/route.ts
apps/web/app/api/auth/logout/route.ts
apps/web/app/api/auth/me/route.ts

Modified

apps/api/prisma/schema.prisma                                                 # Tenant, Admin, AuditEvent, tenantId on 6 models
apps/api/package.json                                                         # bcryptjs, passport-jwt, @nestjs/jwt/passport, db:seed
apps/api/src/main.ts                                                          # global ValidationPipe + global JwtAuthGuard
apps/api/src/app.module.ts                                                    # imports AuthModule, AuditModule
apps/api/src/modules/health/health.controller.ts                              # @Public()
apps/api/src/modules/groups/{controller,service,*.spec}.ts                     # tenant filter
apps/api/src/modules/routes/{controller,service,*.spec}.ts                     # tenant filter + audit
apps/api/src/modules/accounts/{controller,service,*.spec,module}.ts            # tenant filter + audit + @Roles on create
apps/api/src/modules/search/{controller,service,*.spec}.ts                    # tenant filter
apps/api/src/prisma/prisma.service.spec.ts                                    # backfill tenant on test data
packages/types/src/message.ts                                                 # tenantId on IngestJobData/ForwardJobData/IndexJobData
packages/types/src/index.ts                                                   # re-export ./auth
packages/config/src/index.ts                                                  # BCRYPT_ROUNDS, JWT_EXPIRES_IN
packages/search/src/index.ts                                                  # MeiliDocument.tenantId; configureIndex adds filterable
packages/search/src/index.test.ts                                             # tenantId in fixture + filterable assertion
apps/worker/src/main.ts                                                       # tenantId on startAccount + ingest job
apps/worker/src/queues/ingest.processor.ts + .test.ts                         # writes tenantId to Message
apps/worker/src/queues/index.processor.ts + .test.ts                          # writes tenantId to MeiliDocument
apps/worker/src/queues/forward.processor.test.ts                              # tenantId in baseJob
apps/worker/src/core/approval.ts                                              # propagates tenantId to Approval/forwardJobs/indexDoc
apps/worker/src/whatsapp/group-sync.ts                                        # resolves tenantId from Account
apps/web/app/api/accounts/route.ts                                            # uses apiFetch
apps/web/app/api/accounts/[id]/qr/route.ts                                    # uses apiFetch
apps/web/app/api/routes/route.ts                                              # uses apiFetch
apps/web/app/api/routes/[id]/route.ts                                         # uses apiFetch
apps/web/app/layout.tsx                                                       # AuthProvider + Sidebar
.env.example                                                                  # BCRYPT_ROUNDS, JWT_EXPIRES_IN, SEED_ADMIN_*

Tasks

Task 1: Add Tenant model + tenantId on every existing model

  • Add Tenant { id, slug (unique), name, createdAt, updatedAt } to schema
  • Add non-nullable tenantId FK on Group, Message, Approval, SyncRoute, ConsentRecord, Account
  • Hand-edit the auto-generated migration to: create Tenant, INSERT default tenant, ADD COLUMN nullable, UPDATE backfill, SET NOT NULL, add FK
  • Apply migration; verify existing rows have tenantId = '<default tenant id>'

Task 2: Add Admin model + password util

  • Admin { id, tenantId, email (unique per tenant), name, role: AdminRole, passwordHash, createdAt, updatedAt } + AdminRole { OWNER, ADMIN, VIEWER } enum
  • password.util.ts exporting hashPassword(plain, rounds) and verifyPassword(plain, hash) using bcryptjs
  • password.util.spec.ts (mocks bcryptjs)

Task 3: Add AuditEvent model + AuditService

  • AuditEvent { id, tenantId, actorType: ActorType, actorId?, action, resourceType, resourceId, payload (Json), traceId?, createdAt } + ActorType { ADMIN, SYSTEM, WORKER }
  • audit.types.ts exporting AuditAction constants (AUTH_LOGIN, AUTH_LOGOUT, ROUTE_CREATED, ROUTE_DELETED, ACCOUNT_CREATED, etc.)
  • AuditService with .log({ action, resourceType, resourceId, actorType?, actorId?, payload?, traceId?, tenantId? })
    • Throws if no tenantId can be resolved (from input override or injected TenantContext)
  • AuditModule registered as @Global()

Task 4: TenantContext type

  • apps/api/src/common/tenant-context.ts exporting interface TenantContext { tenantId: string; adminId: string; role: AdminRole }

Task 5: Auth guards, strategy, decorators

  • JwtStrategy (passport-jwt) — validate() returns { sub, email, tenantId, role }
  • JwtAuthGuard extends AuthGuard('jwt') — checks @Public() via Reflector; handleRequest populates request.tenantContext
  • RolesGuard reads @Roles(...) metadata; throws ForbiddenException on mismatch
  • @Public(), @CurrentAdmin(), @CurrentTenantContext(), @Roles(... @Input) decorators

Task 6: AuthModule + endpoints

  • AuthService.login(email, password) — finds Admin, verifies hash, signs JWT, returns { token, admin }, writes AUTH_LOGIN audit
  • AuthService.me(adminId) — returns admin profile (with tenant)
  • AuthService.logout(adminId) — writes AUTH_LOGOUT audit
  • AuthController: POST /auth/login (public), POST /auth/logout (auth), GET /auth/me (auth)
  • LoginDto with class-validator (@IsEmail, @IsString, @MinLength)
  • auth.service.spec.ts

Task 7: Wire global guards in main.ts

  • app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }))
  • app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))
  • app.useGlobalGuards(new JwtAuthGuard(app.get(Reflector)))
  • Mark HealthController with @Public()

Task 8: Retrofit existing controllers/services

  • Groups: read @CurrentTenantContext(), filter by tenantId
  • Routes: read @CurrentTenantContext(), filter by tenantId, write ROUTE_CREATED / ROUTE_DELETED audit
  • Accounts: read @CurrentTenantContext(), filter by tenantId, write ACCOUNT_CREATED audit, gate POST with @Roles('OWNER', 'ADMIN')
  • Search: ALWAYS filter Meilisearch query by tenantId (non-negotiable)
  • Updated spec files for all four

Task 9: Seed script

  • apps/api/prisma/seed.ts upserts 'default' tenant and an OWNER admin from SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD env vars
  • db:seed script in apps/api/package.json
  • SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD in .env.example

Task 10: MeiliDocument tenantId

  • MeiliDocument.tenantId: string
  • configureIndex adds tenantId to filterableAttributes
  • index.test.ts asserts tenantId in filterable list

Task 11: Tenant propagation through the worker

  • IngestJobData.tenantId, ForwardJobData.tenantId, IndexJobData.tenantId
  • index.processor.ts writes tenantId to MeiliDocument
  • ingest.processor.ts writes tenantId to Message
  • group-sync.ts resolves tenantId from the Account record before upserting groups
  • approval.ts propagates tenantId to Approval, forwardJobs, and indexDoc
  • worker/main.ts includes tenantId in startAccount and in the ingest job data

Task 12: Web login page + AuthProvider

  • apps/web/app/_lib/auth-context.tsxAuthProvider + useAuth() exposing { admin, loading, error, refresh, logout }
  • apps/web/app/login/page.tsx — form posting to /api/auth/login, redirects to ?next=… on success
    • Split into LoginForm wrapped in <Suspense> (required by useSearchParams in static gen)
  • auth-context.test.tsx (3 tests) + login/page.test.tsx (3 tests)

Task 13: Web auth API routes + apiFetch

  • apps/web/app/_lib/api.tsapiFetch(path, init) reads tower_token cookie, sets Authorization: Bearer …, forwards to API_URL
  • apps/web/app/api/auth/login/route.ts — POST credentials to API, on success sets HTTP-only tower_token cookie
  • apps/web/app/api/auth/logout/route.ts — POST clears cookie
  • apps/web/app/api/auth/me/route.ts — GET forwards to API
  • Retrofit existing route handlers (/api/accounts, /api/accounts/[id]/qr, /api/routes, /api/routes/[id]) to use apiFetch

Task 14: Config + env

  • BCRYPT_ROUNDS (default 10), JWT_EXPIRES_IN (default 7d) in @tower/config
  • SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD in .env.example

Task 15: Verification

  • pnpm turbo build — green across all 15 packages
  • pnpm turbo test — 13 API suites (58 tests) + 10 worker suites (68 tests) + 7 web suites (34 tests) all green
  • Seed ran: admin@tower.local / OWNER on default tenant
  • Migration backfilled 99 groups, 3 messages, 1 approval, 1 sync route, 1 account to default tenant
  • Sidebar shows admin name, tenant, role, and Sign out button; redirects to /login?next=… when unauthenticated
  • This plan doc written to docs/superpowers/plans/2026-06-02-phase1-tenants-auth-audit.md

Notes / Gotchas

  • bcrypt 5.x native binding fails to load in the pnpm environment. Switched to bcryptjs (pure JS, identical API).
  • Prisma migration backfill: Prisma's default migration can't add a non-nullable FK to a table that already has rows. Hand-edited the generated migration.sql to: create Tenant → INSERT default → ADD COLUMN nullable → UPDATE backfill → SET NOT NULL → add FK.
  • JWT_EXPIRES_IN typing: string is not assignable to @nestjs/jwt's strict StringValue template literal type. Cast at the call site: process.env['JWT_EXPIRES_IN'] as \${number}d` | ...`.
  • passport-jwt user cast: TypeScript needs user as unknown as JwtPayload in JwtAuthGuard.handleRequest because the generic TUser doesn't structurally overlap with JwtPayload.
  • bcryptjs mock: jest.mock('bcryptjs') alone doesn't expose jest.fn()s. Use a factory: jest.mock('bcryptjs', () => ({ __esModule: true, hash: jest.fn(), compare: jest.fn() })).
  • useSearchParams static gen: requires a <Suspense> boundary around the component that uses it. Split LoginPage into LoginPage (shell) and LoginForm (inner, wrapped in Suspense).
  • Worker is uncommitted-modification territory: selfJid handling in normalizer.ts was changed recently. The retrofit was careful not to touch that file or behavior.