File

src/ai/ai-enabled.guard.ts

Description

Server-side enforcement of the org-level "Intelligence features" toggle.

The frontend writes organization.settings.aiEnabled from the Intelligence Center page (Admin only). Without this guard, the toggle is purely cosmetic — the API endpoints would still serve any authenticated caller. With this guard, when an admin disables the feature for the org, every /ai/* endpoint returns 403 even to direct curl callers.

Place AFTER the auth guards in the @UseGuards() chain so request.user is populated.

Index

Methods

Constructor

constructor(prisma: PrismaService)
Parameters :
Name Type Optional
prisma PrismaService No

Methods

Async canActivate
canActivate(ctx: ExecutionContext)
Parameters :
Name Type Optional
ctx ExecutionContext No
Returns : Promise<boolean>
invalidate
invalidate(orgId: string)

Drop the cache entry for an org. Call this from the customization service when an admin flips the toggle so the new state takes effect immediately, not after the TTL expires.

Parameters :
Name Type Optional
orgId string No
Returns : void
import {
  CanActivate,
  ExecutionContext,
  ForbiddenException,
  Injectable,
  Logger,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

/**
 * Server-side enforcement of the org-level "Intelligence features" toggle.
 *
 * The frontend writes `organization.settings.aiEnabled` from the
 * Intelligence Center page (Admin only). Without this guard, the toggle
 * is purely cosmetic — the API endpoints would still serve any
 * authenticated caller. With this guard, when an admin disables the
 * feature for the org, every /ai/* endpoint returns 403 even to direct
 * curl callers.
 *
 * Place AFTER the auth guards in the @UseGuards() chain so `request.user`
 * is populated.
 */
@Injectable()
export class AiEnabledGuard implements CanActivate {
  private readonly logger = new Logger(AiEnabledGuard.name);

  // Tiny in-process cache so we don't round-trip to Postgres on every
  // single Intelligence Center request. Org settings rarely change.
  private readonly cache = new Map<string, { enabled: boolean; expiresAt: number }>();
  private readonly TTL_MS = 30_000;

  constructor(private readonly prisma: PrismaService) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const orgId: string | undefined = req.user?.organizationId;
    if (!orgId) {
      // No org context = the upstream auth guard didn't run or the
      // caller isn't authenticated. Let the existing 401/403 paths
      // handle it instead of leaking a confusing message.
      throw new ForbiddenException('Authentication required');
    }

    const cached = this.cache.get(orgId);
    let enabled: boolean;
    if (cached && cached.expiresAt > Date.now()) {
      enabled = cached.enabled;
    } else {
      const org = await this.prisma.organization.findUnique({
        where: { id: orgId },
        select: { settings: true },
      });
      const settings = (org?.settings as Record<string, unknown> | null) || {};
      // Default to enabled — only an explicit `false` blocks access.
      // This matches the frontend behaviour where the toggle starts on.
      enabled = settings.aiEnabled !== false;
      this.cache.set(orgId, { enabled, expiresAt: Date.now() + this.TTL_MS });
    }

    if (!enabled) {
      this.logger.warn(`AI request blocked: org ${orgId} has Intelligence features disabled`);
      throw new ForbiddenException(
        'Intelligence features are disabled for this organization. Contact your administrator.',
      );
    }
    return true;
  }

  /**
   * Drop the cache entry for an org. Call this from the customization
   * service when an admin flips the toggle so the new state takes
   * effect immediately, not after the TTL expires.
   */
  invalidate(orgId: string) {
    this.cache.delete(orgId);
  }
}

results matching ""

    No results matching ""