Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ APP_AWS_ORG_ASSETS_BUCKET=

DATABASE_URL=

NOVU_API_KEY=
INTERNAL_API_TOKEN=

# Upstash
UPSTASH_REDIS_REST_URL=
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { SOAModule } from './soa/soa.module';
import { IntegrationPlatformModule } from './integration-platform/integration-platform.module';
import { CloudSecurityModule } from './cloud-security/cloud-security.module';
import { BrowserbaseModule } from './browserbase/browserbase.module';
import { TaskManagementModule } from './task-management/task-management.module';

@Module({
imports: [
Expand Down Expand Up @@ -68,6 +69,7 @@ import { BrowserbaseModule } from './browserbase/browserbase.module';
IntegrationPlatformModule,
CloudSecurityModule,
BrowserbaseModule,
TaskManagementModule,
],
controllers: [AppController],
providers: [
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/app/s3.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { GetObjectCommand, S3Client, type GetObjectCommandOutput } from '@aws-sdk/client-s3';
import {
GetObjectCommand,
S3Client,
type GetObjectCommandOutput,
} from '@aws-sdk/client-s3';
import { Logger } from '@nestjs/common';
import '../config/load-env';

Expand Down
17 changes: 15 additions & 2 deletions apps/api/src/attachments/attachments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { UploadAttachmentDto } from './upload-attachment.dto';
export class AttachmentsService {
private s3Client: S3Client;
private bucketName: string;
private readonly MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
private readonly MAX_FILE_SIZE_BYTES = 60 * 1024 * 1024; // 60MB
private readonly SIGNED_URL_EXPIRY = 900; // 15 minutes

constructor() {
Expand Down Expand Up @@ -129,7 +129,20 @@ export class AttachmentsService {
const fileId = randomBytes(16).toString('hex');
const sanitizedFileName = this.sanitizeFileName(uploadDto.fileName);
const timestamp = Date.now();
const s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`;

// Special S3 path structure for task items: org_{orgId}/attachments/task-item/{entityType}/{entityId}
let s3Key: string;
if (entityType === 'task_item') {
// For task items, extract entityType and entityId from metadata
// Metadata should contain taskItemEntityType and taskItemEntityId
const taskItemEntityType =
uploadDto.description?.split('|')[0] || 'unknown';
const taskItemEntityId =
uploadDto.description?.split('|')[1] || entityId;
s3Key = `${organizationId}/attachments/task-item/${taskItemEntityType}/${taskItemEntityId}/${timestamp}-${fileId}-${sanitizedFileName}`;
} else {
s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`;
}

// Upload to S3
const putCommand = new PutObjectCommand({
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/auth/auth-context.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export const AuthContext = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AuthContextType => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();

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

if (!organizationId || !authType) {
throw new Error(
Expand All @@ -23,6 +24,7 @@ export const AuthContext = createParamDecorator(
isApiKey,
userId,
userEmail,
userRoles,
};
},
);
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { ApiKeyGuard } from './api-key.guard';
import { ApiKeyService } from './api-key.service';
import { HybridAuthGuard } from './hybrid-auth.guard';
import { InternalTokenGuard } from './internal-token.guard';

@Module({
providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard],
exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard],
providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard],
exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard],
})
export class AuthModule {}
16 changes: 16 additions & 0 deletions apps/api/src/auth/hybrid-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export class HybridAuthGuard implements CanActivate {
request.organizationId = organizationId;
request.authType = 'api-key';
request.isApiKey = true;
// API keys are organization-scoped and are not tied to a specific user/member.
request.userRoles = null;

return true;
}
Expand Down Expand Up @@ -171,9 +173,23 @@ export class HybridAuthGuard implements CanActivate {
);
}

const member = await db.member.findFirst({
where: {
userId,
organizationId: explicitOrgId,
deactivated: false,
},
select: {
role: true,
},
});

const userRoles = member?.role ? member.role.split(',') : null;

// Set request context for JWT auth
request.userId = userId;
request.userEmail = userEmail;
request.userRoles = userRoles;
request.organizationId = explicitOrgId;
request.authType = 'jwt';
request.isApiKey = false;
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/auth/internal-token.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';

type RequestWithHeaders = {
headers: Record<string, string | string[] | undefined>;
};

@Injectable()
export class InternalTokenGuard implements CanActivate {
private readonly logger = new Logger(InternalTokenGuard.name);

canActivate(context: ExecutionContext): boolean {
const expectedToken = process.env.INTERNAL_API_TOKEN;

// In production, we require the token to be configured.
if (!expectedToken) {
if (process.env.NODE_ENV === 'production') {
this.logger.error('INTERNAL_API_TOKEN is not configured in production');
throw new UnauthorizedException('Internal access is not configured');
}

// In local/dev, allow requests if not configured to keep DX smooth.
this.logger.warn(
'INTERNAL_API_TOKEN is not configured; allowing internal request in non-production',
);
return true;
}

const req = context.switchToHttp().getRequest<RequestWithHeaders>();
const headerValue = req.headers['x-internal-token'];
const token = Array.isArray(headerValue) ? headerValue[0] : headerValue;

if (!token || token !== expectedToken) {
throw new UnauthorizedException('Invalid internal token');
}

return true;
}
}


68 changes: 68 additions & 0 deletions apps/api/src/auth/role-validator.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthenticatedRequest } from './types';

@Injectable()
export class RoleValidator implements CanActivate {
private readonly unauthenticatedErrorMessage: string;
private readonly noRolesSpecifiedErrorMessage: string;
private readonly accessDeniedErrorMessage: string;
private readonly allowedRoles: string[] | null;

constructor(allowedRoles: string[] | null) {
this.allowedRoles = allowedRoles;

this.unauthenticatedErrorMessage =
'Role-based authorization requires user authentication (JWT token)';
this.noRolesSpecifiedErrorMessage = 'No roles specified for authorization';
this.accessDeniedErrorMessage =
'Access denied. User does not have the required roles: {allowedRoles}, user has roles: {userRoles}';
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();

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

if (!this.allowedRoles || this.allowedRoles.length === 0) {
throw new UnauthorizedException(this.noRolesSpecifiedErrorMessage);
}

// API keys are organization-scoped and not tied to a specific user/member.
// They are allowed through role-protected endpoints.
if (isApiKey || authType === 'api-key') {
if (!organizationId) {
throw new UnauthorizedException(
'Organization context required for API key authentication',
);
}

return true;
}

// JWT requests must have user context + roles for role-based authorization
if (!userId || !organizationId || !userRoles || userRoles.length === 0) {
throw new UnauthorizedException(this.unauthenticatedErrorMessage);
}

const hasRequiredRoles = this.allowedRoles.some((role) =>
userRoles.includes(role),
);

if (!hasRequiredRoles) {
throw new UnauthorizedException(
this.accessDeniedErrorMessage
.replace('{allowedRoles}', this.allowedRoles.join(', '))
.replace('{userRoles}', userRoles.join(', ')),
);
}

return true;
}
}

export const RequireRoles = (...roles: string[]) => new RoleValidator(roles);
2 changes: 2 additions & 0 deletions apps/api/src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface AuthenticatedRequest extends Request {
isApiKey: boolean;
userId?: string;
userEmail?: string;
userRoles: string[] | null;
}

export interface AuthContext {
Expand All @@ -14,4 +15,5 @@ export interface AuthContext {
isApiKey: boolean;
userId?: string; // Only available for JWT auth
userEmail?: string; // Only available for JWT auth
userRoles: string[] | null;
}
Loading
Loading