Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,010 changes: 964 additions & 46 deletions backend/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { RoleAuditLog } from './entities/role-audit-log.entity';
import { EmailServiceImpl } from './services/email.service';
import { EMAIL_SERVICE } from './interfaces/email-service.interface';
import { RolesService } from './services/roles.service';
import { RolesController } from './controllers/roles.controller';
import { PermissionsService } from './services/permissions.service';
import { RolesPermissionsSeeder } from './seeds/roles-permissions.seed';

Expand Down Expand Up @@ -75,7 +76,7 @@ import { RolesPermissionsSeeder } from './seeds/roles-permissions.seed';
}),
forwardRef(() => UsersModule),
],
controllers: [AuthController],
controllers: [AuthController, RolesController],
providers: [
AuthService,
JwtStrategy,
Expand Down
70 changes: 54 additions & 16 deletions backend/src/auth/constants/permission-definitions.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,91 @@
import { Permission } from './permissions.enum';
import { Resource, Action } from './permission-types.enum';

export interface PermissionDefinition {
name: Permission;
description: string;
resource: string;
action: string;
resource: Resource | '*';
action: Action | '*';
}

export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
// PetOwner
{
name: Permission.READ_OWN_PETS,
description: 'Read own pets',
resource: 'pets',
action: 'READ',
resource: Resource.PETS,
action: Action.READ,
},
{
name: Permission.UPDATE_OWN_PETS,
description: 'Update own pets',
resource: 'pets',
action: 'UPDATE',
resource: Resource.PETS,
action: Action.UPDATE,
},
{
name: Permission.CREATE_PETS,
description: 'Create pets',
resource: 'pets',
action: 'CREATE',
resource: Resource.PETS,
action: Action.CREATE,
},
{
name: Permission.SHARE_RECORDS,
description: 'Share pet medical records',
resource: Resource.MEDICAL_RECORDS,
action: Action.SHARE,
},

// Veterinarian
{
name: Permission.READ_ALL_PETS,
description: 'Read all pets',
resource: 'pets',
action: 'READ',
resource: Resource.PETS,
action: Action.READ,
},
{
name: Permission.UPDATE_MEDICAL_RECORDS,
description: 'Update medical records',
resource: 'medical_records',
action: 'UPDATE',
resource: Resource.MEDICAL_RECORDS,
action: Action.UPDATE,
},
{
name: Permission.CREATE_TREATMENTS,
description: 'Create treatments',
resource: 'treatments',
action: 'CREATE',
resource: Resource.TREATMENTS,
action: Action.CREATE,
},
{
name: Permission.PRESCRIBE,
description: 'Prescribe medication',
resource: Resource.TREATMENTS,
action: Action.PRESCRIBE,
},

// VetStaff
{
name: Permission.READ_ASSIGNED_PETS,
description: 'Read assigned pets',
resource: Resource.PETS,
action: Action.READ,
},
{
name: Permission.UPDATE_APPOINTMENTS,
description: 'Update appointments',
resource: Resource.APPOINTMENTS,
action: Action.UPDATE,
},
{
name: Permission.CREATE_NOTES,
description: 'Create clinical notes',
resource: Resource.NOTES,
action: Action.CREATE,
},

// Admin
{
name: Permission.ALL_PERMISSIONS,
description: 'All permissions (admin only)',
description: 'Full system access',
resource: '*',
action: '*',
},
];
];
16 changes: 16 additions & 0 deletions backend/src/auth/constants/permission-types.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export enum Resource {
PETS = 'pets',
MEDICAL_RECORDS = 'medical_records',
TREATMENTS = 'treatments',
APPOINTMENTS = 'appointments',
NOTES = 'notes',
}

export enum Action {
READ = 'READ',
CREATE = 'CREATE',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
SHARE = 'SHARE',
PRESCRIBE = 'PRESCRIBE',
}
15 changes: 11 additions & 4 deletions backend/src/auth/constants/permissions.enum.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
export enum Permission {
// PetOwner permissions
// PetOwner
READ_OWN_PETS = 'READ_OWN_PETS',
UPDATE_OWN_PETS = 'UPDATE_OWN_PETS',
CREATE_PETS = 'CREATE_PETS',
SHARE_RECORDS = 'SHARE_RECORDS',

// Veterinarian permissions
// Veterinarian
READ_ALL_PETS = 'READ_ALL_PETS',
UPDATE_MEDICAL_RECORDS = 'UPDATE_MEDICAL_RECORDS',
CREATE_TREATMENTS = 'CREATE_TREATMENTS',
PRESCRIBE = 'PRESCRIBE',

// Admin permission (grants all permissions)
// VetStaff
READ_ASSIGNED_PETS = 'READ_ASSIGNED_PETS',
UPDATE_APPOINTMENTS = 'UPDATE_APPOINTMENTS',
CREATE_NOTES = 'CREATE_NOTES',

// Admin
ALL_PERMISSIONS = 'ALL_PERMISSIONS',
}
}
3 changes: 2 additions & 1 deletion backend/src/auth/constants/roles.enum.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export enum RoleName {
PetOwner = 'PetOwner',
Veterinarian = 'Veterinarian',
VetStaff = 'VetStaff',
Admin = 'Admin',
}
}
64 changes: 64 additions & 0 deletions backend/src/auth/controllers/roles.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
Controller,
Post,
Body,
UseGuards,
Get,
Param,
} from '@nestjs/common';

