Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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: 1 addition & 1 deletion apps/api/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import baseConfig from '../../eslint.config.mjs'
import baseConfig from '../eslint.config.mjs'

export default [
...baseConfig,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "bash {projectRoot}/scripts/validate-migration-paths.sh $(find {projectRoot}/src/migrations -name '*-migration.ts' -type f)"
"command": "bash api/scripts/validate-migration-paths.sh $(find api/src/migrations -name '*-migration.ts' -type f)"
}
}
}
Expand Down
24 changes: 15 additions & 9 deletions apps/api/scripts/validate-migration-paths.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,23 @@ LEGACY_CUTOFF=1770880371265
forbidden=()

for f in "$@"; do
rel="$(realpath --relative-to="$PWD" "$f")"

case "$rel" in
apps/api/src/migrations/pre-deploy/*-migration.ts) ;;
apps/api/src/migrations/post-deploy/*-migration.ts) ;;
apps/api/src/migrations/*/*-migration.ts)
rel="${f#"$PWD"/}"
rel="${rel#./}"
migration_path="${rel#*src/migrations/}"

if [ "$migration_path" = "$rel" ]; then
continue
fi

case "$migration_path" in
pre-deploy/*-migration.ts) ;;
post-deploy/*-migration.ts) ;;
*/*-migration.ts)
forbidden+=("$rel")
;;
apps/api/src/migrations/*-migration.ts)
timestamp=$(basename "$rel" | grep -oP '^\d+')
if [ -n "$timestamp" ] && [ "$timestamp" -le "$LEGACY_CUTOFF" ]; then
*-migration.ts)
timestamp="${migration_path%%-*}"
if [[ "$timestamp" =~ ^[0-9]+$ ]] && [ "$timestamp" -le "$LEGACY_CUTOFF" ]; then
continue
fi
forbidden+=("$rel")
Expand Down
29 changes: 27 additions & 2 deletions apps/api/src/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,39 @@
*/

import { Module } from '@nestjs/common'
import { AdminObservabilityController } from './controllers/observability.controller'
import { AdminOverviewController } from './controllers/overview.controller'
import { AdminRunnerController } from './controllers/runner.controller'
import { AdminSandboxController } from './controllers/sandbox.controller'
import {
ADMIN_AUDIT_LOG_READER,
ADMIN_CLOUDWATCH_LOG_READER,
ADMIN_PLATFORM_STATE_READER,
ADMIN_S3_OBJECT_READER,
AdminObservabilityService,
} from './services/observability.service'
import { AdminCloudWatchLogReader } from './services/observability-cloudwatch.reader'
import { AdminOverviewService } from './services/overview.service'
import { AdminS3ObjectReader } from './services/observability-s3.reader'
import { SandboxModule } from '../sandbox/sandbox.module'
import { RegionModule } from '../region/region.module'
import { OrganizationModule } from '../organization/organization.module'
import { UserModule } from '../user/user.module'
import { AuditModule } from '../audit/audit.module'
import { AuditService } from '../audit/services/audit.service'

@Module({
imports: [SandboxModule, RegionModule, OrganizationModule],
controllers: [AdminRunnerController, AdminSandboxController],
imports: [SandboxModule, RegionModule, OrganizationModule, UserModule, AuditModule],
controllers: [AdminRunnerController, AdminSandboxController, AdminOverviewController, AdminObservabilityController],
providers: [
AdminOverviewService,
AdminObservabilityService,
AdminCloudWatchLogReader,
AdminS3ObjectReader,
{ provide: ADMIN_PLATFORM_STATE_READER, useExisting: AdminOverviewService },
{ provide: ADMIN_AUDIT_LOG_READER, useExisting: AuditService },
{ provide: ADMIN_CLOUDWATCH_LOG_READER, useExisting: AdminCloudWatchLogReader },
{ provide: ADMIN_S3_OBJECT_READER, useExisting: AdminS3ObjectReader },
],
})
export class AdminModule {}
72 changes: 72 additions & 0 deletions apps/api/src/admin/controllers/observability.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2025 Daytona Platforms Inc.
* Modified by BoxLite AI, 2025-2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { Request } from 'express'
import { GUARDS_METADATA } from '@nestjs/common/constants'
import { AUDIT_CONTEXT_KEY, AuditContext } from '../../audit/decorators/audit.decorator'
import { AuditAction } from '../../audit/enums/audit-action.enum'
import { AuditTarget } from '../../audit/enums/audit-target.enum'
import { CombinedAuthGuard } from '../../auth/combined-auth.guard'
import { SystemActionGuard } from '../../auth/system-action.guard'
import { RequiredApiRole } from '../../common/decorators/required-role.decorator'
import { SystemRole } from '../../user/enums/system-role.enum'
import { AdminObservabilityController } from './observability.controller'

