Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Codefied-CodePix/Karokar-backend/llms.txt

Use this file to discover all available pages before exploring further.

Authorization in KaroKar is capability-driven, not title-driven. Rather than asking “Is this user a Corporate Admin?”, every access decision asks “Does this user hold the permission to perform this specific action within this organization?” This distinction matters enormously at scale: roles are implementation details that may change, proliferate, or be customized per enterprise customer, but permissions are stable expressions of business capabilities. The entire authorization chain — from JWT extraction through guard evaluation to ownership validation — is built around permissions as the single source of truth.

The Authorization Chain

User
  └── OrganizationMember   (userId + organizationId + roleId)
        └── Role            (organizationType + name + isSystemRole)
              └── Permissions  (resource + action)
A user gains capabilities by belonging to an organization through an OrganizationMember record. That membership references a Role. The role is a named collection of Permission records. When a request arrives, the authorization system resolves this chain and evaluates whether the resolved permission set contains the required permission for the requested action.

Role Entity

// src/identity/domain/entities/role.entity.ts
@Entity('roles')
export class Role extends BaseEntity {
  @Column({ type: 'enum', enum: OrganizationType, name: 'organization_type' })
  organizationType!: OrganizationType;

  @Column()
  name!: string;

  @Column({ type: 'text', nullable: true })
  description!: string | null;

  @Column({ name: 'is_system_role', default: false })
  isSystemRole!: boolean;
}
Roles are scoped to an OrganizationType. A VENDOR_ADMIN role can only be assigned within a VENDOR organization — the data model enforces this at the role definition level.

Permission Entity

// src/identity/domain/entities/permission.entity.ts
@Entity('permissions')
export class Permission extends BaseEntity {
  @Column()
  resource!: string;

  @Column()
  action!: string;
}

Permission Naming Convention

All permissions follow a strict resource.action format. This makes permission strings self-documenting and consistent across every domain.
vehicle.create       vehicle.update      vehicle.read        vehicle.suspend
booking.create       booking.approve     booking.reject      booking.cancel      booking.terminate
assignment.create    assignment.read     assignment.accept   assignment.reject   assignment.close
employee.manage      organization.approve
The resource segment maps to a domain aggregate. The action segment maps to a business operation within that aggregate’s lifecycle.

Phase 01 Role-Permission Mappings

PLATFORM_ADMIN

Full platform governance. Can approve and suspend organizations, review verifications, and access global reports.Key permissions: organization.approve, vehicle.read, booking.read

VENDOR_ADMIN

Manages the vendor fleet and responds to booking requests from corporate organizations.Permissions: vehicle.create, vehicle.update, vehicle.read, booking.read, booking.approve, booking.reject

CORPORATE_ADMIN

Creates bookings on behalf of the corporate organization and manages employee memberships.Permissions: vehicle.read, booking.create, booking.read, assignment.create, employee.manage

EMPLOYEE

Read-only access to their own bookings and the ability to respond to vehicle assignments.Permissions: assignment.read, assignment.accept, assignment.reject, booking.read

The @RequirePermission Decorator

Every protected controller method is annotated with a single decorator that declares the required permission string. The decorator stores the value as route metadata using NestJS’s SetMetadata.
// src/shared/decorators/require-permission.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const PERMISSION_KEY = 'permission';

export const RequirePermission = (permission: string) =>
  SetMetadata(PERMISSION_KEY, permission);

Example Usage on a Controller

import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { RequirePermission } from '../../shared/decorators/require-permission.decorator';
import { PermissionGuard } from '../../shared/guards/permission.guard';

@Controller('bookings')
@UseGuards(PermissionGuard)
export class BookingController {

  @Post()
  @RequirePermission('booking.create')
  async createBooking(@Body() dto: CreateBookingDto) {
    // Only reached if the caller holds booking.create in their org
    return this.bookingService.create(dto);
  }

