Skip to content

Feat/rbac v1#2092

Open
Marfuen wants to merge 45 commits intomainfrom
feat/rbac-v1
Open

Feat/rbac v1#2092
Marfuen wants to merge 45 commits intomainfrom
feat/rbac-v1

Conversation

@Marfuen
Copy link
Contributor

@Marfuen Marfuen commented Feb 2, 2026

What does this PR do?

  • Fixes #XXXX (GitHub issue number)
  • Fixes COMP-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)

Visual Demo (For contributors especially)

A visual demonstration is strongly recommended, for both the original and new change (video / image - any one).

Video Demo (if applicable):

  • Show screen recordings of the issue or feature.
  • Demonstrate how to reproduce the issue, the behavior before and after the change.

Image Demo (if applicable):

  • Add side-by-side screenshots of the original and updated change.
  • Highlight any significant change(s).

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox.
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

How should this be tested?

  • Are there environment variables that should be set?
  • What are the minimal test data to have?
  • What is expected (happy path) to have (input and output)?
  • Any other important info that could help to test that PR

Checklist

  • I haven't read the contributing guide
  • My code doesn't follow the style guidelines of this project
  • I haven't commented my code, particularly in hard-to-understand areas
  • I haven't checked if my changes generate no new warnings
Cursor Bugbot reviewed your changes and found no issues for commit c74407b

Marfuen and others added 19 commits February 2, 2026 12:19
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- Move auth server to API, app now uses proxy to forward auth requests
- Remove localStorage token storage (XSS prevention)
- Add rate limiting to auth proxy (60/min general, 10/min sensitive)
- Add redirect URL validation to prevent open redirects
- Add AUTH_SECRET validation at startup
- Make all debug logging conditional on NODE_ENV
- Simplify root page routing (no activeOrganizationId dependency)
- Use URL-based RBAC with direct DB member lookup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add @comp/auth package with centralized permissions and role definitions
- Update API auth module to integrate with better-auth server
- Add 403 responses to policy and risk endpoints for Swagger
- Add assignment filter and department visibility utilities with tests
- Sync permissions across app and portal
- Update tsconfig and nest-cli for proper module resolution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add dynamicAccessControl config to organization plugin
- Add OrganizationRole table for storing custom roles
- Configure maximum 20 roles per organization
- Add schema mapping for better-auth role table

Resolves: ENG-145

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add roles module with CRUD endpoints for custom roles
- Implement privilege escalation prevention
- Add permission validation against valid resources/actions
- Protect built-in roles (owner, admin, auditor, employee, contractor)
- Add OrganizationRole table migration
- Limit to 20 custom roles per organization
- Require ac:create/read/update/delete permissions for role management

Implements: ENG-146

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update roles service to accept array of roles instead of single role
- Add getCombinedPermissions to merge permissions from all user roles
- Update controller to pass full userRoles array
- Users with multiple roles now get combined permissions for validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add explicit jwks configuration with rotationInterval to prevent
better-auth from creating new JWKS keys on each request. Without this,
all existing JWTs become invalid when the API restarts because new
signing keys are generated.

- Set rotationInterval to 30 days for monthly key rotation
- Set gracePeriod to 7 days so old keys remain valid after rotation

Fixes: Session persistence across API restarts

References:
- better-auth/better-auth#6215

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add 18 tests for RolesService covering CRUD operations
- Add 9 tests for RolesController
- Test permission validation and privilege escalation prevention
- Test multiple roles support for privilege checking
- Test edge cases (duplicate names, max roles limit, reserved names)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update .cursorrules with testing requirements and conventions
- Add apps/api/CLAUDE.md with API-specific development guidelines
- Document when to write tests, how to run them, and test patterns
- Include RBAC system documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove API-specific testing rules from root .cursorrules
- Create apps/api/.cursorrules with API testing requirements
- Keep root .cursorrules focused on commit message conventions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Ensures that users cannot escalate privileges when updating
role permissions, not just when creating roles.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add roles settings pages (list, create, edit) with permission matrix
- Add "Select all" feature to quickly set all permissions
- Integrate custom roles into member management UI:
  - Role filter dropdown shows all roles dynamically
  - Invite modal supports custom role selection
  - Edit member role supports custom roles
