Skip to content

Commit fd6d245

Browse files
committed
feat(api,proxy,daemon,sdk): pre-signed file download/upload URLs with key rotation across all SDKs
Signed-off-by: MDzaja <mirkodzaja0@gmail.com>
1 parent 8f7140c commit fd6d245

91 files changed

Lines changed: 8633 additions & 2148 deletions

File tree

Some content is hidden

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

.vscode/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@
22
"files.watcherExclude": {
33
"**/libs/*api-client*/**": true,
44
"**/node_modules/**": true
5-
}
5+
},
6+
"python-envs.defaultEnvManager": "ms-python.python:poetry",
7+
"python-envs.defaultPackageManager": "ms-python.python:poetry"
68
}

apps/api/src/audit/enums/audit-action.enum.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export enum AuditAction {
3131
REPLACE_LABELS = 'replace_labels',
3232
CREATE_BACKUP = 'create_backup',
3333
UPDATE_PUBLIC_STATUS = 'update_public_status',
34+
ROTATE_SIGNING_KEY = 'rotate_signing_key',
3435
SET_AUTO_STOP_INTERVAL = 'set_auto_stop_interval',
3536
SET_AUTO_ARCHIVE_INTERVAL = 'set_auto_archive_interval',
3637
SET_AUTO_DELETE_INTERVAL = 'set_auto_delete_interval',
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright Daytona Platforms Inc.
3+
* SPDX-License-Identifier: AGPL-3.0
4+
*/
5+
6+
import { MigrationInterface, QueryRunner } from 'typeorm'
7+
8+
export class Migration1781308800000 implements MigrationInterface {
9+
name = 'Migration1781308800000'
10+
11+
public async up(queryRunner: QueryRunner): Promise<void> {
12+
await queryRunner.query(
13+
`CREATE TABLE "sandbox_metadata" ("sandboxId" character varying NOT NULL, "signingKey" character varying NOT NULL, CONSTRAINT "PK_sandbox_metadata" PRIMARY KEY ("sandboxId"))`,
14+
)
15+
await queryRunner.query(
16+
`ALTER TABLE "sandbox_metadata" ADD CONSTRAINT "FK_sandbox_metadata_sandbox" FOREIGN KEY ("sandboxId") REFERENCES "sandbox"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
17+
)
18+
}
19+
20+
public async down(queryRunner: QueryRunner): Promise<void> {
21+
await queryRunner.query(`ALTER TABLE "sandbox_metadata" DROP CONSTRAINT "FK_sandbox_metadata_sandbox"`)
22+
await queryRunner.query(`DROP TABLE "sandbox_metadata"`)
23+
}
24+
}

apps/api/src/sandbox/controllers/preview.controller.auth.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,11 @@ describe('[AUTH] PreviewController', () => {
4747
expectArrayMatch(getAllowedAuthStrategies(PreviewController, methodName), [AuthStrategyType.API_KEY])
4848
expectArrayMatch(getAuthContextGuards(PreviewController, methodName), [ProxyAuthContextGuard])
4949
})
50+
51+
it('getSigningKey', () => {
52+
const methodName = trackMethod('getSigningKey')
53+
expect(isPublicEndpoint(PreviewController, methodName)).toBe(false)
54+
expectArrayMatch(getAllowedAuthStrategies(PreviewController, methodName), [AuthStrategyType.API_KEY])
55+
expectArrayMatch(getAuthContextGuards(PreviewController, methodName), [ProxyAuthContextGuard])
56+
})
5057
})

apps/api/src/sandbox/controllers/preview.controller.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,27 @@ export class PreviewController {
124124
throw new NotFoundException(`Sandbox with ID ${sandboxId} not found`)
125125
}
126126

127+
@Get(':sandboxId/signing-key')
128+
@ApiOperation({
129+
summary: 'Get the signing key for a sandbox',
130+
operationId: 'getSigningKey',
131+
})
132+
@ApiParam({
133+
name: 'sandboxId',
134+
description: 'ID of the sandbox',
135+
type: 'string',
136+
})
137+
@ApiResponse({
138+
status: 200,
139+
description: 'Signing key of the sandbox',
140+
type: String,
141+
})
142+
@AuthStrategy(AuthStrategyType.API_KEY)
143+
@UseGuards(ProxyAuthContextGuard)
144+
async getSigningKey(@Param('sandboxId') sandboxId: string): Promise<string> {
145+
return this.sandboxService.getSigningKey(sandboxId)
146+
}
147+
127148
@Get(':sandboxId/access')
128149
@ApiOperation({
129150
summary: 'Check if user has access to the sandbox',

apps/api/src/sandbox/controllers/sandbox.controller.auth.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,34 @@ describe('[AUTH] SandboxController', () => {
320320
expect(getRequiredOrganizationResourcePermissions(SandboxController, methodName)).toBeUndefined()
321321
})
322322

323+
it('getSandboxSigningKey', () => {
324+
const methodName = trackMethod('getSandboxSigningKey')
325+
expect(isPublicEndpoint(SandboxController, methodName)).toBe(false)
326+
expectArrayMatch(getAllowedAuthStrategies(SandboxController, methodName), [
327+
AuthStrategyType.API_KEY,
328+
AuthStrategyType.JWT,
329+
])
330+
expectArrayMatch(getAuthContextGuards(SandboxController, methodName), [OrganizationAuthContextGuard])
331+
expectArrayMatch(getResourceAccessGuards(SandboxController, methodName), [SandboxAccessGuard])
332+
expect(getRequiredOrganizationMemberRole(SandboxController, methodName)).toBeUndefined()
333+
expect(getRequiredOrganizationResourcePermissions(SandboxController, methodName)).toBeUndefined()
334+
})
335+
336+
it('rotateSigningKey', () => {
337+
const methodName = trackMethod('rotateSigningKey')
338+
expect(isPublicEndpoint(SandboxController, methodName)).toBe(false)
339+
expectArrayMatch(getAllowedAuthStrategies(SandboxController, methodName), [
340+
AuthStrategyType.API_KEY,
341+
AuthStrategyType.JWT,
342+
])
343+
expectArrayMatch(getAuthContextGuards(SandboxController, methodName), [OrganizationAuthContextGuard])
344+
expectArrayMatch(getResourceAccessGuards(SandboxController, methodName), [SandboxAccessGuard])
345+
expect(getRequiredOrganizationMemberRole(SandboxController, methodName)).toBeUndefined()
346+
expectArrayMatch(getRequiredOrganizationResourcePermissions(SandboxController, methodName), [
347+
OrganizationResourcePermission.WRITE_SANDBOXES,
348+
])
349+
})
350+
323351
it('getSignedPortPreviewUrl', () => {
324352
const methodName = trackMethod('getSignedPortPreviewUrl')
325353
expect(isPublicEndpoint(SandboxController, methodName)).toBe(false)

apps/api/src/sandbox/controllers/sandbox.controller.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,52 @@ export class SandboxController {
875875
return this.sandboxService.toSandboxDto(sandbox)
876876
}
877877

878+
@Get(':sandboxId/signing-key')
879+
@ApiOperation({
880+
summary: 'Get the signing key for a sandbox',
881+
operationId: 'getSandboxSigningKey',
882+
})
883+
@ApiParam({
884+
name: 'sandboxId',
885+
description: 'ID of the sandbox',
886+
type: 'string',
887+
})
888+
@ApiResponse({
889+
status: 200,
890+
description: 'Signing key of the sandbox',
891+
type: String,
892+
})
893+
@UseGuards(OrganizationAuthContextGuard, SandboxAccessGuard)
894+
async getSandboxSigningKey(@Param('sandboxId') sandboxId: string): Promise<string> {
895+
return this.sandboxService.getSigningKey(sandboxId)
896+
}
897+
898+
@Post(':sandboxId/signing-key/rotate')
899+
@ApiOperation({
900+
summary: 'Rotate the signing key, invalidating all previously signed URLs',
901+
operationId: 'rotateSigningKey',
902+
})
903+
@ApiParam({
904+
name: 'sandboxId',
905+
description: 'ID of the sandbox',
906+
type: 'string',
907+
})
908+
@ApiResponse({
909+
status: 200,
910+
description: 'New signing key',
911+
type: String,
912+
})
913+
@UseGuards(OrganizationAuthContextGuard, SandboxAccessGuard)
914+
@RequiredOrganizationResourcePermissions([OrganizationResourcePermission.WRITE_SANDBOXES])
915+
@Audit({
916+
action: AuditAction.ROTATE_SIGNING_KEY,
917+
targetType: AuditTarget.SANDBOX,
918+
targetIdFromRequest: (req) => req.params.sandboxId,
919+
})
920+
async rotateSigningKey(@Param('sandboxId') sandboxId: string): Promise<string> {
921+
return this.sandboxService.rotateSigningKey(sandboxId)
922+
}
923+
878924
@Post(':sandboxId/last-activity')
879925
@ApiOperation({
880926
summary: 'Update sandbox last activity',
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright Daytona Platforms Inc.
3+
* SPDX-License-Identifier: AGPL-3.0
4+
*/
5+
6+
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'
7+
import { nanoid } from 'nanoid'
8+
import { Sandbox } from './sandbox.entity'
9+
10+
@Entity('sandbox_metadata')
11+
export class SandboxMetadata {
12+
@PrimaryColumn()
13+
sandboxId: string
14+
15+
// General-purpose HMAC key for signed sandbox URLs (currently pre-signed file
16+
// URLs). Stable across start/stop so signatures survive restarts; rotated only
17+
// via the dedicated rotate endpoint, which invalidates every prior signature.
18+
// Each signing purpose MUST use a distinct domain label in its canonical string
19+
// (e.g. "v1:files:...") so signatures can never be replayed across purposes.
20+
@Column({ type: 'character varying' })
21+
signingKey: string = nanoid(32)
22+
23+
@OneToOne(() => Sandbox, { onDelete: 'CASCADE' })
24+
@JoinColumn({ name: 'sandboxId' })
25+
sandbox?: Sandbox
26+
}

apps/api/src/sandbox/sandbox.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { ProxyAuthContextGuard } from './guards/proxy-auth-context.guard'
5858
import { SshGatewayAuthContextGuard } from './guards/ssh-gateway-auth-context.guard'
5959
import { EventEmitter2 } from '@nestjs/event-emitter'
6060
import { SandboxLastActivity } from './entities/sandbox-last-activity.entity'
61+
import { SandboxMetadata } from './entities/sandbox-metadata.entity'
6162
import { SandboxActivityService } from './services/sandbox-activity.service'
6263
import { OpensearchModule } from 'nestjs-opensearch'
6364
import { TypedConfigService } from '../config/typed-config.service'
@@ -84,6 +85,7 @@ import { SandboxSearchAdapterProvider } from './providers/sandbox-search.provide
8485
Job,
8586
SandboxLastActivity,
8687
SandboxFork,
88+
SandboxMetadata,
8789
]),
8890
OpensearchModule.forRootAsync({
8991
inject: [TypedConfigService],

apps/api/src/sandbox/services/sandbox.service.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { InjectRepository } from '@nestjs/typeorm'
1717
import { Not, Repository, LessThan, In, JsonContains, FindOptionsWhere, ILike } from 'typeorm'
1818
import { Sandbox } from '../entities/sandbox.entity'
1919
import { SandboxFork } from '../entities/sandbox-fork.entity'
20+
import { SandboxMetadata } from '../entities/sandbox-metadata.entity'
2021
import { CreateSandboxDto } from '../dto/create-sandbox.dto'
2122
import { CreateSandboxSnapshotDto } from '../dto/create-sandbox-snapshot.dto'
2223
import { ForkSandboxDto } from '../dto/fork-sandbox.dto'
@@ -153,6 +154,8 @@ export class SandboxService {
153154
private readonly dockerRegistryService: DockerRegistryService,
154155
@InjectRepository(SandboxFork)
155156
private readonly sandboxForkRepository: Repository<SandboxFork>,
157+
@InjectRepository(SandboxMetadata)
158+
private readonly sandboxMetadataRepository: Repository<SandboxMetadata>,
156159
@Inject(SANDBOX_SEARCH_ADAPTER)
157160
private readonly sandboxSearchAdapter: SandboxSearchAdapter,
158161
) {}
@@ -2615,6 +2618,51 @@ export class SandboxService {
26152618
})
26162619
}
26172620

2621+
private async ensureMetadata(sandboxId: string): Promise<SandboxMetadata> {
2622+
const existing = await this.sandboxMetadataRepository.findOne({ where: { sandboxId } })
2623+
if (existing) {
2624+
return existing
2625+
}
2626+
2627+
// ON CONFLICT do nothing to avoid race conditions on sandbox creation where multiple requests
2628+
// may attempt to create metadata for the same sandbox
2629+
await this.sandboxMetadataRepository
2630+
.createQueryBuilder()
2631+
.insert()
2632+
.into(SandboxMetadata)
2633+
.values({
2634+
sandboxId,
2635+
signingKey: nanoid(32),
2636+
})
2637+
.orIgnore()
2638+
.execute()
2639+
2640+
return this.sandboxMetadataRepository.findOneOrFail({ where: { sandboxId } })
2641+
}
2642+
2643+
async getSigningKey(sandboxId: string): Promise<string> {
2644+
const cacheKey = `signing-key:${sandboxId}`
2645+
const cached = await this.redis.get(cacheKey)
2646+
if (cached) {
2647+
return cached
2648+
}
2649+
2650+
const metadata = await this.ensureMetadata(sandboxId)
2651+
await this.redis.setex(cacheKey, 300, metadata.signingKey)
2652+
return metadata.signingKey
2653+
}
2654+
2655+
async rotateSigningKey(sandboxId: string): Promise<string> {
2656+
await this.ensureMetadata(sandboxId)
2657+
const newKey = nanoid(32)
2658+
await this.sandboxMetadataRepository.update(sandboxId, { signingKey: newKey })
2659+
// Write-through (not delete): a concurrent getSigningKey that read the old key from the
2660+
// DB before this update could otherwise re-cache it after a plain delete, pinning a stale
2661+
// key for the full TTL. Overwriting with the new key closes that rotation race window.
2662+
await this.redis.setex(`signing-key:${sandboxId}`, 300, newKey)
2663+
return newKey
2664+
}
2665+
26182666
async updateLastActivityAt(sandboxId: string, lastActivityAt: Date): Promise<void> {
26192667
await this.sandboxActivityService.updateLastActivityAt(sandboxId, lastActivityAt)
26202668
}

0 commit comments

Comments
 (0)