This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user