- Allow normal spelling for role names (spaces, capitalization)
- Add loading skeletons with proper PageLayout wrappers
- Add comprehensive tests for RolesTable, RoleForm, PermissionMatrix

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@cursor
Copy link

cursor bot commented Feb 2, 2026

PR Summary

High Risk
High risk because it changes core authentication flows (session/service-token auth), introduces centralized RBAC enforcement on many endpoints, and adds global audit logging that runs on most requests.

Overview
Introduces RBAC enforcement and auditing across the NestJS API. Controllers are migrated to @UseGuards(HybridAuthGuard, PermissionGuard) and annotated with @RequirePermission(...), replacing prior header-only Swagger docs and enabling permission checks via better-auth.

Authentication is reworked: the API now hosts the better-auth server (auth.server.ts) via @thallesp/nestjs-better-auth, HybridAuthGuard switches from JWKS JWT verification to better-auth session resolution (bearer or cookies), adds scoped service-to-service auth via x-service-token (replacing InternalTokenGuard), expands auth context (memberId, department, platform admin), and adds API key creation/revocation with salted hashing.

Adds global audit logging via a new AuditModule interceptor that records mutations (and opt-in reads via @AuditRead), redacts sensitive fields, computes field diffs (including member-name resolution), and special-cases comments/downloads/version actions; extensive Jest coverage is added.

Cloud Security gains new endpoints and legacy support: read endpoints for providers/findings, legacy connect/disconnect/validate-AWS, and a query service that merges “new platform” and legacy data. Controls gets a new CRUD-lite controller/service for create/delete with relationship mapping. Attachments service adds inline PDF presigned URLs and direct buffer uploads. Notification recipient filtering is tightened to exclude platform admins and pass org context to unsubscribe checks.

Also adds repository AI/dev rules docs, updates env examples to per-service tokens, and adjusts Jest module mapping / Nest CLI entry configuration.

Written by Cursor Bugbot for commit 021beb9. This will update automatically on new commits. Configure here.

@vercel
Copy link

vercel bot commented Feb 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
app Ready Ready Preview, Comment Feb 6, 2026 8:55pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
portal Skipped Skipped Feb 6, 2026 8:55pm

Request Review

@ApiTags('Controls')
@ApiBearerAuth()
@UseGuards(HybridAuthGuard, PermissionGuard)
@Controller('v1/controls')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New controllers have doubled version prefix in routes

High Severity

ControlsController uses @Controller('v1/controls') and FrameworksController uses @Controller('v1/frameworks'), but the application enables URI versioning with defaultVersion: '1' in main.ts. NestJS automatically prepends /v1/ to all versioned routes, so these controllers end up registered at /v1/v1/controls and /v1/v1/frameworks — making them unreachable at the intended paths. All other controllers in the codebase use the correct @Controller({ path: '...', version: '1' }) object format.

Additional Locations (1)

Fix in Cursor Fix in Web


