Skip to content

Commit dbf5552

Browse files
authored
Merge pull request #3057 from trycompai/main
[comp] Production Deploy
2 parents ebf85d3 + 245f66b commit dbf5552

15 files changed

Lines changed: 947 additions & 28 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
2+
import { ServiceTokenOnlyGuard } from './service-token-only.guard';
3+
import type { AuthenticatedRequest } from './types';
4+
5+
function contextFor(
6+
request: Partial<AuthenticatedRequest>,
7+
): ExecutionContext {
8+
return {
9+
switchToHttp: () => ({
10+
getRequest: () => request as AuthenticatedRequest,
11+
}),
12+
} as unknown as ExecutionContext;
13+
}
14+
15+
describe('ServiceTokenOnlyGuard', () => {
16+
const guard = new ServiceTokenOnlyGuard();
17+
18+
it('allows internal service-token requests', () => {
19+
expect(
20+
guard.canActivate(contextFor({ isServiceToken: true })),
21+
).toBe(true);
22+
});
23+
24+
it('rejects session requests', () => {
25+
expect(() =>
26+
guard.canActivate(
27+
contextFor({ authType: 'session', isApiKey: false, isServiceToken: false }),
28+
),
29+
).toThrow(ForbiddenException);
30+
});
31+
32+
it('rejects API-key requests', () => {
33+
expect(() =>
34+
guard.canActivate(
35+
contextFor({ authType: 'api-key', isApiKey: true, isServiceToken: false }),
36+
),
37+
).toThrow(ForbiddenException);
38+
});
39+
40+
it('rejects requests with no service-token flag set', () => {
41+
expect(() => guard.canActivate(contextFor({}))).toThrow(
42+
ForbiddenException,
43+
);
44+
});
45+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
ForbiddenException,
5+
Injectable,
6+
} from '@nestjs/common';
7+
import { AuthenticatedRequest } from './types';
8+
9+
/**
10+
* Guard that rejects everything except internal service-token auth.
11+
* Use on internal-only endpoints that return sensitive data not meant for
12+
* customers (e.g., minting temporary AWS credentials for background workers).
13+
*
14+
* Place between HybridAuthGuard and PermissionGuard:
15+
* @UseGuards(HybridAuthGuard, ServiceTokenOnlyGuard, PermissionGuard)
16+
*/
17+
@Injectable()
18+
export class ServiceTokenOnlyGuard implements CanActivate {
19+
canActivate(context: ExecutionContext): boolean {
20+
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
21+
22+
if (!request.isServiceToken) {
23+
throw new ForbiddenException(
24+
'This endpoint is only available for internal service-token requests.',
25+
);
26+
}
27+
28+
return true;
29+
}
30+
}

‎apps/api/src/cloud-security/cloud-security.controller.spec.ts‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import { Test, TestingModule } from '@nestjs/testing';
1616
import { HttpException, HttpStatus } from '@nestjs/common';
1717

1818
import { CloudSecurityController } from './cloud-security.controller';
19-
import { CloudSecurityService } from './cloud-security.service';
19+
import {
20+
CloudSecurityService,
21+
ConnectionNotFoundError,
22+
} from './cloud-security.service';
2023
import { CloudSecurityQueryService } from './cloud-security-query.service';
2124
import { CloudSecurityLegacyService } from './cloud-security-legacy.service';
2225
import { CloudSecurityActivityService } from './cloud-security-activity.service';
@@ -29,6 +32,7 @@ import { CloudAwsScanModeService } from './aws-scan-mode.service';
2932
import { ActingUserResolver } from '../auth/acting-user.service';
3033
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
3134
import { PermissionGuard } from '../auth/permission.guard';
35+
import { ServiceTokenOnlyGuard } from '../auth/service-token-only.guard';
3236
import type { AuthenticatedRequest } from '../auth/types';
3337

3438
/**
@@ -87,6 +91,8 @@ describe('CloudSecurityController — API-key mutation support', () => {
8791
.useValue(mockGuard)
8892
.overrideGuard(PermissionGuard)
8993
.useValue(mockGuard)
94+
.overrideGuard(ServiceTokenOnlyGuard)
95+
.useValue(mockGuard)
9096
.compile();
9197

9298
controller = module.get(CloudSecurityController);
@@ -309,3 +315,88 @@ describe('CloudSecurityController — API-key mutation support', () => {
309315
});
310316
});
311317
});
318+
319+
describe('CloudSecurityController — resolveSession (internal)', () => {
320+
let controller: CloudSecurityController;
321+
let cloudSecurityService: { resolveAwsSession: jest.Mock };
322+
const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
323+
324+
beforeEach(async () => {
325+
cloudSecurityService = { resolveAwsSession: jest.fn() };
326+
327+
const module: TestingModule = await Test.createTestingModule({
328+
controllers: [CloudSecurityController],
329+
providers: [
330+
{ provide: CloudSecurityService, useValue: cloudSecurityService },
331+
{ provide: CloudSecurityQueryService, useValue: {} },
332+
{ provide: CloudSecurityLegacyService, useValue: {} },
333+
{ provide: CloudSecurityActivityService, useValue: {} },
334+
{ provide: GCPSecurityService, useValue: {} },
335+
{ provide: AzureSecurityService, useValue: {} },
336+
{ provide: CheckDefinitionService, useValue: {} },
337+
{ provide: CloudExceptionService, useValue: {} },
338+
{ provide: CloudHistoryService, useValue: {} },
339+
{ provide: CloudAwsScanModeService, useValue: {} },
340+
{ provide: ActingUserResolver, useValue: {} },
341+
],
342+
})
343+
.overrideGuard(HybridAuthGuard)
344+
.useValue(mockGuard)
345+
.overrideGuard(PermissionGuard)
346+
.useValue(mockGuard)
347+
.overrideGuard(ServiceTokenOnlyGuard)
348+
.useValue(mockGuard)
349+
.compile();
350+
351+
controller = module.get(CloudSecurityController);
352+
jest.clearAllMocks();
353+
});
354+
355+
it('returns the resolved session from the service', async () => {
356+
const session = {
357+
ok: true,
358+
session: {
359+
accessKeyId: 'AKIA_TEMP',
360+
secretAccessKey: 'secret',
361+
sessionToken: 'token',
362+
},
363+
};
364+
cloudSecurityService.resolveAwsSession.mockResolvedValueOnce(session);
365+
366+
const result = await controller.resolveSession('conn_1', 'org_1');
367+
368+
expect(cloudSecurityService.resolveAwsSession).toHaveBeenCalledWith(
369+
'conn_1',
370+
'org_1',
371+
);
372+
expect(result).toEqual(session);
373+
});
374+
375+
it('passes through not_configured / assume_failed results', async () => {
376+
cloudSecurityService.resolveAwsSession.mockResolvedValueOnce({
377+
ok: false,
378+
reason: 'assume_failed',
379+
error: 'denied',
380+
});
381+
382+
const result = await controller.resolveSession('conn_1', 'org_1');
383+
expect(result).toEqual({
384+
ok: false,
385+
reason: 'assume_failed',
386+
error: 'denied',
387+
});
388+
});
389+
390+
it('maps ConnectionNotFoundError to a 404', async () => {
391+
cloudSecurityService.resolveAwsSession.mockRejectedValueOnce(
392+
new ConnectionNotFoundError(),
393+
);
394+
395+
const error = await controller
396+
.resolveSession('conn_missing', 'org_1')
397+
.catch((e) => e);
398+
399+
expect(error).toBeInstanceOf(HttpException);
400+
expect((error as HttpException).getStatus()).toBe(HttpStatus.NOT_FOUND);
401+
});
402+
});

‎apps/api/src/cloud-security/cloud-security.controller.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ApiOperation } from '@nestjs/swagger';
1717
import { SkipThrottle } from '@nestjs/throttler';
1818
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
1919
import { PermissionGuard } from '../auth/permission.guard';
20+
import { ServiceTokenOnlyGuard } from '../auth/service-token-only.guard';
2021
import { RequirePermission } from '../auth/require-permission.decorator';
2122
import { OrganizationId } from '../auth/auth-context.decorator';
2223
import {
@@ -254,6 +255,31 @@ export class CloudSecurityController {
254255
return { data: definition };
255256
}
256257

258+
@Post('resolve-session/:connectionId')
259+
@SkipThrottle()
260+
@UseGuards(HybridAuthGuard, ServiceTokenOnlyGuard, PermissionGuard)
261+
@RequirePermission('integration', 'update')
262+
@ApiOperation({
263+
summary:
264+
'Resolve short-lived AWS credentials for a connection (internal only)',
265+
})
266+
async resolveSession(
267+
@Param('connectionId') connectionId: string,
268+
@OrganizationId() organizationId: string,
269+
) {
270+
try {
271+
return await this.cloudSecurityService.resolveAwsSession(
272+
connectionId,
273+
organizationId,
274+
);
275+
} catch (error) {
276+
if (error instanceof ConnectionNotFoundError) {
277+
throw new HttpException('Connection not found', HttpStatus.NOT_FOUND);
278+
}
279+
throw error;
280+
}
281+
}
282+
257283
@Post('scan/:connectionId')
258284
@UseGuards(HybridAuthGuard, PermissionGuard)
259285
@RequirePermission('integration', 'update')
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
jest.mock('@db', () => ({
2+
db: { integrationConnection: { findFirst: jest.fn() } },
3+
Prisma: {},
4+
}));
5+
6+
import { db } from '@db';
7+
import {
8+
CloudSecurityService,
9+
ConnectionNotFoundError,
10+
} from './cloud-security.service';
11+
12+
type Ctor = ConstructorParameters<typeof CloudSecurityService>;
13+
14+
const findFirst = (db as unknown as {
15+
integrationConnection: { findFirst: jest.Mock };
16+
}).integrationConnection.findFirst;
17+
18+
describe('CloudSecurityService.resolveAwsSession', () => {
19+
let credentialVault: { getDecryptedCredentials: jest.Mock };
20+
let awsService: { resolveRoleSession: jest.Mock };
21+
let service: CloudSecurityService;
22+
23+
beforeEach(() => {
24+
credentialVault = { getDecryptedCredentials: jest.fn() };
25+
awsService = { resolveRoleSession: jest.fn() };
26+
service = new CloudSecurityService(
27+
credentialVault as unknown as Ctor[0],
28+
{} as unknown as Ctor[1],
29+
{} as unknown as Ctor[2],
30+
awsService as unknown as Ctor[3],
31+
{} as unknown as Ctor[4],
32+
{} as unknown as Ctor[5],
33+
);
34+
jest.clearAllMocks();
35+
});
36+
37+
it('scopes the connection lookup by organizationId (tenant isolation)', async () => {
38+
findFirst.mockResolvedValueOnce(null);
39+
40+
await expect(
41+
service.resolveAwsSession('conn_1', 'org_1'),
42+
).rejects.toBeInstanceOf(ConnectionNotFoundError);
43+
44+
expect(findFirst).toHaveBeenCalledWith(
45+
expect.objectContaining({
46+
where: { id: 'conn_1', organizationId: 'org_1', status: 'active' },
47+
}),
48+
);
49+
});
50+
51+
it('returns not_configured for a non-AWS provider (never assumes)', async () => {
52+
findFirst.mockResolvedValueOnce({ provider: { slug: 'gcp' } });
53+
54+
const result = await service.resolveAwsSession('conn_1', 'org_1');
55+
56+
expect(result).toEqual({ ok: false, reason: 'not_configured' });
57+
expect(awsService.resolveRoleSession).not.toHaveBeenCalled();
58+
});
59+
60+
it('returns not_configured when roleArn/externalId are missing', async () => {
61+
findFirst.mockResolvedValueOnce({
62+
provider: { slug: 'aws' },
63+
variables: {},
64+
});
65+
credentialVault.getDecryptedCredentials.mockResolvedValueOnce({
66+
externalId: 'eid', // roleArn missing
67+
});
68+
69+
const result = await service.resolveAwsSession('conn_1', 'org_1');
70+
71+
expect(result).toEqual({ ok: false, reason: 'not_configured' });
72+
expect(awsService.resolveRoleSession).not.toHaveBeenCalled();
73+
});
74+
75+
it('returns the resolved session on success', async () => {
76+
findFirst.mockResolvedValueOnce({
77+
provider: { slug: 'aws' },
78+
variables: { regions: ['us-east-1'] },
79+
});
80+
credentialVault.getDecryptedCredentials.mockResolvedValueOnce({
81+
roleArn: 'arn:aws:iam::123456789012:role/x',
82+
externalId: 'eid',
83+
regions: ['us-east-1'],
84+
});
85+
awsService.resolveRoleSession.mockResolvedValueOnce({
86+
accessKeyId: 'AKIA_TEMP',
87+
secretAccessKey: 'secret',
88+
sessionToken: 'token',
89+
});
90+
91+
const result = await service.resolveAwsSession('conn_1', 'org_1');
92+
93+
expect(result).toEqual({
94+
ok: true,
95+
session: {
96+
accessKeyId: 'AKIA_TEMP',
97+
secretAccessKey: 'secret',
98+
sessionToken: 'token',
99+
},
100+
});
101+
});
102+
103+
it('returns assume_failed with the real reason when the assume throws', async () => {
104+
findFirst.mockResolvedValueOnce({
105+
provider: { slug: 'aws' },
106+
variables: {},
107+
});
108+
credentialVault.getDecryptedCredentials.mockResolvedValueOnce({
109+
roleArn: 'arn:aws:iam::123456789012:role/x',
110+
externalId: 'eid',
111+
regions: ['us-east-1'],
112+
});
113+
awsService.resolveRoleSession.mockRejectedValueOnce(
114+
new Error('not authorized to perform sts:AssumeRole'),
115+
);
116+
117+
const result = await service.resolveAwsSession('conn_1', 'org_1');
118+
119+
expect(result).toEqual({
120+
ok: false,
121+
reason: 'assume_failed',
122+
error: 'not authorized to perform sts:AssumeRole',
123+
});
124+
});
125+
});

0 commit comments

Comments
 (0)