Skip to main content

Security & Authentication

Authentication, authorization, and security infrastructure for the AIQLick platform.

For the code-reviewed role matrix, company membership rules, sharing permissions, and AI billing gates, see Roles & Permissions.

JWT Authentication

All authenticated GraphQL operations pass through JwtAuthGuard:

  1. Extract token from Authorization: Bearer <token> header
  2. Verify signature against JWT_SECRET
  3. Decode payload (sub: userId, email)
  4. Load full user from DB (includes selectedCompanyId, isSuperAdmin, company roles)
  5. Attach merged payload + user to req.user

Token Structure

FieldTypeDescription
substring (UUID)User ID
emailstringUser email
iatnumberIssued-at timestamp
expnumberExpiration timestamp

Google OAuth

Google OAuth uses passport-google-oauth20 with email and profile scopes:

GET /auth/google  -->  Google Consent Screen  -->  GET /auth/google/callback
--> GoogleStrategy.validate() extracts profile
--> authService.validateGoogleUser() creates or updates user
--> authService.generateJwtToken() signs JWT
--> Redirect to FRONTEND_URL/auth/callback?token=<jwt>&user=<json>

Guard Hierarchy

Guards execute left-to-right in the @UseGuards() array. The standard chain is:

OrderGuardDecoratorPurpose
1JwtAuthGuard--Verify JWT, load user, attach to req.user
2RoleGuard@HasRole(...)Check user has required role for company (OR logic)
2PermissionGuard@HasPermission(...)Check user has required permission (OR logic)
2SuperAdminGuard@RequireSuperAdmin()Check user.isSuperAdmin === true
2CompanyWriteGuard--Block viewer-tier roles from any state-changing mutation on a company-scoped resource (see CompanyWriteGuard)
3PlanLimitGuard@CheckPlanLimit(...)Check company plan allows this action
info

Guards at the same order number are alternatives -- use one per resolver method, not all at once.

Common Guard Patterns

// Auth only
@UseGuards(JwtAuthGuard)

// Auth + role check (class-level JwtAuthGuard assumed)
@UseGuards(RoleGuard)
@HasRole('Company Admin', 'Super Admin')

// Auth + role + plan limit
@UseGuards(RoleGuard, PlanLimitGuard)
@HasRole('Company Admin', 'Super Admin', 'Internal Sales')
@CheckPlanLimit('maxUsersPerCompany')

// Super admin only
@UseGuards(JwtAuthGuard, SuperAdminGuard)
@RequireSuperAdmin()

RBAC Summary

The RBAC model is documented in detail on Roles & Permissions. Keep this page focused on authentication and security boundaries; do not duplicate the role matrix here.

Current model:

  • company membership lives in UserCompanyRole
  • global support assignments live in UserRole
  • platform super-admin access is User.isSuperAdmin
  • production and development RDS have 8 roles, 39 permissions, and matching _RolePermissions joins
  • runtime authorization mostly uses role-name checks, tier helpers, resource-specific services, share permissions, and billing gates
  • @HasPermission(...) and PermissionGuard exist, but application resolvers do not currently use them

Plan Limits

Plan limits restrict resource creation based on subscription tier. Values: -1 (unlimited), 0 (disabled), >0 (specific limit).

TypeFieldsReset
AbsolutemaxJobs, maxTalentPools, maxContacts, maxPipelines, maxCvCollections, maxSharedTalentLists, maxCollaborationInvites, maxUsersPerCompanyNever
MonthlymaxInterviewsPerMonth, maxMeetingsPerMonth, maxConnectionRequestsPerMonth, maxNewsPerMonth, maxCvParsesPerMonth, maxAgentMessagesPerMonth1st of month UTC

When exceeded, the guard returns a PLAN_LIMIT_EXCEEDED error (HTTP 403) with current usage and limit details.

AI-related plan limits are prechecked by the backend AI gateway for operations that declare planLimitKey, and are also enforced by the background-tasks BillingGate during execution. See Roles & Permissions and AI Gateway.

Resource Access Gates