// PATCH /v1/policies/:id with isArchived field
if (method === 'PATCH' && requestBody && 'isArchived' in requestBody) {
return requestBody.isArchived ? 'Archived policy' : 'Restored policy';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit description wrongly labels non-policy archive operations

Medium Severity

extractPolicyActionDescription checks for 'isArchived' in requestBody without verifying the request path targets a policy resource. Since this function is called for ALL resources in the AuditLogInterceptor, any PATCH request on any resource that includes an isArchived field would produce the audit description "Archived policy" or "Restored policy" and also suppress change tracking. The path parameter is available but unused for this check.

Fix in Cursor Fix in Web

integration: ['read'],
app: ['read'],
portal: ['read'],
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auditor role missing member and invitation read permissions

Medium Severity

The inlined auditor role definition is missing 'read' for both member and invitation resources. The canonical definition in packages/auth/src/permissions.ts grants member: ['create', 'read'] and invitation: ['create', 'read'], noting auditors need read access to "view people for audit context." This regression means auditors are denied access to member/invitation read operations that the permission system was designed to allow.

Fix in Cursor Fix in Web

// PATCH /v1/policies/:id with isArchived field
if (method === 'PATCH' && requestBody && 'isArchived' in requestBody) {
return requestBody.isArchived ? 'Archived policy' : 'Restored policy';
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit log isArchived check not scoped to policy paths

Medium Severity

extractPolicyActionDescription checks for isArchived in ANY PATCH request body without verifying the URL is a policy endpoint. The /regenerate check correctly validates the path, but the isArchived check does not. If any non-policy resource ever includes isArchived in a PATCH body, the audit log will incorrectly say "Archived policy" or "Restored policy". Worse, in the interceptor, a truthy policyActionDesc forces changes = null, completely suppressing the actual field-level change tracking for that request.

Additional Locations (1)

Fix in Cursor Fix in Web

integration: ['read'],
app: ['read'],
portal: ['read'],
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auditor role lacks finding:delete, breaking existing delete functionality

High Severity

The auditor role is defined with finding: ['create', 'read', 'update'] but omits 'delete'. The deleteFinding endpoint now uses @RequirePermission('finding', 'delete'), so auditors are blocked by the PermissionGuard before reaching the handler. Previously, @UseGuards(RequireRoles('auditor', 'admin', 'owner')) explicitly allowed auditors to delete findings. The handler's inner code still checks isAuditor and the API description still says "Auditor or Platform Admin only", confirming this was unintentional. Auditors can no longer delete findings.

Additional Locations (1)

Fix in Cursor Fix in Web

integration: ['create', 'read', 'update', 'delete'],
app: ['read'],
portal: ['read', 'update'],
} as const;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing cloud-security resource in access control definitions

High Severity

The cloud-security resource is used in @RequirePermission('cloud-security', ...) decorators on every CloudSecurityController endpoint, but it's not included in the access control statement object in auth.server.ts, and no role (owner, admin, auditor, etc.) is granted any cloud-security permissions. When auth.api.hasPermission is called for this resource, it will return success: false for all session-authenticated users, effectively making all cloud security endpoints return 403 Forbidden. Only API key users (which bypass permission checks) can access these endpoints.

Additional Locations (1)

Fix in Cursor Fix in Web

}

return null;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit description wrongly hardcodes "policy" for all resources

Medium Severity

extractPolicyActionDescription is called for every resource, not just policies. The isArchived check on line 181 matches any PATCH request containing isArchived in the body, regardless of the actual resource type. If a non-policy resource (e.g., a vendor) is archived via PATCH, the audit log will incorrectly say "Archived policy" instead of the correct resource name, and the changes diff will be suppressed. The /regenerate pattern has the same issue.

Additional Locations (1)

Fix in Cursor Fix in Web

Marfuen and others added 2 commits February 6, 2026 14:30
Resolves merge conflicts across 5 files:
- task-notifier.service.ts: took main's narrowed notification scope
- trust-portal.controller.ts: kept both RBAC endpoints and new features
- trust/page.tsx: merged both sets of props, fixed duplicate contactEmail/primaryColor
- TrustPortalSwitch.tsx: combined tabbed UI from main with API migration
- openapi.json: took main's version

Also fixed BrandSettings.tsx broken import (server action -> api.put).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
integration: ['create', 'read', 'update', 'delete'],
app: ['read'],
portal: ['read', 'update'],
} as const;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cloud security resource missing from RBAC permission definitions

High Severity

The cloud-security resource is used in @RequirePermission('cloud-security', 'read') (and other actions) on the cloud security controller, but it's not defined in the access control statement or in any role definition in auth.server.ts. Since better-auth's hasPermission returns false for undefined resources, all session-authenticated users (owner, admin, auditor, etc.) will be denied access to every cloud security endpoint. Only API keys (which bypass permission checks) and platform admins can access these routes.

Additional Locations (1)

Fix in Cursor Fix in Web

