Skip to content

Commit f943727

Browse files
[dev] [tofikwest] tofik/vendor-risk-task-assignment (#1947)
* feat: task assignment for vendor and records * refactor(auth): simplify role validation and update entity types * refactor(task): clean and fix bug * feat(task): add GetTaskItemStatsQueryDto for task item stats retrieval * chore: added focus mode for task, improved logic and cleaning up * feat(task): add task item attachment upload and activity logging * feat: add comments to task, notifications in email and in-appm clean code * feat: risk assesstment for vendors, fix some bugs * refactor(notifications): clean up NovuService fetch logic and error handling * feat(api): add INTERNAL_API_TOKEN to environment example * feat(env): add INTERNAL_API_TOKEN to environment configuration * chore(api): fix bugs * fix(api): update default framework ID from iso42001 to iso27001 * fix(api): correct entity route path for risk in comment notifier --------- Co-authored-by: Tofik Hasanov <[email protected]>
1 parent da9bab9 commit f943727

File tree

130 files changed

+12049
-270
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

130 files changed

+12049
-270
lines changed

apps/api/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ APP_AWS_ORG_ASSETS_BUCKET=
1212

1313
DATABASE_URL=
1414

15+
NOVU_API_KEY=
16+
INTERNAL_API_TOKEN=
1517

1618
# Upstash
1719
UPSTASH_REDIS_REST_URL=

apps/api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { SOAModule } from './soa/soa.module';
2828
import { IntegrationPlatformModule } from './integration-platform/integration-platform.module';
2929
import { CloudSecurityModule } from './cloud-security/cloud-security.module';
3030
import { BrowserbaseModule } from './browserbase/browserbase.module';
31+
import { TaskManagementModule } from './task-management/task-management.module';
3132

3233
@Module({
3334
imports: [
@@ -68,6 +69,7 @@ import { BrowserbaseModule } from './browserbase/browserbase.module';
6869
IntegrationPlatformModule,
6970
CloudSecurityModule,
7071
BrowserbaseModule,
72+
TaskManagementModule,
7173
],
7274
controllers: [AppController],
7375
providers: [

apps/api/src/app/s3.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { GetObjectCommand, S3Client, type GetObjectCommandOutput } from '@aws-sdk/client-s3';
1+
import {
2+
GetObjectCommand,
3+
S3Client,
4+
type GetObjectCommandOutput,
5+
} from '@aws-sdk/client-s3';
26
import { Logger } from '@nestjs/common';
37
import '../config/load-env';
48

apps/api/src/attachments/attachments.service.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { UploadAttachmentDto } from './upload-attachment.dto';
1919
export class AttachmentsService {
2020
private s3Client: S3Client;
2121
private bucketName: string;
22-
private readonly MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
22+
private readonly MAX_FILE_SIZE_BYTES = 60 * 1024 * 1024; // 60MB
2323
private readonly SIGNED_URL_EXPIRY = 900; // 15 minutes
2424

2525
constructor() {
@@ -129,7 +129,20 @@ export class AttachmentsService {
129129
const fileId = randomBytes(16).toString('hex');
130130
const sanitizedFileName = this.sanitizeFileName(uploadDto.fileName);
131131
const timestamp = Date.now();
132-
const s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`;
132+
133+
// Special S3 path structure for task items: org_{orgId}/attachments/task-item/{entityType}/{entityId}
134+
let s3Key: string;
135+
if (entityType === 'task_item') {
136+
// For task items, extract entityType and entityId from metadata
137+
// Metadata should contain taskItemEntityType and taskItemEntityId
138+
const taskItemEntityType =
139+
uploadDto.description?.split('|')[0] || 'unknown';
140+
const taskItemEntityId =
141+
uploadDto.description?.split('|')[1] || entityId;
142+
s3Key = `${organizationId}/attachments/task-item/${taskItemEntityType}/${taskItemEntityId}/${timestamp}-${fileId}-${sanitizedFileName}`;
143+
} else {
144+
s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`;
145+
}
133146

134147
// Upload to S3
135148
const putCommand = new PutObjectCommand({

apps/api/src/auth/auth-context.decorator.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export const AuthContext = createParamDecorator(
99
(data: unknown, ctx: ExecutionContext): AuthContextType => {
1010
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
1111

12-
const { organizationId, authType, isApiKey, userId, userEmail } = request;
12+
const { organizationId, authType, isApiKey, userId, userEmail, userRoles } =
13+
request;
1314

1415
if (!organizationId || !authType) {
1516
throw new Error(
@@ -23,6 +24,7 @@ export const AuthContext = createParamDecorator(
2324
isApiKey,
2425
userId,
2526
userEmail,
27+
userRoles,
2628
};
2729
},
2830
);

apps/api/src/auth/auth.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
22
import { ApiKeyGuard } from './api-key.guard';
33
import { ApiKeyService } from './api-key.service';
44
import { HybridAuthGuard } from './hybrid-auth.guard';
5+
import { InternalTokenGuard } from './internal-token.guard';
56

67
@Module({
7-
providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard],
8-
exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard],
8+
providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard],
9+
exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard],
910
})
1011
export class AuthModule {}

apps/api/src/auth/hybrid-auth.guard.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export class HybridAuthGuard implements CanActivate {
7070
request.organizationId = organizationId;
7171
request.authType = 'api-key';
7272
request.isApiKey = true;
73+
// API keys are organization-scoped and are not tied to a specific user/member.
74+
request.userRoles = null;
7375

7476
return true;
7577
}
@@ -171,9 +173,23 @@ export class HybridAuthGuard implements CanActivate {
171173
);
172174
}
173175

176+
const member = await db.member.findFirst({
177+
where: {
178+
userId,
179+
organizationId: explicitOrgId,
180+
deactivated: false,
181+
},
182+
select: {
183+
role: true,
184+
},
185+
});
186+
187+
const userRoles = member?.role ? member.role.split(',') : null;
188+
174189
// Set request context for JWT auth
175190
request.userId = userId;
176191
request.userEmail = userEmail;
192+
request.userRoles = userRoles;
177193
request.organizationId = explicitOrgId;
178194
request.authType = 'jwt';
179195
request.isApiKey = false;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
Injectable,
5+
Logger,
6+
UnauthorizedException,
7+
} from '@nestjs/common';
8+
9+
type RequestWithHeaders = {
10+
headers: Record<string, string | string[] | undefined>;
11+
};
12+
13+
@Injectable()
14+
export class InternalTokenGuard implements CanActivate {
15+
private readonly logger = new Logger(InternalTokenGuard.name);
16+
17+
canActivate(context: ExecutionContext): boolean {
18+
const expectedToken = process.env.INTERNAL_API_TOKEN;
19+
20+
// In production, we require the token to be configured.
21+
if (!expectedToken) {
22+
if (process.env.NODE_ENV === 'production') {
23+
this.logger.error('INTERNAL_API_TOKEN is not configured in production');
24+
throw new UnauthorizedException('Internal access is not configured');
25+
}
26+
27+
// In local/dev, allow requests if not configured to keep DX smooth.
28+
this.logger.warn(
29+
'INTERNAL_API_TOKEN is not configured; allowing internal request in non-production',
30+
);
31+
return true;
32+
}
33+
34+
const req = context.switchToHttp().getRequest<RequestWithHeaders>();
35+
const headerValue = req.headers['x-internal-token'];
36+
const token = Array.isArray(headerValue) ? headerValue[0] : headerValue;
37+
38+
if (!token || token !== expectedToken) {
39+
throw new UnauthorizedException('Invalid internal token');
40+
}
41+
42+
return true;
43+
}
44+
}
45+
46+
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
Injectable,
5+
UnauthorizedException,
6+
} from '@nestjs/common';
7+
import { AuthenticatedRequest } from './types';
8+
9+
@Injectable()
10+
export class RoleValidator implements CanActivate {
11+
private readonly unauthenticatedErrorMessage: string;
12+
private readonly noRolesSpecifiedErrorMessage: string;
13+
private readonly accessDeniedErrorMessage: string;
14+
private readonly allowedRoles: string[] | null;
15+
16+
constructor(allowedRoles: string[] | null) {
17+
this.allowedRoles = allowedRoles;
18+
19+
this.unauthenticatedErrorMessage =
20+
'Role-based authorization requires user authentication (JWT token)';
21+
this.noRolesSpecifiedErrorMessage = 'No roles specified for authorization';
22+
this.accessDeniedErrorMessage =
23+
'Access denied. User does not have the required roles: {allowedRoles}, user has roles: {userRoles}';
24+
}
25+
26+
async canActivate(context: ExecutionContext): Promise<boolean> {
27+
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
28+
29+
const { userRoles, userId, organizationId, authType, isApiKey } = request;
30+
31+
if (!this.allowedRoles || this.allowedRoles.length === 0) {
32+
throw new UnauthorizedException(this.noRolesSpecifiedErrorMessage);
33+
}
34+
35+
// API keys are organization-scoped and not tied to a specific user/member.
36+
// They are allowed through role-protected endpoints.
37+
if (isApiKey || authType === 'api-key') {
38+
if (!organizationId) {
39+
throw new UnauthorizedException(
40+
'Organization context required for API key authentication',
41+
);
42+
}
43+
44+
return true;
45+
}
46+
47+
// JWT requests must have user context + roles for role-based authorization
48+
if (!userId || !organizationId || !userRoles || userRoles.length === 0) {
49+
throw new UnauthorizedException(this.unauthenticatedErrorMessage);
50+
}
51+
52+
const hasRequiredRoles = this.allowedRoles.some((role) =>
53+
userRoles.includes(role),
54+
);
55+
56+
if (!hasRequiredRoles) {
57+
throw new UnauthorizedException(
58+
this.accessDeniedErrorMessage
59+
.replace('{allowedRoles}', this.allowedRoles.join(', '))
60+
.replace('{userRoles}', userRoles.join(', ')),
61+
);
62+
}
63+
64+
return true;
65+
}
66+
}
67+
68+
export const RequireRoles = (...roles: string[]) => new RoleValidator(roles);

apps/api/src/auth/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface AuthenticatedRequest extends Request {
66
isApiKey: boolean;
77
userId?: string;
88
userEmail?: string;
9+
userRoles: string[] | null;
910
}
1011

1112
export interface AuthContext {
@@ -14,4 +15,5 @@ export interface AuthContext {
1415
isApiKey: boolean;
1516
userId?: string; // Only available for JWT auth
1617
userEmail?: string; // Only available for JWT auth
18+
userRoles: string[] | null;
1719
}

0 commit comments

Comments
 (0)