Beyond the JWT/RBAC layer, individual resources sometimes need their own runtime authorization gate based on domain state rather than just identity. The canonical example is the interview Jitsi room: once a candidate withdraws or is rejected, the room must become inaccessible to both the candidate and the recruiter — even if the candidate is still listed as a MeetingAttendee.

Pattern: Single Source of Truth + Narrow Boundary

  1. One module owns the domain rule. src/talent-management/candidate/candidate-status.constants.ts is the single source of truth for "active vs inactive candidate" — see Candidate Management → Active vs Inactive Candidates. Every guard, filter, and cascade imports from this module instead of inlining === WITHDRAWN || === REJECTED checks.
  2. Gate at the actual security boundary, not earlier. For interviews, the boundary is the function that issues a Jitsi join link (MeetingService.getAttendeeLink), not the metadata read (getMeeting). Gating metadata reads would block recruiters from reviewing notes/transcripts of cancelled interviews — a legitimate use case.
  3. Belt-and-suspenders list filters. Every "list meetings" query also excludes inactive-candidate rows so stale UI rows disappear even if the cascade listener has not yet run. Both layers exist independently so a single point of failure cannot expose the room.

Worked Example: Interview Jitsi Room

This gate fires for both candidate and recruiter requests — neither side should be joining a cancelled interview's room. Recruiters who need to review what was discussed can still call getMeeting (metadata) and transcriptText directly.

JWT-Free Storage

The database stores only a generic room URL (e.g. https://book.aiqlick.com/aiqlick-interview-abc-xyz-123) — no JWT is embedded. User-specific JWTs are minted per-participant at the moment they call getMyMeetingLink, not at booking time. This prevents the old vulnerability (SUP-00027) where sharing a stored meetingUrl would expose the interviewer's name and email to the recipient via the embedded JWT. Email confirmations also generate per-attendee JWTs so each recipient sees their own identity in Jitsi.

When to Add a New Resource Gate

Use this pattern when:

  • The access decision depends on domain state that can change after the resource is created.
  • A simple JWT/RBAC check is too coarse — the user might still be a "valid attendee" in the literal sense but should no longer have access.
  • Gating at a list/feed level isn't enough because the row might still be served from a stale cache.

Do NOT use this pattern when a @CheckPlanLimit decorator, @RequireSuperAdmin, or company-scoped @HasRole already covers the case.

External Sharing Tokens

External sharing provides unauthenticated access to specific resources via secure, time-limited tokens.

  • Generation: Two crypto.randomUUID() values concatenated (~68 chars, 256 bits of entropy)
  • Default expiry: 168 hours (7 days)
  • Validation: isActive === true AND expiresAt is in the future
  • Access tracking: Each access increments accessCount (non-blocking, errors swallowed)
  • Cleanup: cleanupExpiredShares() deactivates expired tokens across all share tables

Shareable entities: TalentPool, UserProfile, Talent, SharedTalentList, Pipeline, CvCollection.

Stripe Webhook Security

The webhook endpoint (POST /stripe/webhook) implements three layers:

Layer 1 -- Signature verification: Raw request body verified against STRIPE_WEBHOOK_SECRET using stripe.webhooks.constructEvent(). Requires rawBody: true in NestJS bootstrap.

Layer 2 -- Idempotency: StripeEvent table with stripeEventId @unique. Duplicate events return HTTP 200 immediately. Concurrent duplicates handled via Prisma P2002 catch.

Layer 3 -- Error strategy: Critical events (invoice.paid, invoice.payment_failed) rethrow errors for Stripe retry. Non-critical events (trial_will_end, invoice.upcoming) swallow errors and return 200.

Sensitive Field Redaction

The LoggingInterceptor automatically redacts these fields from logged request variables (case-insensitive, recursive):

password, token, secret, apiKey, accessToken, refreshToken, authorization, creditCard, cvv, ssn

Distributed Tracing

CorrelationIdMiddleware injects a correlation ID into every request:

  1. Read x-correlation-id or x-request-id header
  2. Fall back to generated UUID v4
  3. Attach to req.correlationId and x-correlation-id response header

This enables tracing a single request across backend, background-tasks, and other services.