Skip to content
Open
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"express": "^4.21.2",
"helmet": "^8.1.0",
"jose": "^6.0.12",
"jspdf": "^3.0.3",
"jspdf": "^4.1.0",
"mammoth": "^1.8.0",
"nanoid": "^5.1.6",
"pdf-lib": "^1.17.1",
Expand Down
24 changes: 22 additions & 2 deletions apps/api/src/auth/auth-context.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ export const AuthContext = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AuthContextType => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();

const { organizationId, authType, isApiKey, userId, userEmail, userRoles } =
request;
const {
organizationId,
authType,
isApiKey,
userId,
userEmail,
userRoles,
memberId,
memberDepartment,
} = request;

if (!organizationId || !authType) {
throw new Error(
Expand All @@ -25,6 +33,8 @@ export const AuthContext = createParamDecorator(
userId,
userEmail,
userRoles,
memberId,
memberDepartment,
};
},
);
Expand Down Expand Up @@ -69,6 +79,16 @@ export const UserId = createParamDecorator(
},
);

/**
* Parameter decorator to extract the member ID (only available for session auth)
*/
export const MemberId = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string | undefined => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
return request.memberId;
},
);

/**
* Parameter decorator to check if the request is authenticated via API key
*/
Expand Down
17 changes: 15 additions & 2 deletions apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,22 @@ import { ApiKeyGuard } from './api-key.guard';
import { ApiKeyService } from './api-key.service';
import { HybridAuthGuard } from './hybrid-auth.guard';
import { InternalTokenGuard } from './internal-token.guard';
import { PermissionGuard } from './permission.guard';

@Module({
providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard],
exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard],
providers: [
ApiKeyService,
ApiKeyGuard,
HybridAuthGuard,
InternalTokenGuard,
PermissionGuard,
],
exports: [
ApiKeyService,
ApiKeyGuard,
HybridAuthGuard,
InternalTokenGuard,
PermissionGuard,
],
})
export class AuthModule {}
4 changes: 4 additions & 0 deletions apps/api/src/auth/hybrid-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ export class HybridAuthGuard implements CanActivate {
deactivated: false,
},
select: {
id: true,
role: true,
department: true,
},
});

Expand All @@ -190,6 +192,8 @@ export class HybridAuthGuard implements CanActivate {
request.userId = userId;
request.userEmail = userEmail;
request.userRoles = userRoles;
request.memberId = member?.id; // Set member ID for assignment filtering
request.memberDepartment = member?.department; // Set department for visibility filtering
request.organizationId = explicitOrgId;
request.authType = 'jwt';
request.isApiKey = false;
Expand Down
171 changes: 171 additions & 0 deletions apps/api/src/auth/permission.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { PermissionGuard, PERMISSIONS_KEY } from './permission.guard';

describe('PermissionGuard', () => {
let guard: PermissionGuard;
let reflector: Reflector;

const mockConfigService = {
get: jest.fn().mockReturnValue('http://localhost:3000'),
};

const createMockExecutionContext = (
request: Partial<{
isApiKey: boolean;
userRoles: string[] | null;
headers: Record<string, string>;
organizationId: string;
}>,
): ExecutionContext => {
return {
switchToHttp: () => ({
getRequest: () => ({
isApiKey: false,
userRoles: null,
headers: {},
organizationId: 'org_123',
...request,
}),
}),
getHandler: () => jest.fn(),
getClass: () => jest.fn(),
} as unknown as ExecutionContext;
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PermissionGuard,
Reflector,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();

guard = module.get<PermissionGuard>(PermissionGuard);
reflector = module.get<Reflector>(Reflector);
});

describe('canActivate', () => {
it('should allow access when no permissions are required', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);

const context = createMockExecutionContext({});
const result = await guard.canActivate(context);

expect(result).toBe(true);
});

it('should allow access for API keys (with warning)', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([
{ resource: 'control', actions: ['delete'] },
]);

const context = createMockExecutionContext({ isApiKey: true });
const result = await guard.canActivate(context);

expect(result).toBe(true);
});

it('should deny access when user has no roles', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([
{ resource: 'control', actions: ['delete'] },
]);

// Mock fetch to fail so it uses fallback
global.fetch = jest
.fn()
.mockRejectedValue(new Error('Network error')) as unknown as typeof fetch;

const context = createMockExecutionContext({
userRoles: null,
headers: { authorization: 'Bearer token' },
});

await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException,
);
});

it('should allow access for privileged roles in fallback mode', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([
{ resource: 'control', actions: ['delete'] },
]);

// Mock fetch to fail so it uses fallback
global.fetch = jest
.fn()
.mockRejectedValue(new Error('Network error')) as unknown as typeof fetch;

const context = createMockExecutionContext({
userRoles: ['admin'],
headers: { authorization: 'Bearer token' },
});

const result = await guard.canActivate(context);
expect(result).toBe(true);
});

it('should deny access for restricted roles in fallback mode', async () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([
{ resource: 'control', actions: ['delete'] },
]);

// Mock fetch to fail so it uses fallback
global.fetch = jest
.fn()
.mockRejectedValue(new Error('Network error')) as unknown as typeof fetch;

const context = createMockExecutionContext({
userRoles: ['employee'],
headers: { authorization: 'Bearer token' },
});

await expect(guard.canActivate(context)).rejects.toThrow(
ForbiddenException,
);
});
});

describe('isRestrictedRole', () => {
it('should return true for employee role', () => {
expect(PermissionGuard.isRestrictedRole(['employee'])).toBe(true);
});

it('should return true for contractor role', () => {
expect(PermissionGuard.isRestrictedRole(['contractor'])).toBe(true);
});

it('should return false for admin role', () => {
expect(PermissionGuard.isRestrictedRole(['admin'])).toBe(false);
});

it('should return false for owner role', () => {
expect(PermissionGuard.isRestrictedRole(['owner'])).toBe(false);
});

it('should return false for program_manager role', () => {
expect(PermissionGuard.isRestrictedRole(['program_manager'])).toBe(false);
});

it('should return false for auditor role', () => {
expect(PermissionGuard.isRestrictedRole(['auditor'])).toBe(false);
});

it('should return false if user has both employee and admin roles', () => {
expect(PermissionGuard.isRestrictedRole(['employee', 'admin'])).toBe(
false,
);
});

it('should return true for null roles', () => {
expect(PermissionGuard.isRestrictedRole(null)).toBe(true);
});

it('should return true for empty roles array', () => {
expect(PermissionGuard.isRestrictedRole([])).toBe(true);
});
});
});
Loading