From a9c314b5c418ac368d02313e2f4e8dca8d38c08d Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 2 Feb 2026 12:19:04 -0500 Subject: [PATCH 1/8] feat(rbac): extend better-auth permissions with GRC resources - Update permissions.ts to extend defaultStatements from better-auth - Add GRC resources: control, evidence, policy, risk, vendor, task, framework, audit, finding, questionnaire, integration - Add program_manager role with full GRC access but no member management - Update owner/admin roles to extend ownerAc/adminAc from better-auth - Update auditor role with read + export permissions - Keep employee/contractor roles minimal with assignment-based access - Add ROLE_HIERARCHY, RESTRICTED_ROLES, PRIVILEGED_ROLES exports - Add placeholder for dynamicAccessControl in auth.ts (Sprint 2) Part of ENG-138: Complete Permission System Co-Authored-By: Claude Opus 4.5 --- apps/app/src/utils/auth.ts | 6 + apps/app/src/utils/permissions.ts | 188 ++++++++++++++++++++++++++---- 2 files changed, 170 insertions(+), 24 deletions(-) diff --git a/apps/app/src/utils/auth.ts b/apps/app/src/utils/auth.ts index 8d52e71c4..a813d9975 100644 --- a/apps/app/src/utils/auth.ts +++ b/apps/app/src/utils/auth.ts @@ -152,6 +152,12 @@ export const auth = betterAuth({ }, ac, roles: allRoles, + // Enable dynamic access control for custom roles (Sprint 2) + // This allows creating organization-specific roles at runtime + // dynamicAccessControl: { + // enabled: true, + // maximumRolesPerOrganization: 20, + // }, schema: { organization: { modelName: 'Organization', diff --git a/apps/app/src/utils/permissions.ts b/apps/app/src/utils/permissions.ts index e9ef88bf3..66c7b7730 100644 --- a/apps/app/src/utils/permissions.ts +++ b/apps/app/src/utils/permissions.ts @@ -1,63 +1,203 @@ import { createAccessControl } from 'better-auth/plugins/access'; +import { + defaultStatements, + adminAc, + ownerAc, +} from 'better-auth/plugins/organization/access'; +/** + * Permission statement extending better-auth's defaults with GRC resources. + * + * Default resources from better-auth: + * - organization: ['update', 'delete'] + * - member: ['create', 'update', 'delete'] + * - invitation: ['create', 'cancel'] + * - team: ['create', 'update', 'delete'] + * - ac: ['create', 'read', 'update', 'delete'] (for role management) + */ const statement = { - app: ['create', 'update', 'delete', 'read'], - member: ['create', 'update'], - invitation: ['create', 'cancel'], + ...defaultStatements, + // Override organization to add 'read' action + organization: ['read', 'update', 'delete'], + // GRC Resources + control: ['create', 'read', 'update', 'delete', 'assign', 'export'], + evidence: ['create', 'read', 'update', 'delete', 'upload', 'export'], + policy: ['create', 'read', 'update', 'delete', 'publish', 'approve'], + risk: ['create', 'read', 'update', 'delete', 'assess', 'export'], + vendor: ['create', 'read', 'update', 'delete', 'assess'], + task: ['create', 'read', 'update', 'delete', 'assign', 'complete'], + framework: ['create', 'read', 'update', 'delete'], + audit: ['create', 'read', 'update', 'export'], + finding: ['create', 'read', 'update', 'delete'], + questionnaire: ['create', 'read', 'update', 'delete', 'respond'], + integration: ['create', 'read', 'update', 'delete'], + // Legacy resources (for backwards compatibility) + app: ['create', 'read', 'update', 'delete'], portal: ['read', 'update'], - organization: ['update', 'delete', 'read'], } as const; export const ac = createAccessControl(statement); /** - * Owner role with full permissions to manage all resources - * Has complete control over apps, organizations, members, invitations, and portal + * Owner role - Full access to everything + * Extends better-auth's ownerAc with GRC permissions */ export const owner = ac.newRole({ - app: ['create', 'update', 'delete', 'read'], - organization: ['update', 'delete'], - member: ['create', 'update'], - invitation: ['create', 'cancel'], + ...ownerAc.statements, + organization: ['read', 'update', 'delete'], + // Full GRC access + control: ['create', 'read', 'update', 'delete', 'assign', 'export'], + evidence: ['create', 'read', 'update', 'delete', 'upload', 'export'], + policy: ['create', 'read', 'update', 'delete', 'publish', 'approve'], + risk: ['create', 'read', 'update', 'delete', 'assess', 'export'], + vendor: ['create', 'read', 'update', 'delete', 'assess'], + task: ['create', 'read', 'update', 'delete', 'assign', 'complete'], + framework: ['create', 'read', 'update', 'delete'], + audit: ['create', 'read', 'update', 'export'], + finding: ['create', 'read', 'update', 'delete'], + questionnaire: ['create', 'read', 'update', 'delete', 'respond'], + integration: ['create', 'read', 'update', 'delete'], + // Legacy + app: ['create', 'read', 'update', 'delete'], portal: ['read', 'update'], }); /** - * Admin role with permissions to manage most resources - * Can manage apps, portal settings, members and invitations, but has limited organization access + * Admin role - Full access except organization deletion + * Extends better-auth's adminAc with GRC permissions */ export const admin = ac.newRole({ - app: ['create', 'update', 'delete', 'read'], + ...adminAc.statements, + organization: ['read', 'update'], // No delete + // Full GRC access + control: ['create', 'read', 'update', 'delete', 'assign', 'export'], + evidence: ['create', 'read', 'update', 'delete', 'upload', 'export'], + policy: ['create', 'read', 'update', 'delete', 'publish', 'approve'], + risk: ['create', 'read', 'update', 'delete', 'assess', 'export'], + vendor: ['create', 'read', 'update', 'delete', 'assess'], + task: ['create', 'read', 'update', 'delete', 'assign', 'complete'], + framework: ['create', 'read', 'update', 'delete'], + audit: ['create', 'read', 'update', 'export'], + finding: ['create', 'read', 'update', 'delete'], + questionnaire: ['create', 'read', 'update', 'delete', 'respond'], + integration: ['create', 'read', 'update', 'delete'], + // Legacy + app: ['create', 'read', 'update', 'delete'], portal: ['read', 'update'], - member: ['create', 'update'], - invitation: ['create', 'cancel'], }); /** - * Auditor role with read-only access - * Can only view apps and organization information for compliance purposes + * Program Manager role - Full GRC access, no platform admin + * Can manage all compliance resources but not organization settings or members */ -export const auditor = ac.newRole({ +export const program_manager = ac.newRole({ + organization: ['read'], + // No member/invitation management (unlike admin) + // Full GRC access + control: ['create', 'read', 'update', 'delete', 'assign', 'export'], + evidence: ['create', 'read', 'update', 'delete', 'upload', 'export'], + policy: ['create', 'read', 'update', 'delete', 'publish', 'approve'], + risk: ['create', 'read', 'update', 'delete', 'assess', 'export'], + vendor: ['create', 'read', 'update', 'delete', 'assess'], + task: ['create', 'read', 'update', 'delete', 'assign', 'complete'], + framework: ['create', 'read', 'update', 'delete'], + audit: ['create', 'read', 'update', 'export'], + finding: ['create', 'read', 'update', 'delete'], + questionnaire: ['create', 'read', 'update', 'delete', 'respond'], + integration: ['read'], // Can view integrations but not manage + // Legacy app: ['read'], + portal: ['read', 'update'], +}); + +/** + * Auditor role - Read-only access with export capabilities + * Can view and export GRC data for compliance audits + */ +export const auditor = ac.newRole({ organization: ['read'], + member: ['create'], // Can invite other auditors invitation: ['create'], - member: ['create'], + // Read + export access to GRC resources + control: ['read', 'export'], + evidence: ['read', 'export'], + policy: ['read'], + risk: ['read', 'export'], + vendor: ['read'], + task: ['read'], + framework: ['read'], + audit: ['read', 'export'], + finding: ['create', 'read', 'update'], // Can create/update findings + questionnaire: ['read'], + integration: ['read'], + // Legacy + app: ['read'], + portal: ['read'], }); /** - * Employee role with standard operational permissions - * Can manage portal, read/update organization info, manage members and invitations, and work with apps + * Employee role - Limited access, assignment-based filtering + * Can only see tasks assigned to them and complete basic compliance activities */ export const employee = ac.newRole({ + // Assignment-filtered access (filtering handled by API layer) + task: ['read', 'complete'], + evidence: ['read', 'upload'], + policy: ['read'], + questionnaire: ['read', 'respond'], + // Legacy portal: ['read', 'update'], }); /** - * Contractor role with same permissions as employee - * Can manage portal for compliance purposes + * Contractor role - Same as employee + * External contractors with limited compliance access */ export const contractor = ac.newRole({ + // Assignment-filtered access (filtering handled by API layer) + task: ['read', 'complete'], + evidence: ['read', 'upload'], + policy: ['read'], + // Legacy portal: ['read', 'update'], }); -export const allRoles = { owner, admin, auditor, employee, contractor } as const; +/** + * All available roles for the organization plugin + */ +export const allRoles = { + owner, + admin, + program_manager, + auditor, + employee, + contractor, +} as const; + +/** + * Role hierarchy for privilege checking + * Higher index = higher privilege + */ +export const ROLE_HIERARCHY = [ + 'contractor', + 'employee', + 'auditor', + 'program_manager', + 'admin', + 'owner', +] as const; + +/** + * Roles that require assignment-based filtering + */ +export const RESTRICTED_ROLES = ['employee', 'contractor'] as const; + +/** + * Roles that have full access without assignment filtering + */ +export const PRIVILEGED_ROLES = [ + 'owner', + 'admin', + 'program_manager', + 'auditor', +] as const; From 565917ed3f15eb04de22c15738f0828d370a5277 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 2 Feb 2026 12:21:15 -0500 Subject: [PATCH 2/8] feat(rbac): add PermissionGuard and @RequirePermission decorator - Create PermissionGuard that calls better-auth's hasPermission API - Add fallback role-based check when better-auth is unavailable - Create @RequirePermission decorator for route-level permission checks - Create @RequirePermissions decorator for multi-resource permissions - Export GRCResource and GRCAction types for type safety - Add program_manager to Role enum in database schema - Update AuthModule to export PermissionGuard The guard: - Validates permissions via better-auth's hasPermission endpoint - Falls back to role-based check if API unavailable - Logs warnings for API key bypass (TODO: add API key scopes) - Provides static isRestrictedRole() helper for assignment filtering Part of ENG-138: Complete Permission System Co-Authored-By: Claude Opus 4.5 --- apps/api/src/auth/auth.module.ts | 17 +- apps/api/src/auth/permission.guard.ts | 204 ++++++++++++++++++ .../src/auth/require-permission.decorator.ts | 86 ++++++++ packages/db/prisma/schema/auth.prisma | 1 + 4 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/auth/permission.guard.ts create mode 100644 apps/api/src/auth/require-permission.decorator.ts diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index c687cadf4..c65035510 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -3,9 +3,22 @@ import { ApiKeyGuard } from './api-key.guard'; import { ApiKeyService } from './api-key.service'; import { HybridAuthGuard } from './hybrid-auth.guard'; import { InternalTokenGuard } from './internal-token.guard'; +import { PermissionGuard } from './permission.guard'; @Module({ - providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard], - exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard], + providers: [ + ApiKeyService, + ApiKeyGuard, + HybridAuthGuard, + InternalTokenGuard, + PermissionGuard, + ], + exports: [ + ApiKeyService, + ApiKeyGuard, + HybridAuthGuard, + InternalTokenGuard, + PermissionGuard, + ], }) export class AuthModule {} diff --git a/apps/api/src/auth/permission.guard.ts b/apps/api/src/auth/permission.guard.ts new file mode 100644 index 000000000..7520f957c --- /dev/null +++ b/apps/api/src/auth/permission.guard.ts @@ -0,0 +1,204 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; +import { AuthenticatedRequest } from './types'; + +/** + * Represents a required permission for an endpoint + */ +export interface RequiredPermission { + resource: string; + actions: string[]; +} + +/** + * Metadata key for storing required permissions on route handlers + */ +export const PERMISSIONS_KEY = 'required_permissions'; + +/** + * Roles that require assignment-based filtering for resources + */ +const RESTRICTED_ROLES = ['employee', 'contractor']; + +/** + * Roles that have full access without assignment filtering + */ +const PRIVILEGED_ROLES = ['owner', 'admin', 'program_manager', 'auditor']; + +/** + * PermissionGuard - Validates user permissions using better-auth's hasPermission API + * + * This guard: + * 1. Extracts required permissions from route metadata + * 2. Calls better-auth's hasPermission endpoint to validate + * 3. For restricted roles (employee/contractor), also checks assignment access + * + * Usage: + * ```typescript + * @UseGuards(HybridAuthGuard, PermissionGuard) + * @RequirePermission('control', 'delete') + * async deleteControl() { ... } + * ``` + */ +@Injectable() +export class PermissionGuard implements CanActivate { + private readonly logger = new Logger(PermissionGuard.name); + private readonly betterAuthUrl: string; + + constructor( + private reflector: Reflector, + private configService: ConfigService, + ) { + this.betterAuthUrl = + this.configService.get('BETTER_AUTH_URL') || + process.env.BETTER_AUTH_URL || + ''; + } + + async canActivate(context: ExecutionContext): Promise { + // Get required permissions from route metadata + const requiredPermissions = + this.reflector.getAllAndOverride(PERMISSIONS_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + // No permissions required - allow access + if (!requiredPermissions || requiredPermissions.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + + // API keys bypass permission checks for now + // TODO: Implement API key scopes for fine-grained access control + if (request.isApiKey) { + this.logger.warn( + `[PermissionGuard] API key bypassing permission check for ${requiredPermissions.map((p) => `${p.resource}:${p.actions.join(',')}`).join('; ')}`, + ); + return true; + } + + // JWT auth - validate permissions via better-auth + const permissionBody: Record = {}; + for (const perm of requiredPermissions) { + permissionBody[perm.resource] = perm.actions; + } + + try { + const hasPermission = await this.checkPermission( + request, + permissionBody, + ); + + if (!hasPermission) { + throw new ForbiddenException( + `Access denied. Required permissions: ${JSON.stringify(permissionBody)}`, + ); + } + + return true; + } catch (error) { + if (error instanceof ForbiddenException) { + throw error; + } + this.logger.error('[PermissionGuard] Error checking permissions:', error); + throw new ForbiddenException('Unable to verify permissions'); + } + } + + /** + * Check permissions via better-auth's hasPermission API + */ + private async checkPermission( + request: AuthenticatedRequest, + permissions: Record, + ): Promise { + if (!this.betterAuthUrl) { + this.logger.error( + '[PermissionGuard] BETTER_AUTH_URL not configured, falling back to role check', + ); + return this.fallbackRoleCheck(request.userRoles, permissions); + } + + try { + const response = await fetch( + `${this.betterAuthUrl}/api/auth/organization/has-permission`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: request.headers['authorization'] as string, + }, + body: JSON.stringify({ + permissions, + }), + }, + ); + + if (!response.ok) { + this.logger.warn( + `[PermissionGuard] hasPermission API returned ${response.status}`, + ); + // Fall back to role-based check if better-auth is unavailable + return this.fallbackRoleCheck(request.userRoles, permissions); + } + + const result = await response.json(); + return result.success === true || result.hasPermission === true; + } catch (error) { + this.logger.warn( + '[PermissionGuard] Failed to call hasPermission API, falling back to role check:', + error, + ); + return this.fallbackRoleCheck(request.userRoles, permissions); + } + } + + /** + * Fallback permission check using role-based logic + * Used when better-auth API is unavailable + */ + private fallbackRoleCheck( + userRoles: string[] | null, + _permissions: Record, + ): boolean { + if (!userRoles || userRoles.length === 0) { + return false; + } + + // If user has any privileged role, allow access + const hasPrivilegedRole = userRoles.some((role) => + PRIVILEGED_ROLES.includes(role), + ); + + return hasPrivilegedRole; + } + + /** + * Check if user has restricted role that requires assignment filtering + */ + static isRestrictedRole(roles: string[] | null): boolean { + if (!roles || roles.length === 0) { + return true; // No roles = restricted + } + + // If user has any privileged role, they're not restricted + const hasPrivilegedRole = roles.some((role) => + PRIVILEGED_ROLES.includes(role), + ); + if (hasPrivilegedRole) { + return false; + } + + // Check if all roles are restricted + return roles.every((role) => RESTRICTED_ROLES.includes(role)); + } +} diff --git a/apps/api/src/auth/require-permission.decorator.ts b/apps/api/src/auth/require-permission.decorator.ts new file mode 100644 index 000000000..4d9dfce75 --- /dev/null +++ b/apps/api/src/auth/require-permission.decorator.ts @@ -0,0 +1,86 @@ +import { SetMetadata } from '@nestjs/common'; +import { PERMISSIONS_KEY, RequiredPermission } from './permission.guard'; + +/** + * Decorator to require specific permissions on a controller or endpoint. + * Uses better-auth's hasPermission API under the hood via PermissionGuard. + * + * @param resource - The resource being accessed (e.g., 'control', 'policy', 'task') + * @param actions - The action(s) being performed (e.g., 'read', 'delete', ['create', 'update']) + * + * @example + * // Require single permission + * @RequirePermission('control', 'delete') + * + * @example + * // Require multiple actions on same resource + * @RequirePermission('control', ['read', 'update']) + * + * @example + * // Use with guards + * @UseGuards(HybridAuthGuard, PermissionGuard) + * @RequirePermission('policy', 'publish') + * @Post(':id/publish') + * async publishPolicy(@Param('id') id: string) { ... } + */ +export const RequirePermission = ( + resource: string, + actions: string | string[], +) => + SetMetadata(PERMISSIONS_KEY, [ + { resource, actions: Array.isArray(actions) ? actions : [actions] }, + ] as RequiredPermission[]); + +/** + * Decorator to require multiple permissions on different resources. + * All specified permissions must be satisfied for access to be granted. + * + * @param permissions - Array of permission requirements + * + * @example + * // Require permissions on multiple resources + * @RequirePermissions([ + * { resource: 'control', actions: ['read'] }, + * { resource: 'evidence', actions: ['upload'] }, + * ]) + */ +export const RequirePermissions = (permissions: RequiredPermission[]) => + SetMetadata(PERMISSIONS_KEY, permissions); + +/** + * Resource types available in the GRC permission system + */ +export type GRCResource = + | 'organization' + | 'member' + | 'invitation' + | 'control' + | 'evidence' + | 'policy' + | 'risk' + | 'vendor' + | 'task' + | 'framework' + | 'audit' + | 'finding' + | 'questionnaire' + | 'integration' + | 'app' + | 'portal'; + +/** + * Action types available for GRC resources + */ +export type GRCAction = + | 'create' + | 'read' + | 'update' + | 'delete' + | 'assign' + | 'export' + | 'upload' + | 'publish' + | 'approve' + | 'assess' + | 'complete' + | 'respond'; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 9b7cec21c..ea59b46bc 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -137,6 +137,7 @@ model Invitation { enum Role { owner admin + program_manager auditor employee contractor From 1521c9443bc3f14271e077f1c74050c5f23de858 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 2 Feb 2026 12:21:55 -0500 Subject: [PATCH 3/8] feat(rbac): sync portal permissions with app permissions - Update portal permissions.ts to match app version - Fix security issue where employee/contractor had excessive permissions - Add program_manager role to portal - Extend defaultStatements from better-auth - Add RESTRICTED_ROLES and PRIVILEGED_ROLES exports BREAKING CHANGE: Employee and contractor roles in portal now have restricted permissions matching the app. Previously they had member management and organization update permissions. Part of ENG-138: Complete Permission System Co-Authored-By: Claude Opus 4.5 --- apps/portal/src/app/lib/permissions.ts | 160 +++++++++++++++++++++---- 1 file changed, 137 insertions(+), 23 deletions(-) diff --git a/apps/portal/src/app/lib/permissions.ts b/apps/portal/src/app/lib/permissions.ts index 4110b05cf..7fdee4e6c 100644 --- a/apps/portal/src/app/lib/permissions.ts +++ b/apps/portal/src/app/lib/permissions.ts @@ -1,53 +1,167 @@ import { createAccessControl } from 'better-auth/plugins/access'; +import { + defaultStatements, + ownerAc, + adminAc, +} from 'better-auth/plugins/organization/access'; +/** + * Permission statement for the Employee Portal + * Extends better-auth's defaults with GRC resources + * + * Note: This should be kept in sync with apps/app/src/utils/permissions.ts + */ const statement = { - app: ['create', 'update', 'delete', 'read'], - member: ['create', 'update'], - invitation: ['create', 'cancel'], + ...defaultStatements, + organization: ['read', 'update', 'delete'], + // GRC Resources + control: ['create', 'read', 'update', 'delete', 'assign', 'export'], + evidence: ['create', 'read', 'update', 'delete', 'upload', 'export'], + policy: ['create', 'read', 'update', 'delete', 'publish', 'approve'], + risk: ['create', 'read', 'update', 'delete', 'assess', 'export'], + vendor: ['create', 'read', 'update', 'delete', 'assess'], + task: ['create', 'read', 'update', 'delete', 'assign', 'complete'], + framework: ['create', 'read', 'update', 'delete'], + audit: ['create', 'read', 'update', 'export'], + finding: ['create', 'read', 'update', 'delete'], + questionnaire: ['create', 'read', 'update', 'delete', 'respond'], + integration: ['create', 'read', 'update', 'delete'], + // Legacy + app: ['create', 'read', 'update', 'delete'], portal: ['read', 'update'], - organization: ['update', 'delete', 'read'], } as const; export const ac = createAccessControl(statement); +/** + * Owner role - Full access + */ export const owner = ac.newRole({ - app: ['create', 'update', 'delete', 'read'], - organization: ['update', 'delete'], - member: ['create', 'update'], - invitation: ['create', 'cancel'], + ...ownerAc.statements, + organization: ['read', 'update', 'delete'], + control: ['create', 'read', 'update', 'delete', 'assign', 'export'], + evidence: ['create', 'read', 'update', 'delete', 'upload', 'export'], + policy: ['create', 'read', 'update', 'delete', 'publish', 'approve'], + risk: ['create', 'read', 'update', 'delete', 'assess', 'export'], + vendor: ['create', 'read', 'update', 'delete', 'assess'], + task: ['create', 'read', 'update', 'delete', 'assign', 'complete'], + framework: ['create', 'read', 'update', 'delete'], + audit: ['create', 'read', 'update', 'export'], + finding: ['create', 'read', 'update', 'delete'], + questionnaire: ['create', 'read', 'update', 'delete', 'respond'], + integration: ['create', 'read', 'update', 'delete'], + app: ['create', 'read', 'update', 'delete'], portal: ['read', 'update'], }); +/** + * Admin role - Full access except org deletion + */ export const admin = ac.newRole({ - app: ['create', 'update', 'delete', 'read'], + ...adminAc.statements, + organization: ['read', 'update'], + control: ['create', 'read', 'update', 'delete', 'assign', 'export'], + evidence: ['create', 'read', 'update', 'delete', 'upload', 'export'], + policy: ['create', 'read', 'update', 'delete', 'publish', 'approve'], + risk: ['create', 'read', 'update', 'delete', 'assess', 'export'], + vendor: ['create', 'read', 'update', 'delete', 'assess'], + task: ['create', 'read', 'update', 'delete', 'assign', 'complete'], + framework: ['create', 'read', 'update', 'delete'], + audit: ['create', 'read', 'update', 'export'], + finding: ['create', 'read', 'update', 'delete'], + questionnaire: ['create', 'read', 'update', 'delete', 'respond'], + integration: ['create', 'read', 'update', 'delete'], + app: ['create', 'read', 'update', 'delete'], portal: ['read', 'update'], - member: ['create', 'update'], - invitation: ['create', 'cancel'], }); -export const member = ac.newRole({ - app: ['update', 'read'], - portal: ['read', 'update'], +/** + * Program Manager role - Full GRC access, no member management + */ +export const program_manager = ac.newRole({ organization: ['read'], + control: ['create', 'read', 'update', 'delete', 'assign', 'export'], + evidence: ['create', 'read', 'update', 'delete', 'upload', 'export'], + policy: ['create', 'read', 'update', 'delete', 'publish', 'approve'], + risk: ['create', 'read', 'update', 'delete', 'assess', 'export'], + vendor: ['create', 'read', 'update', 'delete', 'assess'], + task: ['create', 'read', 'update', 'delete', 'assign', 'complete'], + framework: ['create', 'read', 'update', 'delete'], + audit: ['create', 'read', 'update', 'export'], + finding: ['create', 'read', 'update', 'delete'], + questionnaire: ['create', 'read', 'update', 'delete', 'respond'], + integration: ['read'], + app: ['read'], + portal: ['read', 'update'], }); +/** + * Auditor role - Read-only with export + */ export const auditor = ac.newRole({ - app: ['read'], organization: ['read'], + member: ['create'], + invitation: ['create'], + control: ['read', 'export'], + evidence: ['read', 'export'], + policy: ['read'], + risk: ['read', 'export'], + vendor: ['read'], + task: ['read'], + framework: ['read'], + audit: ['read', 'export'], + finding: ['create', 'read', 'update'], + questionnaire: ['read'], + integration: ['read'], + app: ['read'], + portal: ['read'], }); +/** + * Employee role - Limited access, assignment-based + * This is the primary role used in the employee portal + */ export const employee = ac.newRole({ + task: ['read', 'complete'], + evidence: ['read', 'upload'], + policy: ['read'], + questionnaire: ['read', 'respond'], portal: ['read', 'update'], - organization: ['read', 'update'], - member: ['create', 'update'], - invitation: ['create', 'cancel'], - app: ['read', 'update'], }); +/** + * Contractor role - Same as employee + */ export const contractor = ac.newRole({ + task: ['read', 'complete'], + evidence: ['read', 'upload'], + policy: ['read'], portal: ['read', 'update'], - organization: ['read', 'update'], - member: ['create', 'update'], - invitation: ['create', 'cancel'], - app: ['read', 'update'], }); + +/** + * All available roles + */ +export const allRoles = { + owner, + admin, + program_manager, + auditor, + employee, + contractor, +} as const; + +/** + * Roles that require assignment-based filtering + */ +export const RESTRICTED_ROLES = ['employee', 'contractor'] as const; + +/** + * Roles that have full access without assignment filtering + */ +export const PRIVILEGED_ROLES = [ + 'owner', + 'admin', + 'program_manager', + 'auditor', +] as const; From 68d4cbf750d5ec6e7c2ed74cd57070055ec58869 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 2 Feb 2026 12:25:50 -0500 Subject: [PATCH 4/8] test(rbac): add PermissionGuard unit tests Add comprehensive tests for PermissionGuard covering: - Permission bypass when no permissions required - API key bypass behavior - Role-based access for privileged vs restricted roles - Fallback behavior when better-auth API unavailable - isRestrictedRole static method for all role types Co-Authored-By: Claude Opus 4.5 --- apps/api/src/auth/permission.guard.spec.ts | 165 +++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 apps/api/src/auth/permission.guard.spec.ts diff --git a/apps/api/src/auth/permission.guard.spec.ts b/apps/api/src/auth/permission.guard.spec.ts new file mode 100644 index 000000000..a310f854b --- /dev/null +++ b/apps/api/src/auth/permission.guard.spec.ts @@ -0,0 +1,165 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; +import { PermissionGuard, PERMISSIONS_KEY } from './permission.guard'; + +describe('PermissionGuard', () => { + let guard: PermissionGuard; + let reflector: Reflector; + + const mockConfigService = { + get: jest.fn().mockReturnValue('http://localhost:3000'), + }; + + const createMockExecutionContext = ( + request: Partial<{ + isApiKey: boolean; + userRoles: string[] | null; + headers: Record; + organizationId: string; + }>, + ): ExecutionContext => { + return { + switchToHttp: () => ({ + getRequest: () => ({ + isApiKey: false, + userRoles: null, + headers: {}, + organizationId: 'org_123', + ...request, + }), + }), + getHandler: () => jest.fn(), + getClass: () => jest.fn(), + } as unknown as ExecutionContext; + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PermissionGuard, + Reflector, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + guard = module.get(PermissionGuard); + reflector = module.get(Reflector); + }); + + describe('canActivate', () => { + it('should allow access when no permissions are required', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + + const context = createMockExecutionContext({}); + const result = await guard.canActivate(context); + + expect(result).toBe(true); + }); + + it('should allow access for API keys (with warning)', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([ + { resource: 'control', actions: ['delete'] }, + ]); + + const context = createMockExecutionContext({ isApiKey: true }); + const result = await guard.canActivate(context); + + expect(result).toBe(true); + }); + + it('should deny access when user has no roles', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([ + { resource: 'control', actions: ['delete'] }, + ]); + + // Mock fetch to fail so it uses fallback + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const context = createMockExecutionContext({ + userRoles: null, + headers: { authorization: 'Bearer token' }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('should allow access for privileged roles in fallback mode', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([ + { resource: 'control', actions: ['delete'] }, + ]); + + // Mock fetch to fail so it uses fallback + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const context = createMockExecutionContext({ + userRoles: ['admin'], + headers: { authorization: 'Bearer token' }, + }); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + + it('should deny access for restricted roles in fallback mode', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([ + { resource: 'control', actions: ['delete'] }, + ]); + + // Mock fetch to fail so it uses fallback + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const context = createMockExecutionContext({ + userRoles: ['employee'], + headers: { authorization: 'Bearer token' }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + }); + }); + + describe('isRestrictedRole', () => { + it('should return true for employee role', () => { + expect(PermissionGuard.isRestrictedRole(['employee'])).toBe(true); + }); + + it('should return true for contractor role', () => { + expect(PermissionGuard.isRestrictedRole(['contractor'])).toBe(true); + }); + + it('should return false for admin role', () => { + expect(PermissionGuard.isRestrictedRole(['admin'])).toBe(false); + }); + + it('should return false for owner role', () => { + expect(PermissionGuard.isRestrictedRole(['owner'])).toBe(false); + }); + + it('should return false for program_manager role', () => { + expect(PermissionGuard.isRestrictedRole(['program_manager'])).toBe(false); + }); + + it('should return false for auditor role', () => { + expect(PermissionGuard.isRestrictedRole(['auditor'])).toBe(false); + }); + + it('should return false if user has both employee and admin roles', () => { + expect(PermissionGuard.isRestrictedRole(['employee', 'admin'])).toBe( + false, + ); + }); + + it('should return true for null roles', () => { + expect(PermissionGuard.isRestrictedRole(null)).toBe(true); + }); + + it('should return true for empty roles array', () => { + expect(PermissionGuard.isRestrictedRole([])).toBe(true); + }); + }); +}); From 25ef8cffb9126bb66ecf0278762416cfe0deb632 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 2 Feb 2026 12:29:01 -0500 Subject: [PATCH 5/8] feat(rbac): migrate controllers from RequireRoles to RequirePermission Migrate all API controllers to use the new better-auth permission system: - findings.controller.ts: finding create/update/delete permissions - task-management.controller.ts: task CRUD + assign permissions - people.controller.ts: member delete permission for removeHost - evidence-export.controller.ts: evidence export permission Also fix TypeScript errors in permission.guard.spec.ts for fetch mocking. Co-Authored-By: Claude Opus 4.5 --- apps/api/src/auth/permission.guard.spec.ts | 12 +++++++++--- apps/api/src/findings/findings.controller.ts | 12 ++++++++---- apps/api/src/people/people.controller.ts | 6 ++++-- .../task-management/task-management.controller.ts | 8 +++++--- .../evidence-export/evidence-export.controller.ts | 6 ++++-- 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/api/src/auth/permission.guard.spec.ts b/apps/api/src/auth/permission.guard.spec.ts index a310f854b..c403bda8d 100644 --- a/apps/api/src/auth/permission.guard.spec.ts +++ b/apps/api/src/auth/permission.guard.spec.ts @@ -75,7 +75,9 @@ describe('PermissionGuard', () => { ]); // Mock fetch to fail so it uses fallback - global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + global.fetch = jest + .fn() + .mockRejectedValue(new Error('Network error')) as unknown as typeof fetch; const context = createMockExecutionContext({ userRoles: null, @@ -93,7 +95,9 @@ describe('PermissionGuard', () => { ]); // Mock fetch to fail so it uses fallback - global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + global.fetch = jest + .fn() + .mockRejectedValue(new Error('Network error')) as unknown as typeof fetch; const context = createMockExecutionContext({ userRoles: ['admin'], @@ -110,7 +114,9 @@ describe('PermissionGuard', () => { ]); // Mock fetch to fail so it uses fallback - global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + global.fetch = jest + .fn() + .mockRejectedValue(new Error('Network error')) as unknown as typeof fetch; const context = createMockExecutionContext({ userRoles: ['employee'], diff --git a/apps/api/src/findings/findings.controller.ts b/apps/api/src/findings/findings.controller.ts index a4006a2dd..9939aa9b0 100644 --- a/apps/api/src/findings/findings.controller.ts +++ b/apps/api/src/findings/findings.controller.ts @@ -25,7 +25,8 @@ import { } from '@nestjs/swagger'; import { FindingStatus } from '@trycompai/db'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; -import { RequireRoles } from '../auth/role-validator.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; import { AuthContext } from '../auth/auth-context.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; import { FindingsService } from './findings.service'; @@ -157,7 +158,8 @@ export class FindingsController { } @Post() - @UseGuards(RequireRoles('auditor', 'admin', 'owner')) + @UseGuards(PermissionGuard) + @RequirePermission('finding', 'create') @ApiOperation({ summary: 'Create a finding', description: @@ -233,7 +235,8 @@ export class FindingsController { } @Patch(':id') - @UseGuards(RequireRoles('auditor', 'admin', 'owner')) + @UseGuards(PermissionGuard) + @RequirePermission('finding', 'update') @ApiOperation({ summary: 'Update a finding', description: @@ -310,7 +313,8 @@ export class FindingsController { } @Delete(':id') - @UseGuards(RequireRoles('auditor', 'admin', 'owner')) + @UseGuards(PermissionGuard) + @RequirePermission('finding', 'delete') @ApiOperation({ summary: 'Delete a finding', description: 'Delete a finding (Auditor or Platform Admin only)', diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts index 8be17cb61..f5fd6074b 100644 --- a/apps/api/src/people/people.controller.ts +++ b/apps/api/src/people/people.controller.ts @@ -23,7 +23,8 @@ import { } from '@nestjs/swagger'; import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; -import { RequireRoles } from '../auth/role-validator.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; import { CreatePeopleDto } from './dto/create-people.dto'; import { UpdatePeopleDto } from './dto/update-people.dto'; @@ -204,7 +205,8 @@ export class PeopleController { @Delete(':id/host/:hostId') @HttpCode(HttpStatus.OK) - @UseGuards(RequireRoles('owner')) + @UseGuards(PermissionGuard) + @RequirePermission('member', 'delete') @ApiOperation(PEOPLE_OPERATIONS.removeHost) @ApiParam(PEOPLE_PARAMS.memberId) @ApiParam(PEOPLE_PARAMS.hostId) diff --git a/apps/api/src/task-management/task-management.controller.ts b/apps/api/src/task-management/task-management.controller.ts index e6dad639a..385705451 100644 --- a/apps/api/src/task-management/task-management.controller.ts +++ b/apps/api/src/task-management/task-management.controller.ts @@ -24,8 +24,9 @@ import { import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; -import { Role, TaskItemEntityType } from '@trycompai/db'; -import { RequireRoles } from '../auth/role-validator.guard'; +import { TaskItemEntityType } from '@trycompai/db'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; import { TaskManagementService } from './task-management.service'; import { CreateTaskItemDto } from './dto/create-task-item.dto'; import { UpdateTaskItemDto } from './dto/update-task-item.dto'; @@ -40,7 +41,8 @@ import { TaskItemAuditService } from './task-item-audit.service'; @ApiTags('Task Management') @Controller({ path: 'task-management', version: '1' }) -@UseGuards(HybridAuthGuard, RequireRoles(Role.admin, Role.owner)) +@UseGuards(HybridAuthGuard, PermissionGuard) +@RequirePermission('task', ['create', 'read', 'update', 'delete', 'assign']) @ApiSecurity('apikey') @ApiHeader({ name: 'X-Organization-Id', diff --git a/apps/api/src/tasks/evidence-export/evidence-export.controller.ts b/apps/api/src/tasks/evidence-export/evidence-export.controller.ts index 910735327..137860e1c 100644 --- a/apps/api/src/tasks/evidence-export/evidence-export.controller.ts +++ b/apps/api/src/tasks/evidence-export/evidence-export.controller.ts @@ -19,7 +19,8 @@ import { import type { Response } from 'express'; import { OrganizationId } from '../../auth/auth-context.decorator'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; -import { RequireRoles } from '../../auth/role-validator.guard'; +import { PermissionGuard } from '../../auth/permission.guard'; +import { RequirePermission } from '../../auth/require-permission.decorator'; import { EvidenceExportService } from './evidence-export.service'; import { TasksService } from '../tasks.service'; @@ -210,7 +211,8 @@ export class AuditorEvidenceExportController { * Export all evidence for the organization (auditor only) */ @Get('all') - @UseGuards(RequireRoles('auditor', 'admin', 'owner')) + @UseGuards(PermissionGuard) + @RequirePermission('evidence', 'export') @ApiOperation({ summary: 'Export all organization evidence as ZIP (Auditor only)', description: From 3f8c37c2aa45b9e46c4f64064fe23cbfc2c3654c Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 2 Feb 2026 12:33:25 -0500 Subject: [PATCH 6/8] feat(rbac): add assignment-based filtering for employee/contractor roles Implement assignment filtering to restrict employees/contractors to only see resources they are assigned to: - Add memberId to AuthContext for assignment checking - Create assignment-filter utility with filter builders and access checkers - Update tasks controller/service with assignment filtering on GET endpoints - Update risks controller/service with assignment filtering on GET endpoints - Add PermissionGuard and @RequirePermission to tasks and risks endpoints Employees/contractors now only see: - Tasks where they are the assignee - Risks where they are the assignee Privileged roles (owner, admin, program_manager, auditor) see all resources. Co-Authored-By: Claude Opus 4.5 --- apps/api/src/auth/auth-context.decorator.ts | 22 ++- apps/api/src/auth/hybrid-auth.guard.ts | 2 + apps/api/src/auth/types.ts | 2 + apps/api/src/risks/risks.controller.ts | 28 +++- apps/api/src/risks/risks.service.ts | 9 +- apps/api/src/tasks/tasks.controller.ts | 42 ++++- apps/api/src/tasks/tasks.service.ts | 10 +- apps/api/src/utils/assignment-filter.ts | 175 ++++++++++++++++++++ 8 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/utils/assignment-filter.ts diff --git a/apps/api/src/auth/auth-context.decorator.ts b/apps/api/src/auth/auth-context.decorator.ts index 294b4d5f8..467705a35 100644 --- a/apps/api/src/auth/auth-context.decorator.ts +++ b/apps/api/src/auth/auth-context.decorator.ts @@ -9,8 +9,15 @@ export const AuthContext = createParamDecorator( (data: unknown, ctx: ExecutionContext): AuthContextType => { const request = ctx.switchToHttp().getRequest(); - const { organizationId, authType, isApiKey, userId, userEmail, userRoles } = - request; + const { + organizationId, + authType, + isApiKey, + userId, + userEmail, + userRoles, + memberId, + } = request; if (!organizationId || !authType) { throw new Error( @@ -25,6 +32,7 @@ export const AuthContext = createParamDecorator( userId, userEmail, userRoles, + memberId, }; }, ); @@ -69,6 +77,16 @@ export const UserId = createParamDecorator( }, ); +/** + * Parameter decorator to extract the member ID (only available for session auth) + */ +export const MemberId = createParamDecorator( + (data: unknown, ctx: ExecutionContext): string | undefined => { + const request = ctx.switchToHttp().getRequest(); + return request.memberId; + }, +); + /** * Parameter decorator to check if the request is authenticated via API key */ diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index 11655a070..eb8799d1f 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -180,6 +180,7 @@ export class HybridAuthGuard implements CanActivate { deactivated: false, }, select: { + id: true, role: true, }, }); @@ -190,6 +191,7 @@ export class HybridAuthGuard implements CanActivate { request.userId = userId; request.userEmail = userEmail; request.userRoles = userRoles; + request.memberId = member?.id; // Set member ID for assignment filtering request.organizationId = explicitOrgId; request.authType = 'jwt'; request.isApiKey = false; diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts index 0143395e4..6e82b177a 100644 --- a/apps/api/src/auth/types.ts +++ b/apps/api/src/auth/types.ts @@ -7,6 +7,7 @@ export interface AuthenticatedRequest extends Request { userId?: string; userEmail?: string; userRoles: string[] | null; + memberId?: string; // Member ID for assignment filtering (only available for JWT auth) } export interface AuthContext { @@ -16,4 +17,5 @@ export interface AuthContext { userId?: string; // Only available for JWT auth userEmail?: string; // Only available for JWT auth userRoles: string[] | null; + memberId?: string; // Member ID for assignment filtering (only available for JWT auth) } diff --git a/apps/api/src/risks/risks.controller.ts b/apps/api/src/risks/risks.controller.ts index 28afa46c0..b3c258327 100644 --- a/apps/api/src/risks/risks.controller.ts +++ b/apps/api/src/risks/risks.controller.ts @@ -7,6 +7,7 @@ import { Body, Param, UseGuards, + ForbiddenException, } from '@nestjs/common'; import { ApiBody, @@ -19,7 +20,13 @@ import { } from '@nestjs/swagger'; import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; +import { + buildRiskAssignmentFilter, + hasRiskAccess, +} from '../utils/assignment-filter'; import { CreateRiskDto } from './dto/create-risk.dto'; import { UpdateRiskDto } from './dto/update-risk.dto'; import { RisksService } from './risks.service'; @@ -46,6 +53,8 @@ export class RisksController { constructor(private readonly risksService: RisksService) {} @Get() + @UseGuards(PermissionGuard) + @RequirePermission('risk', 'read') @ApiOperation(RISK_OPERATIONS.getAllRisks) @ApiResponse(GET_ALL_RISKS_RESPONSES[200]) @ApiResponse(GET_ALL_RISKS_RESPONSES[401]) @@ -55,7 +64,16 @@ export class RisksController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - const risks = await this.risksService.findAllByOrganization(organizationId); + // Build assignment filter for restricted roles (employee/contractor) + const assignmentFilter = buildRiskAssignmentFilter( + authContext.memberId, + authContext.userRoles, + ); + + const risks = await this.risksService.findAllByOrganization( + organizationId, + assignmentFilter, + ); return { data: risks, @@ -72,10 +90,13 @@ export class RisksController { } @Get(':id') + @UseGuards(PermissionGuard) + @RequirePermission('risk', 'read') @ApiOperation(RISK_OPERATIONS.getRiskById) @ApiParam(RISK_PARAMS.riskId) @ApiResponse(GET_RISK_BY_ID_RESPONSES[200]) @ApiResponse(GET_RISK_BY_ID_RESPONSES[401]) + @ApiResponse(GET_RISK_BY_ID_RESPONSES[403]) @ApiResponse(GET_RISK_BY_ID_RESPONSES[404]) @ApiResponse(GET_RISK_BY_ID_RESPONSES[500]) async getRiskById( @@ -85,6 +106,11 @@ export class RisksController { ) { const risk = await this.risksService.findById(riskId, organizationId); + // Check assignment access for restricted roles + if (!hasRiskAccess(risk, authContext.memberId, authContext.userRoles)) { + throw new ForbiddenException('You do not have access to this risk'); + } + return { ...risk, authType: authContext.authType, diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts index 74cdd8bea..701fa8bf5 100644 --- a/apps/api/src/risks/risks.service.ts +++ b/apps/api/src/risks/risks.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; -import { db } from '@trycompai/db'; +import { db, Prisma } from '@trycompai/db'; import { CreateRiskDto } from './dto/create-risk.dto'; import { UpdateRiskDto } from './dto/update-risk.dto'; @@ -7,10 +7,13 @@ import { UpdateRiskDto } from './dto/update-risk.dto'; export class RisksService { private readonly logger = new Logger(RisksService.name); - async findAllByOrganization(organizationId: string) { + async findAllByOrganization( + organizationId: string, + assignmentFilter: Prisma.RiskWhereInput = {}, + ) { try { const risks = await db.risk.findMany({ - where: { organizationId }, + where: { organizationId, ...assignmentFilter }, orderBy: { createdAt: 'desc' }, include: { assignee: { diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 320313bfa..ecf9ad4d9 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -4,6 +4,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, Param, Patch, @@ -25,7 +26,13 @@ import { AttachmentsService } from '../attachments/attachments.service'; import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; +import { + buildTaskAssignmentFilter, + hasTaskAccess, +} from '../utils/assignment-filter'; import { AttachmentResponseDto, TaskResponseDto, @@ -52,9 +59,12 @@ export class TasksController { // ==================== TASKS ==================== @Get() + @UseGuards(PermissionGuard) + @RequirePermission('task', 'read') @ApiOperation({ summary: 'Get all tasks', - description: 'Retrieve all tasks for the authenticated organization', + description: + 'Retrieve all tasks for the authenticated organization. Employees/contractors only see their assigned tasks.', }) @ApiResponse({ status: 200, @@ -92,8 +102,15 @@ export class TasksController { }) async getTasks( @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, ): Promise { - return await this.tasksService.getTasks(organizationId); + // Build assignment filter for restricted roles (employee/contractor) + const assignmentFilter = buildTaskAssignmentFilter( + authContext.memberId, + authContext.userRoles, + ); + + return await this.tasksService.getTasks(organizationId, assignmentFilter); } @Patch('bulk') @@ -305,6 +322,8 @@ export class TasksController { } @Get(':taskId') + @UseGuards(PermissionGuard) + @RequirePermission('task', 'read') @ApiOperation({ summary: 'Get task by ID', description: 'Retrieve a specific task by its ID', @@ -331,6 +350,10 @@ export class TasksController { }, }, }) + @ApiResponse({ + status: 403, + description: 'Forbidden - Not assigned to this task', + }) @ApiResponse({ status: 404, description: 'Task not found', @@ -351,8 +374,21 @@ export class TasksController { async getTask( @OrganizationId() organizationId: string, @Param('taskId') taskId: string, + @AuthContext() authContext: AuthContextType, ): Promise { - return await this.tasksService.getTask(organizationId, taskId); + // Service returns full task object with assignee info + const task = await this.tasksService.getTask(organizationId, taskId); + + // Check assignment access for restricted roles + // The task object from service includes assigneeId even though DTO doesn't declare it + const taskWithAssignee = task as TaskResponseDto & { assigneeId: string | null }; + if ( + !hasTaskAccess(taskWithAssignee, authContext.memberId, authContext.userRoles) + ) { + throw new ForbiddenException('You do not have access to this task'); + } + + return task; } @Patch(':taskId') diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index edf3e7d53..df0155040 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -3,7 +3,7 @@ import { Injectable, InternalServerErrorException, } from '@nestjs/common'; -import { db, TaskStatus } from '@trycompai/db'; +import { db, TaskStatus, Prisma } from '@trycompai/db'; import { TaskResponseDto } from './dto/task-responses.dto'; import { TaskNotifierService } from './task-notifier.service'; @@ -13,12 +13,18 @@ export class TasksService { /** * Get all tasks for an organization + * @param organizationId - The organization ID + * @param assignmentFilter - Optional filter for assignment-based access (for employee/contractor roles) */ - async getTasks(organizationId: string): Promise { + async getTasks( + organizationId: string, + assignmentFilter: Prisma.TaskWhereInput = {}, + ): Promise { try { const tasks = await db.task.findMany({ where: { organizationId, + ...assignmentFilter, }, orderBy: [{ status: 'asc' }, { order: 'asc' }, { createdAt: 'asc' }], }); diff --git a/apps/api/src/utils/assignment-filter.ts b/apps/api/src/utils/assignment-filter.ts new file mode 100644 index 000000000..e58c2e411 --- /dev/null +++ b/apps/api/src/utils/assignment-filter.ts @@ -0,0 +1,175 @@ +import { Prisma } from '@prisma/client'; + +/** + * Roles that require assignment-based filtering for resources + */ +const RESTRICTED_ROLES = ['employee', 'contractor']; + +/** + * Roles that have full access without assignment filtering + */ +const PRIVILEGED_ROLES = ['owner', 'admin', 'program_manager', 'auditor']; + +/** + * Check if user roles are restricted (employee/contractor only) + * Users with any privileged role are NOT restricted, even if they also have a restricted role + */ +export function isRestrictedRole(roles: string[] | null | undefined): boolean { + if (!roles || roles.length === 0) { + return true; // No roles = restricted (fail-safe) + } + + // If user has any privileged role, they're not restricted + const hasPrivilegedRole = roles.some((role) => + PRIVILEGED_ROLES.includes(role), + ); + if (hasPrivilegedRole) { + return false; + } + + // Check if all roles are restricted + return roles.every((role) => RESTRICTED_ROLES.includes(role)); +} + +/** + * Build Prisma where filter for tasks based on assignment + * For restricted roles, only show tasks assigned to the member + */ +export function buildTaskAssignmentFilter( + memberId: string | null | undefined, + roles: string[] | null | undefined, +): Prisma.TaskWhereInput { + if (!isRestrictedRole(roles)) { + return {}; // No filtering for privileged roles + } + + if (!memberId) { + // Restricted user with no memberId - return filter that matches nothing + return { id: 'impossible_match_no_member' }; + } + + return { assigneeId: memberId }; +} + +/** + * Build Prisma where filter for risks based on assignment + * For restricted roles, only show risks assigned to the member + */ +export function buildRiskAssignmentFilter( + memberId: string | null | undefined, + roles: string[] | null | undefined, +): Prisma.RiskWhereInput { + if (!isRestrictedRole(roles)) { + return {}; // No filtering for privileged roles + } + + if (!memberId) { + return { id: 'impossible_match_no_member' }; + } + + return { assigneeId: memberId }; +} + +/** + * Build Prisma where filter for controls based on task assignment + * For restricted roles, only show controls linked to tasks assigned to the member + */ +export function buildControlAssignmentFilter( + memberId: string | null | undefined, + roles: string[] | null | undefined, +): Prisma.ControlWhereInput { + if (!isRestrictedRole(roles)) { + return {}; // No filtering for privileged roles + } + + if (!memberId) { + return { id: 'impossible_match_no_member' }; + } + + // Controls visible if any linked task is assigned to member + return { + tasks: { + some: { assigneeId: memberId }, + }, + }; +} + +/** + * Build Prisma where filter for policies based on assignment + * For restricted roles, only show policies where the member is the assignee + */ +export function buildPolicyAssignmentFilter( + memberId: string | null | undefined, + roles: string[] | null | undefined, +): Prisma.PolicyWhereInput { + if (!isRestrictedRole(roles)) { + return {}; // No filtering for privileged roles + } + + if (!memberId) { + return { id: 'impossible_match_no_member' }; + } + + // Policies visible if member is the assignee + return { + assigneeId: memberId, + }; +} + +/** + * Check if a member has access to a specific task + */ +export function hasTaskAccess( + task: { assigneeId: string | null }, + memberId: string | null | undefined, + roles: string[] | null | undefined, +): boolean { + if (!isRestrictedRole(roles)) { + return true; // Privileged roles have access to all tasks + } + + if (!memberId) { + return false; + } + + return task.assigneeId === memberId; +} + +/** + * Check if a member has access to a specific risk + */ +export function hasRiskAccess( + risk: { assigneeId: string | null }, + memberId: string | null | undefined, + roles: string[] | null | undefined, +): boolean { + if (!isRestrictedRole(roles)) { + return true; // Privileged roles have access to all risks + } + + if (!memberId) { + return false; + } + + return risk.assigneeId === memberId; +} + +/** + * Check if a member has access to a control (via assigned tasks) + */ +export function hasControlAccess( + control: { tasks: { assigneeId: string | null }[] }, + memberId: string | null | undefined, + roles: string[] | null | undefined, +): boolean { + if (!isRestrictedRole(roles)) { + return true; // Privileged roles have access to all controls + } + + if (!memberId) { + return false; + } + + // Control accessible if ANY linked task is assigned to the member + return control.tasks.some((task) => task.assigneeId === memberId); +} From 4c2d1436dac1a1726af40b9adb059d097c73e800 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 2 Feb 2026 12:37:02 -0500 Subject: [PATCH 7/8] feat(rbac): add department-based policy visibility Allow admins to control which departments can see specific policies: Schema changes: - Add PolicyVisibility enum (ALL, DEPARTMENT) - Add visibility and visibleToDepartments fields to Policy model API changes: - Add memberDepartment to AuthContext for visibility filtering - Create department-visibility utility with filter builders - Update policies controller to filter by visibility for restricted roles - Update policies service to accept visibility filter Policies can now be: - Visible to ALL (default) - everyone in the organization sees them - Visible to specific DEPARTMENTS only - only members in those departments see them Privileged roles (owner, admin, program_manager, auditor) see all policies regardless of visibility settings. Co-Authored-By: Claude Opus 4.5 --- apps/api/src/auth/auth-context.decorator.ts | 2 + apps/api/src/auth/hybrid-auth.guard.ts | 2 + apps/api/src/auth/types.ts | 4 + apps/api/src/policies/policies.controller.ts | 36 +++++++- apps/api/src/policies/policies.service.ts | 11 ++- apps/api/src/utils/department-visibility.ts | 89 +++++++++++++++++++ .../migration.sql | 9 ++ packages/db/prisma/schema/policy.prisma | 9 ++ 8 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/utils/department-visibility.ts create mode 100644 packages/db/prisma/migrations/20260202173355_add_policy_visibility/migration.sql diff --git a/apps/api/src/auth/auth-context.decorator.ts b/apps/api/src/auth/auth-context.decorator.ts index 467705a35..3f07f20b8 100644 --- a/apps/api/src/auth/auth-context.decorator.ts +++ b/apps/api/src/auth/auth-context.decorator.ts @@ -17,6 +17,7 @@ export const AuthContext = createParamDecorator( userEmail, userRoles, memberId, + memberDepartment, } = request; if (!organizationId || !authType) { @@ -33,6 +34,7 @@ export const AuthContext = createParamDecorator( userEmail, userRoles, memberId, + memberDepartment, }; }, ); diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index eb8799d1f..3b1b598dc 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -182,6 +182,7 @@ export class HybridAuthGuard implements CanActivate { select: { id: true, role: true, + department: true, }, }); @@ -192,6 +193,7 @@ export class HybridAuthGuard implements CanActivate { request.userEmail = userEmail; request.userRoles = userRoles; request.memberId = member?.id; // Set member ID for assignment filtering + request.memberDepartment = member?.department; // Set department for visibility filtering request.organizationId = explicitOrgId; request.authType = 'jwt'; request.isApiKey = false; diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts index 6e82b177a..37774d5c0 100644 --- a/apps/api/src/auth/types.ts +++ b/apps/api/src/auth/types.ts @@ -1,5 +1,7 @@ // Types for API authentication - supports API keys and JWT tokens only +import { Departments } from '@prisma/client'; + export interface AuthenticatedRequest extends Request { organizationId: string; authType: 'api-key' | 'jwt'; @@ -8,6 +10,7 @@ export interface AuthenticatedRequest extends Request { userEmail?: string; userRoles: string[] | null; memberId?: string; // Member ID for assignment filtering (only available for JWT auth) + memberDepartment?: Departments; // Member department for visibility filtering (only available for JWT auth) } export interface AuthContext { @@ -18,4 +21,5 @@ export interface AuthContext { userEmail?: string; // Only available for JWT auth userRoles: string[] | null; memberId?: string; // Member ID for assignment filtering (only available for JWT auth) + memberDepartment?: Departments; // Member department for visibility filtering (only available for JWT auth) } diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 2f27305fd..9294353d6 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, HttpCode, Param, @@ -27,7 +28,13 @@ import { openai } from '@ai-sdk/openai'; import { streamText, convertToModelMessages, type UIMessage } from 'ai'; import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; +import { + buildPolicyVisibilityFilter, + canViewPolicy, +} from '../utils/department-visibility'; import { CreatePolicyDto } from './dto/create-policy.dto'; import { UpdatePolicyDto } from './dto/update-policy.dto'; import { AISuggestPolicyRequestDto } from './dto/ai-suggest-policy.dto'; @@ -75,6 +82,8 @@ export class PoliciesController { constructor(private readonly policiesService: PoliciesService) {} @Get() + @UseGuards(PermissionGuard) + @RequirePermission('policy', 'read') @ApiOperation(POLICY_OPERATIONS.getAllPolicies) @ApiResponse(GET_ALL_POLICIES_RESPONSES[200]) @ApiResponse(GET_ALL_POLICIES_RESPONSES[401]) @@ -82,7 +91,16 @@ export class PoliciesController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - const policies = await this.policiesService.findAll(organizationId); + // Build visibility filter for department-specific policies + const visibilityFilter = buildPolicyVisibilityFilter( + authContext.memberDepartment, + authContext.userRoles, + ); + + const policies = await this.policiesService.findAll( + organizationId, + visibilityFilter, + ); return { data: policies, @@ -131,10 +149,13 @@ export class PoliciesController { } @Get(':id') + @UseGuards(PermissionGuard) + @RequirePermission('policy', 'read') @ApiOperation(POLICY_OPERATIONS.getPolicyById) @ApiParam(POLICY_PARAMS.policyId) @ApiResponse(GET_POLICY_BY_ID_RESPONSES[200]) @ApiResponse(GET_POLICY_BY_ID_RESPONSES[401]) + @ApiResponse(GET_POLICY_BY_ID_RESPONSES[403]) @ApiResponse(GET_POLICY_BY_ID_RESPONSES[404]) async getPolicy( @Param('id') id: string, @@ -143,6 +164,19 @@ export class PoliciesController { ) { const policy = await this.policiesService.findById(id, organizationId); + // Check visibility access for department-specific policies + if ( + !canViewPolicy( + policy, + authContext.memberDepartment, + authContext.userRoles, + ) + ) { + throw new ForbiddenException( + 'You do not have access to view this policy', + ); + } + return { ...policy, authType: authContext.authType, diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index a39cc74e9..75206d590 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -27,10 +27,13 @@ export class PoliciesService { private readonly pdfRendererService: PolicyPdfRendererService, ) {} - async findAll(organizationId: string) { + async findAll( + organizationId: string, + visibilityFilter: Prisma.PolicyWhereInput = {}, + ) { try { const policies = await db.policy.findMany({ - where: { organizationId }, + where: { organizationId, ...visibilityFilter }, select: { id: true, name: true, @@ -56,6 +59,8 @@ export class PoliciesService { pendingVersionId: true, displayFormat: true, pdfUrl: true, + visibility: true, + visibleToDepartments: true, assignee: { select: { id: true, @@ -115,6 +120,8 @@ export class PoliciesService { pendingVersionId: true, displayFormat: true, pdfUrl: true, + visibility: true, + visibleToDepartments: true, approver: { include: { user: true, diff --git a/apps/api/src/utils/department-visibility.ts b/apps/api/src/utils/department-visibility.ts new file mode 100644 index 000000000..296be4d04 --- /dev/null +++ b/apps/api/src/utils/department-visibility.ts @@ -0,0 +1,89 @@ +import { Departments, Prisma, PolicyVisibility } from '@prisma/client'; + +/** + * Roles that have full access without department visibility filtering + */ +const PRIVILEGED_ROLES = ['owner', 'admin', 'program_manager', 'auditor']; + +/** + * Check if user has a privileged role that bypasses visibility filtering + */ +export function isPrivilegedRole(roles: string[] | null | undefined): boolean { + if (!roles || roles.length === 0) { + return false; + } + return roles.some((role) => PRIVILEGED_ROLES.includes(role)); +} + +/** + * Build Prisma where filter for policy visibility based on member's department + * + * For privileged roles: No filtering (see all policies) + * For employees/contractors: + * - See policies with visibility = ALL + * - See policies where their department is in visibleToDepartments + */ +export function buildPolicyVisibilityFilter( + memberDepartment: Departments | null | undefined, + memberRoles: string[] | null | undefined, +): Prisma.PolicyWhereInput { + // Privileged roles see everything + if (isPrivilegedRole(memberRoles)) { + return {}; + } + + // If no department, only show policies visible to ALL + if (!memberDepartment || memberDepartment === Departments.none) { + return { + visibility: PolicyVisibility.ALL, + }; + } + + // Employees/contractors only see: + // 1. Policies with visibility = ALL + // 2. Policies where their department is in visibleToDepartments + return { + OR: [ + { visibility: PolicyVisibility.ALL }, + { + visibility: PolicyVisibility.DEPARTMENT, + visibleToDepartments: { has: memberDepartment }, + }, + ], + }; +} + +/** + * Check if a member can view a specific policy based on visibility settings + */ +export function canViewPolicy( + policy: { + visibility: PolicyVisibility; + visibleToDepartments: Departments[]; + }, + memberDepartment: Departments | null | undefined, + memberRoles: string[] | null | undefined, +): boolean { + // Privileged roles see everything + if (isPrivilegedRole(memberRoles)) { + return true; + } + + // Policy visible to ALL - everyone can see + if (policy.visibility === PolicyVisibility.ALL) { + return true; + } + + // Policy is department-specific + if (policy.visibility === PolicyVisibility.DEPARTMENT) { + // No department = can't see department-specific policies + if (!memberDepartment || memberDepartment === Departments.none) { + return false; + } + + // Check if member's department is in the visible list + return policy.visibleToDepartments.includes(memberDepartment); + } + + return false; +} diff --git a/packages/db/prisma/migrations/20260202173355_add_policy_visibility/migration.sql b/packages/db/prisma/migrations/20260202173355_add_policy_visibility/migration.sql new file mode 100644 index 000000000..359269470 --- /dev/null +++ b/packages/db/prisma/migrations/20260202173355_add_policy_visibility/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "PolicyVisibility" AS ENUM ('ALL', 'DEPARTMENT'); + +-- AlterEnum +ALTER TYPE "Role" ADD VALUE 'program_manager'; + +-- AlterTable +ALTER TABLE "Policy" ADD COLUMN "visibility" "PolicyVisibility" NOT NULL DEFAULT 'ALL', +ADD COLUMN "visibleToDepartments" "Departments"[] DEFAULT ARRAY[]::"Departments"[]; diff --git a/packages/db/prisma/schema/policy.prisma b/packages/db/prisma/schema/policy.prisma index 07e210f8a..dff10d894 100644 --- a/packages/db/prisma/schema/policy.prisma +++ b/packages/db/prisma/schema/policy.prisma @@ -3,6 +3,11 @@ enum PolicyDisplayFormat { PDF } +enum PolicyVisibility { + ALL // Visible to everyone in organization + DEPARTMENT // Only visible to specified departments +} + model Policy { id String @id @default(dbgenerated("generate_prefixed_cuid('pol'::text)")) name String @@ -19,6 +24,10 @@ model Policy { displayFormat PolicyDisplayFormat @default(EDITOR) pdfUrl String? + // Visibility settings (for department-specific policies) + visibility PolicyVisibility @default(ALL) + visibleToDepartments Departments[] @default([]) + // Dates createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From d59cd806e278d0a9b63bbe4af2ff99cf4c70e044 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:19:13 +0000 Subject: [PATCH 8/8] chore(deps): bump the npm_and_yarn group across 3 directories with 1 update Bumps the npm_and_yarn group with 1 update in the / directory: [jspdf](https://github.com/parallax/jsPDF). Bumps the npm_and_yarn group with 1 update in the /apps/api directory: [jspdf](https://github.com/parallax/jsPDF). Bumps the npm_and_yarn group with 1 update in the /apps/app directory: [jspdf](https://github.com/parallax/jsPDF). Updates `jspdf` from 3.0.4 to 4.1.0 - [Release notes](https://github.com/parallax/jsPDF/releases) - [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md) - [Commits](https://github.com/parallax/jsPDF/compare/v3.0.4...v4.1.0) Updates `jspdf` from 3.0.4 to 4.1.0 - [Release notes](https://github.com/parallax/jsPDF/releases) - [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md) - [Commits](https://github.com/parallax/jsPDF/compare/v3.0.4...v4.1.0) Updates `jspdf` from 3.0.4 to 4.1.0 - [Release notes](https://github.com/parallax/jsPDF/releases) - [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md) - [Commits](https://github.com/parallax/jsPDF/compare/v3.0.4...v4.1.0) --- updated-dependencies: - dependency-name: jspdf dependency-version: 4.1.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: jspdf dependency-version: 4.1.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: jspdf dependency-version: 4.1.0 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- apps/api/package.json | 2 +- apps/app/package.json | 2 +- apps/portal/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index acc62c3f0..5f10dd0c3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -43,7 +43,7 @@ "express": "^4.21.2", "helmet": "^8.1.0", "jose": "^6.0.12", - "jspdf": "^3.0.3", + "jspdf": "^4.1.0", "mammoth": "^1.8.0", "nanoid": "^5.1.6", "pdf-lib": "^1.17.1", diff --git a/apps/app/package.json b/apps/app/package.json index 60f50690b..ed1e08f25 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -78,7 +78,7 @@ "dub": "^0.66.1", "framer-motion": "^12.18.1", "geist": "^1.3.1", - "jspdf": "^3.0.2", + "jspdf": "^4.1.0", "lucide-react": "^0.544.0", "mammoth": "^1.11.0", "motion": "^12.9.2", diff --git a/apps/portal/package.json b/apps/portal/package.json index d171688ec..972d4d345 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -20,7 +20,7 @@ "better-auth": "^1.4.5", "class-variance-authority": "^0.7.1", "geist": "^1.3.1", - "jspdf": "^3.0.3", + "jspdf": "^4.1.0", "jszip": "^3.10.1", "next": "^16.0.10", "next-safe-action": "^8.0.3",