207 lines
5.3 KiB
TypeScript
207 lines
5.3 KiB
TypeScript
import { DomainError } from '@devrunbook/domain'
|
|
|
|
import type {
|
|
InstanceRole,
|
|
WorkspaceRole,
|
|
} from '../auth/workspace-authorization'
|
|
import type { JobJsonValue, JobRecord, JobState } from '../jobs/job-queue'
|
|
|
|
export interface OperationsActor {
|
|
readonly userId: string
|
|
readonly instanceRole: InstanceRole
|
|
readonly workspaceId: string | null
|
|
readonly workspaceRole: WorkspaceRole | null
|
|
}
|
|
|
|
export interface OperationsActorLookup {
|
|
findOperationsActor(userId: string): Promise<OperationsActor | null>
|
|
}
|
|
|
|
export type OperationsScope =
|
|
| { readonly kind: 'instance' }
|
|
| { readonly kind: 'workspace'; readonly workspaceId: string }
|
|
|
|
export interface OperationsJob {
|
|
readonly id: string
|
|
readonly workspaceId: string | null
|
|
readonly type: string
|
|
readonly state: JobState
|
|
readonly progress: JobJsonValue
|
|
readonly attemptCount: number
|
|
readonly maxAttempts: number
|
|
readonly errorCode: string | null
|
|
readonly errorDetail: string | null
|
|
readonly retryable: boolean
|
|
readonly createdAt: Date
|
|
readonly updatedAt: Date
|
|
}
|
|
|
|
export interface AuditEventRecord {
|
|
readonly id: string
|
|
readonly occurredAt: Date
|
|
readonly actorUserId: string | null
|
|
readonly workspaceId: string | null
|
|
readonly action: string
|
|
readonly resourceType: string
|
|
readonly resourceId: string | null
|
|
readonly outcome: 'success' | 'denied' | 'failed'
|
|
readonly metadata: Readonly<Record<string, unknown>>
|
|
}
|
|
|
|
export interface OperationsPage<T> {
|
|
readonly items: readonly T[]
|
|
readonly nextCursor: string | null
|
|
}
|
|
|
|
export interface OperationsStore {
|
|
listJobs(request: {
|
|
scope: OperationsScope
|
|
cursor: string | null
|
|
limit: number
|
|
state?: JobState
|
|
}): Promise<OperationsPage<OperationsJob>>
|
|
findJob(scope: OperationsScope, jobId: string): Promise<OperationsJob | null>
|
|
retryJob(request: {
|
|
scope: OperationsScope
|
|
jobId: string
|
|
actorUserId: string
|
|
workspaceId: string | null
|
|
}): Promise<'retried' | 'not-found' | 'not-retryable'>
|
|
listAuditEvents(request: {
|
|
scope: OperationsScope
|
|
cursor: string | null
|
|
limit: number
|
|
action?: string
|
|
workspaceId?: string
|
|
}): Promise<OperationsPage<AuditEventRecord>>
|
|
}
|
|
|
|
export function operationsActor(context: OperationsActor): OperationsActor {
|
|
return context
|
|
}
|
|
|
|
function deny(): never {
|
|
throw new DomainError(
|
|
'operations_access_denied',
|
|
'Operations access is not permitted',
|
|
)
|
|
}
|
|
|
|
export function authorizeOperations(
|
|
actor: OperationsActor,
|
|
action: 'read' | 'retry',
|
|
): OperationsScope {
|
|
if (
|
|
actor.instanceRole === 'instance_owner' ||
|
|
actor.instanceRole === 'instance_admin'
|
|
) {
|
|
return { kind: 'instance' }
|
|
}
|
|
if (!actor.workspaceId || !actor.workspaceRole) return deny()
|
|
if (
|
|
action === 'retry' &&
|
|
actor.workspaceRole !== 'editor' &&
|
|
actor.workspaceRole !== 'owner'
|
|
) {
|
|
return deny()
|
|
}
|
|
return { kind: 'workspace', workspaceId: actor.workspaceId }
|
|
}
|
|
|
|
function validLimit(limit: number): number {
|
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
throw new DomainError(
|
|
'operations_limit_invalid',
|
|
'Operations page limit must be an integer from 1 through 100',
|
|
)
|
|
}
|
|
return limit
|
|
}
|
|
|
|
export function listOperationsJobs(
|
|
store: OperationsStore,
|
|
actor: OperationsActor,
|
|
request: { cursor?: string; limit?: number; state?: JobState } = {},
|
|
) {
|
|
return store.listJobs({
|
|
scope: authorizeOperations(actor, 'read'),
|
|
cursor: request.cursor ?? null,
|
|
limit: validLimit(request.limit ?? 25),
|
|
...(request.state ? { state: request.state } : {}),
|
|
})
|
|
}
|
|
|
|
export function getOperationsJob(
|
|
store: OperationsStore,
|
|
actor: OperationsActor,
|
|
jobId: string,
|
|
) {
|
|
return store.findJob(authorizeOperations(actor, 'read'), jobId)
|
|
}
|
|
|
|
export async function retryOperationsJob(
|
|
store: OperationsStore,
|
|
actor: OperationsActor,
|
|
jobId: string,
|
|
): Promise<void> {
|
|
const result = await store.retryJob({
|
|
scope: authorizeOperations(actor, 'retry'),
|
|
jobId,
|
|
actorUserId: actor.userId,
|
|
workspaceId: actor.workspaceId,
|
|
})
|
|
if (result === 'not-found') {
|
|
throw new DomainError('operations_job_not_found', 'Job was not found')
|
|
}
|
|
if (result === 'not-retryable') {
|
|
throw new DomainError(
|
|
'operations_job_not_retryable',
|
|
'Only retryable terminal jobs can be retried',
|
|
)
|
|
}
|
|
}
|
|
|
|
export function listOperationsAuditEvents(
|
|
store: OperationsStore,
|
|
actor: OperationsActor,
|
|
request: {
|
|
cursor?: string
|
|
limit?: number
|
|
action?: string
|
|
workspaceId?: string
|
|
} = {},
|
|
) {
|
|
const scope = authorizeOperations(actor, 'read')
|
|
if (
|
|
scope.kind === 'workspace' &&
|
|
request.workspaceId !== undefined &&
|
|
request.workspaceId !== scope.workspaceId
|
|
) {
|
|
return deny()
|
|
}
|
|
return store.listAuditEvents({
|
|
scope,
|
|
cursor: request.cursor ?? null,
|
|
limit: validLimit(request.limit ?? 25),
|
|
...(request.action ? { action: request.action } : {}),
|
|
...(request.workspaceId ? { workspaceId: request.workspaceId } : {}),
|
|
})
|
|
}
|
|
|
|
export function projectOperationsJob(job: JobRecord): OperationsJob {
|
|
return {
|
|
id: job.id,
|
|
workspaceId: job.workspaceId,
|
|
type: job.type,
|
|
state: job.state,
|
|
progress: job.progress,
|
|
attemptCount: job.attemptCount,
|
|
maxAttempts: job.maxAttempts,
|
|
errorCode: job.errorCode,
|
|
errorDetail: job.errorDetailRedacted,
|
|
retryable: false,
|
|
createdAt: job.createdAt,
|
|
updatedAt: job.updatedAt,
|
|
}
|
|
}
|