describe('AdminObservabilityController audit metadata', () => {
function auditContext(methodName: keyof AdminObservabilityController): AuditContext {
return Reflect.getMetadata(AUDIT_CONTEXT_KEY, AdminObservabilityController.prototype[methodName])
}

it('audits investigate with a scoped target id and sanitized query metadata', () => {
const context = auditContext('investigate')
const request = {
query: {
traceId: 'trace-1',
boxId: 'box-1',
runnerId: 'runner-1',
from: '2026-06-05T00:00:00.000Z',
to: '2026-06-05T01:00:00.000Z',
search: 'secret token',
},
} as unknown as Request

expect(context).toMatchObject({
action: AuditAction.READ,
targetType: AuditTarget.OBSERVABILITY,
})
expect(context.targetIdFromRequest?.(request)).toBe('traceId:trace-1')
expect(context.requestMetadata?.surface(request)).toBe('admin_observability')
expect(context.requestMetadata?.query(request)).toEqual({
traceId: 'trace-1',
boxId: 'box-1',
runnerId: 'runner-1',
from: '2026-06-05T00:00:00.000Z',
to: '2026-06-05T01:00:00.000Z',
search: { present: true, length: 12 },
})
})

it('audits trace span detail reads with the path trace id', () => {
const context = auditContext('getTraceSpans')
const request = {
params: { traceId: 'trace-path-1' },
query: { boxId: 'box-1' },
} as unknown as Request

expect(context.targetIdFromRequest?.(request)).toBe('traceId:trace-path-1')
expect(context.requestMetadata?.query(request)).toEqual({ boxId: 'box-1' })
})
})

describe('AdminObservabilityController access control', () => {
it('requires authenticated Admin API access for all observability endpoints', () => {
const guards = Reflect.getMetadata(GUARDS_METADATA, AdminObservabilityController)
const requiredApiRoles = Reflect.getMetadata(RequiredApiRole.KEY, AdminObservabilityController)

expect(guards).toEqual([CombinedAuthGuard, SystemActionGuard])
expect(requiredApiRoles).toEqual([SystemRole.ADMIN])
})
})
208 changes: 208 additions & 0 deletions apps/api/src/admin/controllers/observability.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/*
* Copyright 2025 Daytona Platforms Inc.
* Modified by BoxLite AI, 2025-2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { Controller, Get, HttpCode, Param, Query, UseGuards } from '@nestjs/common'
import { ApiBearerAuth, ApiOAuth2, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'
import { Request } from 'express'
import { Audit } from '../../audit/decorators/audit.decorator'
import { AuditAction } from '../../audit/enums/audit-action.enum'
import { AuditTarget } from '../../audit/enums/audit-target.enum'
import { CombinedAuthGuard } from '../../auth/combined-auth.guard'
import { SystemActionGuard } from '../../auth/system-action.guard'
import { RequiredApiRole } from '../../common/decorators/required-role.decorator'
import { MetricsResponseDto } from '../../sandbox-telemetry/dto/metrics-response.dto'
import { PaginatedLogsDto } from '../../sandbox-telemetry/dto/paginated-logs.dto'
import { PaginatedTracesDto } from '../../sandbox-telemetry/dto/paginated-traces.dto'
import { TraceSpanDto } from '../../sandbox-telemetry/dto/trace-span.dto'
import { SystemRole } from '../../user/enums/system-role.enum'
import {
AdminObservabilityLogsQueryParamsDto,
AdminObservabilityMetricsQueryParamsDto,
AdminObservabilityQueryParamsDto,
} from '../dto/observability-query.dto'
import {
AdminObservabilityInvestigateQueryParamsDto,
AdminObservabilityInvestigateResponseDto,
} from '../dto/observability-investigate.dto'
import { AdminObservabilityStatusDto } from '../dto/observability-status.dto'
import { AdminObservabilityService } from '../services/observability.service'

const OBSERVABILITY_AUDIT_QUERY_KEYS = [
'from',
'to',
'page',
'limit',
'layer',
'serviceName',
'orgId',
'userId',
'sandboxId',
'boxId',
'runnerId',
'machineId',
'traceId',
'requestId',
'operationId',
'executionId',
'jobId',
'severities',
'metricNames',
] as const

const OBSERVABILITY_TARGET_ID_KEYS = [
'traceId',
'userId',
'boxId',
'sandboxId',
'runnerId',
'machineId',
'executionId',
'jobId',
'requestId',
'operationId',
] as const

function firstQueryValue(value: unknown): string | undefined {
const resolvedValue = Array.isArray(value) ? value[0] : value
return typeof resolvedValue === 'string' && resolvedValue.length > 0 ? resolvedValue : undefined
}

function resolveObservabilityTargetId(req: Request): string | undefined {
for (const key of OBSERVABILITY_TARGET_ID_KEYS) {
const value = firstQueryValue(req.query[key])
if (value) {
return `${key}:${value}`
}
}

return undefined
}

function buildObservabilityAuditQuery(req: Request): Record<string, unknown> {
const metadata: Record<string, unknown> = {}
for (const key of OBSERVABILITY_AUDIT_QUERY_KEYS) {
const value = req.query[key]
if (value !== undefined) {
metadata[key] = value
}
}

const search = firstQueryValue(req.query.search)
if (search) {
metadata.search = { present: true, length: search.length }
}

return metadata
}

const OBSERVABILITY_READ_AUDIT = {
action: AuditAction.READ,
targetType: AuditTarget.OBSERVABILITY,
targetIdFromRequest: resolveObservabilityTargetId,
requestMetadata: {
surface: () => 'admin_observability',
query: buildObservabilityAuditQuery,
},
}

@ApiTags('admin')
@Controller('admin/observability')
@UseGuards(CombinedAuthGuard, SystemActionGuard)
@RequiredApiRole([SystemRole.ADMIN])
@ApiOAuth2(['openid', 'profile', 'email'])
@ApiBearerAuth()
export class AdminObservabilityController {
constructor(private readonly observabilityService: AdminObservabilityService) {}

@Get('status')
@HttpCode(200)
@ApiOperation({
summary: 'Get admin observability backend and layer status',
operationId: 'adminGetObservabilityStatus',
})
@ApiResponse({ status: 200, type: AdminObservabilityStatusDto })
@Audit(OBSERVABILITY_READ_AUDIT)
async getStatus(): Promise<AdminObservabilityStatusDto> {
return this.observabilityService.getStatus()
}

@Get('logs')
@HttpCode(200)
@ApiOperation({
summary: 'Get admin-scoped logs',
operationId: 'adminGetObservabilityLogs',
})
@ApiResponse({ status: 200, type: PaginatedLogsDto })
@Audit(OBSERVABILITY_READ_AUDIT)
async getLogs(@Query() queryParams: AdminObservabilityLogsQueryParamsDto): Promise<PaginatedLogsDto> {
return this.observabilityService.getLogs(queryParams)
}

@Get('traces')
@HttpCode(200)
@ApiOperation({
summary: 'Get admin-scoped traces',
operationId: 'adminGetObservabilityTraces',
})
@ApiResponse({ status: 200, type: PaginatedTracesDto })
@Audit(OBSERVABILITY_READ_AUDIT)
async getTraces(@Query() queryParams: AdminObservabilityQueryParamsDto): Promise<PaginatedTracesDto> {
return this.observabilityService.getTraces({
...queryParams,
page: queryParams.page ?? 1,
limit: queryParams.limit ?? 100,
})
}

@Get('traces/:traceId')
@HttpCode(200)
@ApiOperation({
summary: 'Get admin-scoped trace spans',
operationId: 'adminGetObservabilityTraceSpans',
})
@ApiParam({ name: 'traceId', type: 'string' })
@ApiResponse({ status: 200, type: [TraceSpanDto] })
@Audit({
...OBSERVABILITY_READ_AUDIT,
targetIdFromRequest: (req) => `traceId:${req.params.traceId}`,
})
async getTraceSpans(
@Param('traceId') traceId: string,
@Query() queryParams: AdminObservabilityQueryParamsDto,
): Promise<TraceSpanDto[]> {
return this.observabilityService.getTraceSpans(traceId, queryParams)
}

@Get('metrics')
@HttpCode(200)
@ApiOperation({
summary: 'Get admin-scoped metrics',
operationId: 'adminGetObservabilityMetrics',
})
@ApiResponse({ status: 200, type: MetricsResponseDto })
@Audit(OBSERVABILITY_READ_AUDIT)
async getMetrics(@Query() queryParams: AdminObservabilityMetricsQueryParamsDto): Promise<MetricsResponseDto> {
return this.observabilityService.getMetrics(queryParams)
}

@Get('investigate')
@HttpCode(200)
@ApiOperation({
summary: 'Investigate related observability and platform state from trace or resource identifiers',
operationId: 'adminInvestigateObservability',
})
@ApiResponse({ status: 200, type: AdminObservabilityInvestigateResponseDto })
@Audit(OBSERVABILITY_READ_AUDIT)
async investigate(
@Query() queryParams: AdminObservabilityInvestigateQueryParamsDto,
): Promise<AdminObservabilityInvestigateResponseDto> {
return this.observabilityService.investigate({
...queryParams,
page: queryParams.page ?? 1,
limit: queryParams.limit ?? 100,
})
}
}
Loading