import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator';
import { CurrentUser } from '../decorators/current-user.decorator';

import { RolesService } from '../services/roles.service';
import { AssignRoleDto, RemoveRoleDto } from '../dto/role.dto';

import { RoleName } from '../constants/roles.enum';
import { User } from '../../modules/users/entities/user.entity';

@Controller('roles')
@UseGuards(JwtAuthGuard, RolesGuard)
export class RolesController {
constructor(private readonly rolesService: RolesService) {}

/*
|--------------------------------------------------------------------------
| ASSIGN ROLE (ADMIN ONLY)
|--------------------------------------------------------------------------
*/
@Post('assign')
@Roles(RoleName.Admin)
async assignRole(
@Body() dto: AssignRoleDto,
@CurrentUser() admin: User,
) {
return this.rolesService.assignRole(dto, admin.id);
}

/*
|--------------------------------------------------------------------------
| REMOVE ROLE (ADMIN ONLY)
|--------------------------------------------------------------------------
*/
@Post('remove')
@Roles(RoleName.Admin)
async removeRole(
@Body() dto: RemoveRoleDto,
@CurrentUser() admin: User,
) {
return this.rolesService.removeRole(dto, admin.id);
}

/*
|--------------------------------------------------------------------------
| VIEW USER ROLES (ADMIN ONLY)
|--------------------------------------------------------------------------
*/
@Get('user/:userId')
@Roles(RoleName.Admin)
async getUserRoles(@Param('userId') userId: string) {
return this.rolesService.getUserRoles(userId);
}
}
3 changes: 2 additions & 1 deletion backend/src/auth/decorators/permissions.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Permission } from "../constants/permissions.enum"

export const PERMISSIONS_KEY = 'permissions';

Expand All @@ -7,5 +8,5 @@ export const PERMISSIONS_KEY = 'permissions';
* Allows fine-grained permission checks independent of roles
* @param permissions - Array of permission names (e.g., 'READ_OWN_PETS', 'CREATE_PETS')
*/
export const Permissions = (...permissions: string[]) =>
export const Permissions = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
80 changes: 44 additions & 36 deletions backend/src/auth/guards/roles.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,69 +22,77 @@ export class RolesGuard implements CanActivate {
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
// Get required roles and permissions from route metadata
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
const requiredPermissions =
this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);

const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
const requiredRoles =
this.reflector.getAllAndOverride<RoleName[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);

// If no roles or permissions are required, allow access
if (!requiredRoles && !requiredPermissions) {
// If nothing required → allow
if (!requiredPermissions?.length && !requiredRoles?.length) {
return true;
}

// Get current user from request (set by JwtAuthGuard)
const request = context.switchToHttp().getRequest();
const user: User = request.user;

if (!user) {
throw new ForbiddenException('User not authenticated');
}

// Check roles if required
if (requiredRoles && requiredRoles.length > 0) {
const userRoles = await this.rolesService.getUserRoles(user.id);
const userRoleNames = userRoles.map((role) => role.name);
/*
|--------------------------------------------------------------------------
| PERMISSION-BASED CHECK (PRIMARY)
|--------------------------------------------------------------------------
*/
if (requiredPermissions?.length) {
const userPermissions =
await this.rolesService.getUserPermissions(user.id);

// Check if user has at least one of the required roles
const hasRequiredRole = requiredRoles.some((requiredRole) =>
userRoleNames.includes(requiredRole as RoleName),
const hasAllPermissions = requiredPermissions.every((permission) =>
this.permissionsService.checkPermissionAccess(
userPermissions,
permission,
),
);

if (!hasRequiredRole) {
if (!hasAllPermissions) {
throw new ForbiddenException(
`Access denied. Required roles: ${requiredRoles.join(', ')}`,
`Access denied. Missing required permissions.`,
);
}

return true;
}

// Check permissions if required
if (requiredPermissions && requiredPermissions.length > 0) {
const userPermissions = await this.rolesService.getUserPermissions(
user.id,
);
/*
|--------------------------------------------------------------------------
| ROLE-BASED CHECK (SECONDARY / EXPLICIT)
|--------------------------------------------------------------------------
*/
if (requiredRoles?.length) {
const userRoles = await this.rolesService.getUserRoles(user.id);
const userRoleNames = userRoles.map((r) => r.name);

// Check if user has all required permissions
const hasAllPermissions = requiredPermissions.every(
(requiredPermission) =>
this.permissionsService.checkPermissionAccess(
userPermissions,
requiredPermission,
),
const hasRequiredRole = requiredRoles.some((role) =>
userRoleNames.includes(role),
);

if (!hasAllPermissions) {
if (!hasRequiredRole) {
throw new ForbiddenException(
`Access denied. Required permissions: ${requiredPermissions.join(', ')}`,
`Access denied. Required roles: ${requiredRoles.join(', ')}`,
);
}

return true;
}

return true;
}
}
}
Loading