// PATCH /v1/policies/:id with isArchived field
if (method === 'PATCH' && requestBody && 'isArchived' in requestBody) {
return requestBody.isArchived ? 'Archived policy' : 'Restored policy';
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Archive description hardcodes "policy" for all resources

Medium Severity

extractPolicyActionDescription checks for isArchived in the request body without verifying the URL path contains /policies/. This function is called for every mutation request in the interceptor, so a PATCH to any resource that includes isArchived would produce audit descriptions like "Archived policy" or "Restored policy" regardless of the actual resource. It also suppresses the changes diff via the policyActionDesc truthy check in the interceptor.

Additional Locations (1)

Fix in Cursor Fix in Web

organization: 'organization',
member: 'member',
framework: 'frameworkInstance',
task: 'taskItem',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong Prisma model mapping for task audit lookups

Medium Severity

RESOURCE_TO_PRISMA_MODEL maps task to 'taskItem', but the task permission resource corresponds to the Task Prisma model (accessed via db.task), not TaskItem (which is a separate entity for task management items with different IDs and fields). When fetchCurrentValues runs for a task update, it queries db.taskItem.findUnique with a tsk_-prefixed ID, which will never match a tski_-prefixed TaskItem record, so previous values are always null and audit log diffs for task changes will be missing.

Fix in Cursor Fix in Web

integration: ['create', 'read', 'update', 'delete'],
app: ['read'],
portal: ['read', 'update'],
} as const;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing cloud-security and training in access control statement

High Severity

The statement object in auth.server.ts does not include cloud-security or training as resources, yet @RequirePermission('cloud-security', ...) and @RequirePermission('training', ...) are used on controller endpoints. Since these resources aren't defined in the access control, auth.api.hasPermission will return false for all session-authenticated users, making cloud security and training endpoints completely inaccessible. No built-in role grants these permissions either. The canonical packages/auth/src/permissions.ts also omits these resources.

Additional Locations (2)

Fix in Cursor Fix in Web

integration: ['create', 'read', 'update', 'delete'],
app: ['read'],
portal: ['read', 'update'],
} as const;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inlined permissions diverge from canonical permission definitions

Medium Severity

The auth.server.ts statement is described as "inlined from @comp/auth" but diverges from packages/auth/src/permissions.ts. The canonical source overrides member to include 'read', invitation to include 'read', and team/ac with full CRUD. The inlined version omits these overrides, relying on defaultStatements which lack 'read' for member/invitation. The auditor role also drops member:['read'] and invitation:['read']. Since auth.server.ts is the running auth server, member:read is not a valid permission at runtime, so any permission check requiring it will always fail.

Additional Locations (1)

Fix in Cursor Fix in Web

// PATCH /v1/policies/:id with isArchived field
if (method === 'PATCH' && requestBody && 'isArchived' in requestBody) {
return requestBody.isArchived ? 'Archived policy' : 'Restored policy';
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit log isArchived check lacks path restriction

Medium Severity

extractPolicyActionDescription checks for isArchived in the request body for any PATCH request without restricting to policy URLs. This function runs in a global interceptor on every request. If any non-policy resource ever accepts an isArchived field in a PATCH body, the audit log would incorrectly say "Archived policy" or "Restored policy" and would also suppress the changes diff. The path parameter is available but unused for this check, unlike the /regenerate check above it which properly validates the path.

Fix in Cursor Fix in Web

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

integration: ['create', 'read', 'update', 'delete'],
app: ['read'],
portal: ['read', 'update'],
} as const;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing cloud-security and training in RBAC permission statements

High Severity

The statement object passed to createAccessControl does not include cloud-security or training as resources, yet controllers use @RequirePermission('cloud-security', ...) and @RequirePermission('training', ...). Since no built-in role (owner, admin, auditor, etc.) can be granted permissions on undefined resources, auth.api.hasPermission will deny all session-authenticated users access to cloud security and training endpoints. API key and service token paths are unaffected, but regular logged-in users will get ForbiddenException on every cloud security and training request.

Additional Locations (2)

Fix in Cursor Fix in Web

// PATCH /v1/policies/:id with isArchived field
if (method === 'PATCH' && requestBody && 'isArchived' in requestBody) {
return requestBody.isArchived ? 'Archived policy' : 'Restored policy';
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit description not scoped to policy resource path

Medium Severity

extractPolicyActionDescription checks for isArchived in any PATCH request body without verifying the path corresponds to a policy endpoint. Since this function is called by the global AuditLogInterceptor for all mutation requests, any future resource that adds an isArchived field would have its audit log entries incorrectly labeled as "Archived policy" or "Restored policy" instead of referencing the actual resource. The path parameter is already available but unused for this check.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant