Roles & Permissions
This page documents the role, permission, membership, sharing, billing, and AI authorization rules verified against RDS and reviewed from the backend and AI service code.
Primary code paths reviewed:
aiqlick-backend/prisma/schema.prismaaiqlick-backend/prisma/seed-essential.tsaiqlick-backend/src/common/constants/roles.tsaiqlick-backend/src/user-management/authentication/guards/*.tsaiqlick-backend/src/user-management/authorization/**/*.tsaiqlick-backend/src/company-management/company/**/*aiqlick-backend/src/ai-gateway/**/*background-tasks/app/graphql/**/*background-tasks/app/services/billing/**/*
RDS Verification
The canonical authorization state is the current codebase plus production RDS. The RDS checks below were read-only and queried only role, permission, and join tables.
| Environment | Database | PostgreSQL | Verified | Role rows | Permission rows | Role-permission links |
|---|---|---|---|---|---|---|
| Production RDS | aiqlick_db | 16.6 | 2026-05-29 20:24 UTC | 8 | 39 | 155 |
| Development RDS | aiqlick_dev | 16.10 | 2026-05-29 20:24 UTC | 8 | 39 | 155 |
Production and development RDS now have identical role-permission joins by Role.name and Permission.name. Production remains the canonical environment for documenting the matrix; development was synchronized to it on 2026-05-29.
Authorization Model
The platform uses several authorization layers. They are related, but they are not the same thing.
| Layer | Source | Meaning |
|---|---|---|
| Authentication | JWT sub loaded by JwtAuthGuard | Identifies the current user and attaches the full user record to req.user. |
| Platform super admin | User.isSuperAdmin | Platform-level override used by SuperAdminGuard and selected services. This is separate from a company role named Super Admin. |
| Company membership | UserCompanyRole | Links a user, company, and role. Most company authorization is based on this table. |
| Global support role | UserRole with role name support | Used by support tooling through SupportAgentGuard; it is not a company membership role. |
| Current company | User.selectedCompanyId or explicit GraphQL variables | Determines which company-scoped role and plan checks apply. |
| Billing owner | Company.billingOwnerId | Determines which subscription controls a company when resolving plan limits and AI billing. |
| Share permissions | Share/collaborator tables | Grants direct object access such as view, edit, or manage, even when the user is not a member of the owning company. |
| AI billing context | backend AI gateway and background-tasks billing gate | Determines whether an AI operation is billed to a company, a user, or rejected for missing context. |
RDS Roles
Production and development RDS both contain these eight roles. Seven are company roles; support is a global role used by support tooling.
| Role | Scope | Code tier | Production assignments | Production permission links | Development permission links |
|---|---|---|---|---|---|
Super Admin | Company | Admin | 0 company, 0 global | 39 | 39 |
Company Admin | Company | Admin | 15 company, 0 global | 35 | 35 |
Internal Sales | Company | Member | 10 company, 0 global | 27 | 27 |
External Sales | Company | Viewer | 1 company, 0 global | 1 | 1 |
Economy | Company | Viewer | 0 company, 0 global | 13 | 13 |
Recruiter/HR | Company | Member | 0 company, 0 global | 27 | 27 |
Auditor/Viewer | Company | Viewer | 0 company, 0 global | 13 | 13 |
support | Global | Support | 0 company, 7 global | 0 | 0 |
Assignment counts are a point-in-time RDS snapshot. Role names, scopes, and code tiers are the authorization contract.
The role tier helpers live in src/common/constants/roles.ts:
ADMIN_ROLES:Super Admin,Company AdminMEMBER_ROLES:Internal Sales,Recruiter/HRVIEWER_ROLES:Auditor/Viewer,External Sales,EconomyMUTATION_ALLOWED_ROLES: admin + member rolesINVITER_ROLES: admin roles +Internal Sales
User.isSuperAdmin is a platform flag. Role.name === "Super Admin" is a company role. Some guards check the platform flag, some check the company role, and they are not interchangeable.
RDS Permission Matrix
Production and development RDS contain 39 permissions across 13 resources. Permission names use resource_READ, resource_WRITE, and resource_DELETE. The matrix below is the _RolePermissions state and matches seed-essential.ts.
Legend:
R: readW: writeD: delete-: no seeded permission
| Resource | Super Admin | Company Admin | Internal Sales | External Sales | Economy | Recruiter/HR | Auditor/Viewer | support |
|---|---|---|---|---|---|---|---|---|
| CV Database | R/W/D | R/W/D | R/W/D | - | R | R/W/D | R | - |
| Contact List | R/W/D | R/W/D | R/W/D | - | R | R/W/D | R | - |
| Economy | R/W/D | R/W/D | - | - | R/W/D | - | R | - |
| Grow Company | R/W/D | R/W | R | - | R | R | R | - |
| Network Expansion | R/W/D | R/W | R/W/D | - | R | R/W/D | R | - |
| Opportunities | R/W/D | R/W/D | R/W/D | R | R | R/W/D | R | - |
| Projects | R/W/D | R/W/D | R/W | - | - | R/W | R | - |
| Share CV List | R/W/D | R/W/D | R/W/D | - | - | R/W/D | R | - |
| Share Job List | R/W/D | R/W/D | R/W/D | - | - | R/W/D | R | - |
| Statistics | R/W/D | R/W | R | - | R | R | R | - |
| Subscriptions | R/W/D | R/W | R | - | R/W | R | R | - |
| Task Board | R/W/D | R/W/D | R/W/D | - | R | R/W/D | R | - |
| Tracking System | R/W/D | R/W/D | R | - | R | R | R | - |
RDS has role-permission links, but application resolvers do not currently use @HasPermission(...). Runtime authorization is mostly enforced through JwtAuthGuard, role-name checks, tier helpers, domain authorization services, sharing services, plan guards, and AI billing gates.
The eight roles and 39 permissions above are the platform-seeded global baseline. Company Admins can now create their own custom company roles with arbitrary tiers and permission sets through the IAM Dashboard. The global matrix is still the read-only foundation; custom roles extend it per company. See Custom Company Roles below.
Custom Company Roles
Company Admins are no longer limited to assigning the seven seeded company roles. Through the IAM Dashboard (surfaced at /userprofile/iam), an admin can author custom roles scoped to their company.
| Capability | Mutation | Applies to |
|---|---|---|
| Create a new company role | CREATE_COMPANY_ROLE | New custom role |
| Clone an existing role into an editable copy | CLONE_ROLE_AS_CUSTOM (cloneRoleAsCustom) | Any role — global or custom |
| Edit a custom role | UPDATE_COMPANY_ROLE | Custom only |
| Delete a custom role | DELETE_COMPANY_ROLE | Custom only |
Global roles (the eight above) are read-only in the UI — Edit/Delete are disabled with the hint to clone them into an editable copy first. A custom role carries a backend tier field (Admin / Member / Viewer) chosen in the role editor; the frontend's getRoleTier(roleName, tier?) reads that field as a fallback for names not present in the ADMIN_ROLES / MEMBER_ROLES / VIEWER_ROLES arrays. So a custom role's write/manage gating follows the tier the admin selected.
Assignable-role whitelist
Not every role is assignable by a company admin — platform-only roles must never be handed out from the company UI.
GET_COMPANY_ASSIGNABLE_ROLES(companyAssignableRoles) filters platform-only roles (super admin, support) server-side, returning only the roles a company admin may assign.- The frontend intersects role IDs against that result and, while the query is still loading, defensively hides super admin / support so a privileged role can never appear in an assignable dropdown.
Role reassignment and audit
- Reassignment — Changing a member's role in the IAM Members tab issues
UPDATE_USER_COMPANY_ROLE(updateUserCompanyRole). The billing owner's row is locked; ownership moves via Company Ownership Transfer, not a role edit. - Audit trail —
ROLE_CHANGE_AUDIT(roleChangeAudit) records each reassignment (actor, target, old role → new role, reason, timestamp). The IAM dashboard renders the last 8 entries as a "Recent role changes" feed.
Per-member navigation gating (Phase 1)
Each jobSeekerNavItems / teamNavItems entry (lib/config/navigation.ts) carries a requiredPermission key (e.g. jobseeker.dashboard.view, pipeline.view, team.view). The Sidebar hides an item when the member lacks the effective permission. This defaults to show-all until the backend ships effectivePermissions on whoAmI; the keys are wired now so per-member visibility becomes a data change later. See RBAC Frontend → requiredPermission nav gating.
Backend Guards
| Guard | Decorator or input | Rule |
|---|---|---|
JwtAuthGuard | Authorization: Bearer <jwt> | Verifies the JWT with JWT_SECRET, loads the user with roles, and attaches it to req.user. |
SseAuthGuard | ?token= or bearer header | Same basic JWT authentication for SSE. Query tokens work but can leak in infrastructure logs. |
RoleGuard | @HasRole(...) | Allows if the user has at least one required role on the resolved company. If no @HasRole metadata exists, the guard is a no-op. |
PermissionGuard | @HasPermission(...) | Allows if any role on the resolved company has one of the required permission names. If no metadata exists, the guard is a no-op. |
SuperAdminGuard | @RequireSuperAdmin() | Requires req.user.isSuperAdmin === true. |
CompanyWriteGuard | company context from GraphQL variables or selected company | Blocks viewer-tier company roles from state-changing company mutations. Platform super admins bypass this guard. |
SupportAgentGuard | support role or platform super admin | Allows platform super admins or users assigned the global support role. |
PlanLimitGuard | @CheckPlanLimit(feature) | Checks the company plan and usage for a specific feature limit. If no feature or no selected company exists, it does not block. |
Company Context Rules
RoleGuard resolves the company from:
variables.companyIdvariables.iduser.selectedCompanyId
CompanyWriteGuard resolves the company from:
variables.companyIdvariables.input.companyIduser.selectedCompanyId
CompanyWriteGuard deliberately does not use a bare id variable because many mutations use id for a resource ID, not a company ID.
CompanyWriteGuard Viewer Blocker
CompanyWriteGuard blocks company-scoped write mutations when the caller's role on the resolved company is in VIEWER_ROLES:
External SalesEconomyAuditor/Viewer
Platform super admins bypass this guard through User.isSuperAdmin === true.
Canonical error messages:
| Condition | Message |
|---|---|
| Unauthenticated caller | User is not authenticated |
| Missing company context | Company context is required for this action. |
| No role on resolved company | You do not have a role on this company. |
| Viewer-tier role | Your role is read-only on this company. Ask a Company Admin to perform this action. |
Company And Team Rules
Company Creation
An authenticated user can create a company, subject to the maxCompanies plan limit. The service sets Company.billingOwnerId to the creator and creates company billing configuration.
The reviewed CompanyService.create(...) path does not create a UserCompanyRole for the creator. If a flow relies on the creator immediately having a company role, confirm the onboarding path creates that membership elsewhere.
Updating And Deleting Companies
Company update and delete mutations use RoleGuard with:
Company AdminSuper Admin
The delete service also checks that the caller has an admin-tier company role through isAdminRole(...).
Inviting Members
The new-invitation mutation requires:
- authenticated user
- company role in
Company Admin,Super Admin, orInternal Sales maxUsersPerCompanyplan capacity- a target role that exists
- no duplicate pending invitation for the same company and email
- the invitee is not already a company member
The service repeats the inviter role check using INVITER_ROLES, so the resolver guard is not the only protection.
Resending Invitations
Resend requires the caller to have an inviter role on the invitation's company:
Super AdminCompany AdminInternal Sales
Resend does not consume another maxUsersPerCompany slot. It refreshes the token, sets the invitation back to PENDING, and sends a new email. Accepted invitations cannot be resent.
Accepting Invitations
Invitation acceptance is token-based and public. The service:
- finds the invitation by token
- rejects already accepted invitations
- finds or creates the user
- creates the
UserCompanyRole - sets
selectedCompanyIdif the user does not have one - marks the invitation
ACCEPTED
If the accepted role is Company Admin and the company has no billing owner, the accepting user becomes Company.billingOwnerId.
Removing Members
User-facing member removal requires:
- authenticated caller
- the caller is not removing themself
- platform super admin, or admin-tier role on the target member's company
- non-super-admin callers cannot remove the
billingOwnerIdmember role
If the removed membership was linked to an invitation, the invitation is set to CANCELLED in the same transaction.
Transferring Ownership
Ownership transfer is allowed only for:
- the current
Company.billingOwnerId - a platform super admin
The service promotes or creates the new owner's membership as Company Admin, demotes other company admins to Internal Sales, and updates Company.billingOwnerId.
Resource Sharing Rules
The platform uses share and collaborator tables for object-level authorization in addition to company roles.
Common permission enums:
| Enum | Values | Typical use |
|---|---|---|
AccessPermission | VIEW, EDIT, MANAGE | Share records such as talent pool, pipeline, and user/company shares. |
PermissionLevel | READ_ONLY, EDIT, ADMIN | Collaborators and some talent/CV sharing paths. |
Talent And CV Access
A user can access talent data when at least one of these is true:
- they own the talent
- they are a member of the owning talent-pool company
- the talent is shared with the user or one of their companies
- they have collaborator access
- the talent is included in a shared talent list they can access
Talent write access is narrower:
- talent owner
- company role
Company Admin,Super Admin, orRecruiter/HRon the owning company - explicit talent share above read-only
CV access follows associated talent access. Direct CV write access is blocked for application snapshots and restricted when the active CV owner is another user.
Talent Pool Access
Talent pool access can come from:
- pool creator
- company membership for company pools
- public pool visibility
- user, company, or collaborator share
Talent pool write access can come from:
- creator
- company role
Company Admin,Super Admin, orRecruiter/HR - user/company/collaborator share with
EDITorMANAGE
Public pools are treated as readable by broad list filters, but non-creators do not get write access just because a pool is public.
Pipeline Access
Pipeline access can come from:
- membership in the pipeline company
- collaborator access through shared talents
- pipeline company share
- pipeline user share
Pipeline write access can come from:
- pipeline owner
- company role
Company Admin,Super Admin, orRecruiter/HR - pipeline user or company share with
EDITorMANAGE
Public And External CV Views
Public or external CV access masks personal data. The helper removes or masks fields including email, phone, date of birth, gender, street address, city, state, zip code, profile image URL, and salary preference fields.
Plan And Billing Rules
Plan limits are company-scoped unless the operation is explicitly personal. The backend resolves company subscription control through the company billing owner.
Company subscription resolution uses:
Company.billingOwnerId- fallback to the earliest
Company Adminmembership when no billing owner exists
Active subscription statuses are:
ACTIVETRIALING
Postpaid enterprise plans can be blocked when the subscription status is UNPAID.
Backend Plan Limits
PlanLimitGuard and PlanLimitsService enforce feature limits such as:
maxJobsmaxTalentPoolsmaxContactsmaxPipelinesmaxInterviewsPerMonthmaxMeetingsPerMonthmaxCvCollectionsmaxSharedTalentListsmaxCollaborationInvitesmaxConnectionRequestsPerMonthmaxUsersPerCompanymaxNewsPerMonthmaxContactNotesmaxCvParsesPerMonthmaxAgentMessagesPerMonthmaxTextRewritesPerMonth
Monthly limits are counted from the start of the current UTC month. A limit of -1 means unlimited. A limit of 0 disables the feature.
Postpaid plans are generally unlimited, but CompanyBillingConfig.allocated* overrides can cap individual features.
Credit Balance Routing
Credit balance routing depends on the user's view mode:
EMPLOYER+selectedCompanyId: use the company credit balance- otherwise: use the user's personal credit balance
AI Authorization And Billing Rules
The backend AI gateway is the first gate for AI operations. The background-tasks service is the final billing and execution gate.
Backend AI Gateway
The following GraphQL entry points require JwtAuthGuard:
subscribeAiqueryAimutateAi
Each operation must be present in the backend operation registry. Unknown operations are rejected before reaching background-tasks.
The gateway resolves an actor company ID with this order:
- if
user.viewMode === "JOB_SEEKER", no company context is used user.selectedCompanyId- active company subscription owned by the user
- company where the user is
billingOwnerId null
For an AI operation, AiPolicyService requires an active subscription when either of these registry fields is set:
stampBillingContextplanLimitKey
If a planLimitKey exists and a company context exists, the gateway also calls PlanLimitsService.checkLimit(...). If no company context exists, subscription and plan-limit checks are skipped at the gateway and the background-tasks billing gate becomes the final authority.
Current Subscription Operations
| Operation | Stamps billing context | Backend plan limit | Gateway subscription precheck |
|---|---|---|---|
extractCv | Yes | maxCvParsesPerMonth | Yes |
parseJob | Yes | - | Yes |
faceExtraction | Yes | - | Yes |
agentChat | No | maxAgentMessagesPerMonth | Yes |
textRewrite | Yes | maxTextRewritesPerMonth | Yes |
generateMeetingInsight | No | - | No |
addCandidate | No | - | No |
userJobMatching | No | - | No |
bulkCvImport | No | - | No |
documentProcessing | No | - | No |
Current HTTP AI operations are registry-allowlisted but do not stamp billing context and do not declare backend plan limits.
Background-Tasks Authentication
The AI service accepts internal service JWTs by default:
- signed with
INTERNAL_SERVICE_SECRETorINTERNAL_SERVICE_SECRET_PREVIOUS - audience
aiqlick-background-tasks - issuer
aiqlick-backend - requires
actorUserId - normalizes
actorCompanyIdtocompanyId
Legacy user JWTs are disabled by default and only accepted when ALLOW_USER_JWT=true.
The GraphQL context extracts tokens lazily from WebSocket connection params, HTTP headers, or ?token=, but resolvers or extensions must call ensure_auth() to enforce authentication.
Background-Tasks Billing Gate
BillingGate requires billing context when billing is enabled:
- company context for employer/company operations
- user context for personal operations
If both company ID and user ID are missing and billing is enabled, the operation is rejected. If billing is disabled, the gate can skip credit deduction, but it still enforces unpaid postpaid blocking.
Billing mode rules:
- active company subscription: company mode
- active personal subscription: user mode
- postpaid plan: log usage, do not deduct credits
- prepaid plan: pre-check and consume credits
- unpaid postpaid: block
Background-tasks plan-limit mapping currently includes:
| AI operation type | Plan field |
|---|---|
CV_EXTRACTION | maxCvParsesPerMonth |
JOB_PARSING | maxJobs |
MEETING_INSIGHT | maxMeetingsPerMonth |
AGENT_CHAT | maxAgentMessagesPerMonth |
TEXT_REWRITE | maxTextRewritesPerMonth |
Unknown AI operation types have no background-tasks plan limit configured.
Review Findings And Gaps
The following findings come from code review and should be treated as implementation notes for future hardening.
| Area | Finding |
|---|---|
| Permission guard usage | PermissionGuard exists, but application resolvers are not currently using @HasPermission(...). Seeded permissions are therefore not the primary runtime enforcement mechanism. |
| Legacy role/permission CRUD | The older role, permission, and role-permission CRUD resolvers appear to expose mutations without the same JwtAuthGuard + SuperAdminGuard protection used by the admin role-management API. Prefer the guarded admin role-management API or lock down the legacy resolvers. |
RoleGuard no-op behavior | RoleGuard does nothing unless @HasRole(...) metadata is present. Resolver classes that only add @UseGuards(RoleGuard) do not get a role check from that alone. |
| Company creator membership | Company creation sets the creator as billing owner, but the reviewed service path does not create a company membership role. Confirm onboarding creates the membership before relying on role-based access for new company creators. |
| Billing-owner removal protection | User-facing member removal protects the billing-owner membership. The super-admin role revocation path should be checked before using it to remove a billing owner. |
| AI gateway versus background billing | Gateway prechecks only apply to operations with stampBillingContext or planLimitKey, and only when a company context is resolved. Background-tasks remains the final billing gate. |
Implementation Rules Of Thumb
For new company-scoped write mutations:
- require
JwtAuthGuard - use
CompanyWriteGuardfor viewer-tier blocking - add
PlanLimitGuardwhen the mutation consumes a plan-limited feature - use a domain authorization service for object-specific ownership/share checks
- pass an explicit
companyIdorinput.companyIdinstead of relying on a bareid
For new admin-only platform operations:
- require
JwtAuthGuard - require
SuperAdminGuard - add
@RequireSuperAdmin()
For new AI operations:
- add the operation to the backend registry
- set
stampBillingContextwhen the operation should be billed to the actor company or user - set
planLimitKeywhen the operation consumes a plan-limited feature - ensure the background-tasks resolver calls
ensure_auth() - call
BillingGate.pre_check(...)andBillingGate.check_and_consume(...)around billable execution - log failures through the billing gate when execution fails after pre-check