Skip to content

Company authorization

Company-side authorization is membership-, permission-, and resource-scope-based. The JWT Role.COMPANY value identifies the broad company persona only; it does not grant access by itself.

Identity and tenant context

After normal JWT authentication, the auth plugin resolves the user's one active CompanyMembership. It attaches this internal actor context to the request:

interface ActorContext {
  userId: string;
  membershipId: string;
  companyId: string;
  authorizationVersion: number;
}

companyId is always the canonical Company.id. Company APIs must not accept a client-selected company context. A missing, suspended, or revoked membership is rejected before the use case runs.

Subscriber and employee personas are mutually exclusive. An invitation is rejected when its normalized email or mobile number belongs to a subscriber, an employee of another company, or an existing member of the same company.

Authorization model

flowchart LR
    U[User] --> M[CompanyMembership]
    M --> A[MembershipRoleAssignment]
    A --> R[AccessRole]
    R --> RP[RolePermission]
    RP --> P[Permission]
    A -->|COMPANY| C[All company resources]
    A -->|BRANCH| B[One branch and its programs]
    A -->|PROGRAM| G[One program]

Permissions are additive. There are no explicit deny grants. An assignment must use the role's declared scope:

Scope Required target Effective reach
COMPANY no branch or program ID the company and all of its branches and programs
BRANCH exactly one branchId the branch and all programs owned by it
PROGRAM exactly one programId that program only

Database constraints and a tenancy trigger reject mismatched role scopes, cross-company custom roles, and cross-company branch/program targets. Application checks also prevent an actor from assigning permissions or resources that the actor cannot access.

Built-in roles

Built-in roles are immutable. Roles that support both branch and program scopes exist as separate role records with the same system-role family.

Role Scope variants Purpose
OWNER company complete access, including ownership transfer
COMPANY_ADMIN company company operations except ownership transfer
BRANCH_MANAGER branch branch profile/banking and its program operations
PROGRAM_MANAGER branch, program program configuration and lifecycle
AUCTION_OPERATOR branch, program auction read, configuration, scheduling, operation, bids, prebids, and winners
SUBSCRIPTION_MANAGER branch, program program read, subscribers, subscriptions, statements, and payments
VIEWER company, branch, program read-only access within the assigned scope

The immutable catalog contains action-based permissions for company profile, compliance, MOU, policy and billing; branch profile, address, banking and lifecycle; employee and role administration; program lifecycle and transfer; subscriptions, subscribers, statements and payments; auctions and winners; and documents, templates, presets, and security types.

Custom roles

Custom roles belong to one company and have a fixed scope. Their permission set must be valid at that scope:

  • company roles may contain company, branch, and program permissions;
  • branch roles may contain branch and program permissions;
  • program roles may contain program permissions only;
  • ownership-only permissions cannot be added;
  • system roles cannot be edited or deleted;
  • assigned custom roles return 409 Conflict on deletion;
  • names are unique within a company and scope.

Changing a role invalidates effective-access caches for the company.

Ownership safeguards

OWNER cannot be granted through the normal assignment API. It is created only during company provisioning or the explicit ownership-transfer operation.

Every active company must retain at least one active owner. The rule is enforced twice:

  1. the application rejects suspension/revocation of the last owner; and
  2. deferred PostgreSQL constraint triggers verify the invariant at transaction commit while serializing owner-changing transactions per company.

Only an owner can manage another owner. Company admins cannot add or remove the OWNER role.

Runtime checks

Company use cases use the authorization service rather than interpreting loggedUserId as a tenant ID:

await authorizationService.assertCompanyPermission(actor, 'employee.invite');
await authorizationService.assertBranchPermission(
  actor,
  branchId,
  'branch.profile.manage'
);
await authorizationService.assertProgramPermission(actor, programId, 'auction.operate');

List queries call resolveScopedFilters and apply the resulting company, branch, and program predicates in the repository query. Filtering must happen in PostgreSQL, not after an unscoped result has been loaded.

For a guessed branch, program, cycle, invoice, or auction outside the actor's scope, the API returns 404 Not Found when revealing existence would leak tenant data. 403 Forbidden is reserved for non-resource-specific permission failures where existence is not sensitive.

Cache and session revocation

Effective access is cached in Redis under a key containing the user, membership, company, and authorizationVersion. Role, assignment, membership, and branch-status mutations invalidate affected keys synchronously.

Suspending or revoking a membership also revokes all active sessions in the same transaction. The next authenticated request therefore cannot continue with stale privileges.

Audit trail

Branch, employee, invitation, role, assignment, ownership, program-transfer, and auction-control mutations write structured AuthorizationAuditLog records. Each record contains the canonical company, actor user, action, target type and ID, optional before/after JSON, correlation/request ID, and timestamp. Tokens, OTPs, and other secrets must never be included.

Route-level roles

JWT roles still provide the outer persona boundary:

JWT role Meaning
SUBSCRIBER individual program participant
COMPANY owner or employee; membership permissions decide access
SUPERADMIN platform administrator

fastify.authorize(['COMPANY']) is necessary but not sufficient. The use case must perform the appropriate scoped permission assertion, and the repository must retain the same tenant predicate.

Verification checklist

  • Test same-company allow and cross-company guessed-ID denial.
  • Test company inheritance, two independent branch grants, and a single-program grant.
  • Test that auction-only employees cannot edit programs or subscriptions.
  • Test immediate denial after assignment removal or membership suspension.
  • Test privilege-escalation prevention and the last-owner invariant.
  • Test the same branch code in different companies and conflicts inside one company.
  • Test concurrent invitation acceptance and duplicate branch-code writes.