  @Post(':id/approve')
  @RequirePermission('booking.approve')
  async approveBooking(@Param('id') id: string) {
    return this.bookingService.approve(id);
  }
}

How PermissionGuard Works

The PermissionGuard runs on every request that has a @RequirePermission annotation. It reads the permission key from route metadata, extracts userId and organizationId from the authenticated request, and delegates the evaluation to the IAuthorizationService.
// src/shared/guards/permission.guard.ts
@Injectable()
export class PermissionGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    @Inject(AUTHORIZATION_SERVICE)
    private readonly authorizationService: IAuthorizationService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const permission = this.reflector.getAllAndOverride<string | undefined>(
      PERMISSION_KEY,
      [context.getHandler(), context.getClass()],
    );

    if (!permission) {
      return true; // No annotation → unprotected route
    }

    const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
    const user = request.user;

    const userId = user && 'userId' in user ? user.userId : undefined;
    const organizationId =
      request.organizationId ??
      (user && 'organizationId' in user ? user.organizationId : undefined);

    if (!userId || !organizationId) {
      throw new UnauthorizedException();
    }

    const allowed = await this.authorizationService.can(
      userId,
      organizationId,
      permission,
    );

    if (!allowed) {
      throw new ForbiddenException();
    }

    return true;
  }
}
The guard enforces two requirements in sequence:
  1. Authentication checkuserId and organizationId must both be present on the request. If either is missing, a 401 Unauthorized is thrown.
  2. Permission checkauthorizationService.can(userId, organizationId, permission) resolves the user’s membership in that specific organization, walks the Role → Permission chain, and returns a boolean. If false, a 403 Forbidden is thrown.
AuthorizationService.can() is not yet implemented. The concrete AuthorizationService class currently throws NotImplementedException on every call. The IAuthorizationService interface and PermissionGuard infrastructure are fully wired, but the Role → Permission resolution query has not been written yet. All protected routes will return 501 Not Implemented until this method is completed.

Organization Isolation

Permission checks alone are insufficient. A user with booking.approve in Vendor A must never be able to approve a booking that belongs to Vendor B — even if both vendors share the same role definition. The authorization model therefore combines two distinct checks:
  1. Permission check — Does the user hold the required capability?
  2. Ownership check — Does the resource being acted upon belong to the user’s current organization?
Both checks must pass. Ownership validation is the responsibility of the service layer, executed after the guard confirms the permission is present.
Never authorize based on role names in business logic. Code such as if (user.role === 'CorporateAdmin') is explicitly forbidden. It creates maintenance nightmares, cannot support custom enterprise roles, and duplicates logic that belongs in the permission layer. Always use authorizationService.can(userId, organizationId, 'permission.name') — or the @RequirePermission decorator at the controller boundary.

Organization Context Propagation

The OrganizationContextInterceptor runs before guards and extracts organizationId from the JWT payload, attaching it directly to request.organizationId. This ensures the PermissionGuard always has the tenant context available without requiring controllers to parse the token themselves.
// src/shared/interceptors/organization-context.interceptor.ts
@Injectable()
export class OrganizationContextInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
    const user = request.user as JwtPayload | undefined;

    if (user?.organizationId) {
      request.organizationId = user.organizationId;

      if (!('userId' in user) || !user.userId) {
        request.user = {
          userId: user.sub ?? user.userId ?? '',
          organizationId: user.organizationId,
        };
      }
    }

    return next.handle();
  }
}

Future-Proofing

The current Phase 01 roles (PLATFORM_ADMIN, VENDOR_ADMIN, CORPORATE_ADMIN, EMPLOYEE) are system roles seeded at boot time. The data model supports custom roles without any code changes: a new Role row with isSystemRole: false can be created for an enterprise customer and populated with any subset of existing Permission records. Future roles such as FLEET_MANAGER, FINANCE_MANAGER, APPROVER, and DRIVER are anticipated in the ADR and will require only data changes — not schema or application code changes.

Build docs developers (and LLMs) love