Files
openmaic/OpenMAIC/lib/server/authz/classroom-job-access.ts
2026-08-16 14:58:47 +08:00

189 lines
6.0 KiB
TypeScript

import { apiError, type ApiErrorCode } from '@/lib/server/api-response';
import type {
ClassroomGenerationJob,
ClassroomJobOwnershipBinding,
} from '@/lib/server/classroom-job-store';
import {
classroomJobOwnership,
observeClassroomJobAuthorization,
} from '@/lib/server/authz/classroom-job-shadow';
import {
evaluateResourceAuthorization,
resolveIdentityEnforcementMode,
type IdentityEnforcementMode,
} from '@/lib/server/authz/identity-enforcement';
import {
resolveRequestPrincipal,
type PrincipalResolution,
type RequestPrincipalResolver,
} from '@/lib/server/authz/principal';
import {
decideResourceAuthorization,
type ResourceAuthorizationAction,
type ResourceAuthorizationDecision,
} from '@/lib/server/authz/resource-ownership';
import type { ResourceAuthorizationObserver } from '@/lib/server/authz/shadow-observer';
import { resourceOwnerBindingFromPrincipal } from '@/lib/server/authz/owner-binding';
export const CLASSROOM_JOB_MANAGE_ANY_CAPABILITY = 'classroom-job:manage:any';
export const CLASSROOM_JOB_CREATE_ANY_CAPABILITY = 'classroom-job:create:any';
export interface ClassroomJobAccessDeps {
readonly mode?: IdentityEnforcementMode;
readonly resolvePrincipal?: RequestPrincipalResolver;
readonly observer?: ResourceAuthorizationObserver;
}
export interface ClassroomJobAccessDenial {
readonly allowed: false;
readonly status: number;
readonly code: ApiErrorCode;
readonly message: string;
}
export interface ClassroomJobAccessAllowed {
readonly allowed: true;
}
export type ClassroomJobAccessResult = ClassroomJobAccessAllowed | ClassroomJobAccessDenial;
export type ClassroomJobCreationAccessResult =
| (ClassroomJobAccessAllowed & { readonly ownership: ClassroomJobOwnershipBinding })
| ClassroomJobAccessDenial;
export type ClassroomJobListAccessResult =
| (ClassroomJobAccessAllowed & { readonly jobs: ClassroomGenerationJob[] })
| ClassroomJobAccessDenial;
function modeFrom(deps: ClassroomJobAccessDeps): IdentityEnforcementMode {
return deps.mode ?? resolveIdentityEnforcementMode();
}
async function resolvePrincipalSafely(
request: Request,
resolver: RequestPrincipalResolver | undefined,
): Promise<PrincipalResolution> {
try {
return await (resolver ?? resolveRequestPrincipal)(request);
} catch {
return { state: 'unavailable', reason: 'resolver_failed' };
}
}
function denialFor(
principal: PrincipalResolution,
decision?: ResourceAuthorizationDecision,
): ClassroomJobAccessDenial {
if (principal.state === 'unavailable') {
return {
allowed: false,
status: 503,
code: 'IDENTITY_UNAVAILABLE',
message: 'Identity verification is unavailable.',
};
}
if (principal.state === 'anonymous') {
return {
allowed: false,
status: 401,
code: 'AUTHENTICATION_REQUIRED',
message: 'Authentication is required.',
};
}
if (decision?.effect === 'indeterminate') {
return {
allowed: false,
status: 409,
code: 'OWNERSHIP_MIGRATION_REQUIRED',
message: 'This resource must be assigned to an account before it can be used.',
};
}
return {
allowed: false,
status: 403,
code: 'FORBIDDEN',
message: 'You do not have access to this resource.',
};
}
/** Resolve and bind the owner subject for a newly created public job. */
export async function authorizeClassroomJobCreation(
request: Request,
deps: ClassroomJobAccessDeps = {},
): Promise<ClassroomJobCreationAccessResult> {
const principal = await resolvePrincipalSafely(request, deps.resolvePrincipal);
const ownership: ClassroomJobOwnershipBinding = resourceOwnerBindingFromPrincipal(principal);
if (modeFrom(deps) === 'shadow') {
return { allowed: true, ownership };
}
if (principal.state !== 'authenticated') return denialFor(principal);
if (ownership.ownerPrincipalId || ownership.guestPrincipalId) {
return { allowed: true, ownership };
}
if (principal.principal.capabilities.includes(CLASSROOM_JOB_CREATE_ANY_CAPABILITY)) {
return { allowed: true, ownership: {} };
}
return denialFor(principal);
}
/** Enforce one job operation when required; preserve the former observer in shadow mode. */
export async function authorizeClassroomJobRequest(
request: Request,
job: ClassroomGenerationJob,
action: ResourceAuthorizationAction,
deps: ClassroomJobAccessDeps = {},
): Promise<ClassroomJobAccessResult> {
if (modeFrom(deps) === 'shadow') {
await observeClassroomJobAuthorization(request, job, action, {
...(deps.resolvePrincipal ? { resolvePrincipal: deps.resolvePrincipal } : {}),
...(deps.observer ? { observer: deps.observer } : {}),
});
return { allowed: true };
}
const evaluation = await evaluateResourceAuthorization(
{
request,
ownership: classroomJobOwnership(job),
action,
overrideCapability: CLASSROOM_JOB_MANAGE_ANY_CAPABILITY,
},
deps.resolvePrincipal ? { resolvePrincipal: deps.resolvePrincipal } : {},
);
return evaluation.decision.effect === 'allow'
? { allowed: true }
: denialFor(evaluation.principal, evaluation.decision);
}
/** Filter the global task list by resource ownership in required mode. */
export async function authorizeClassroomJobList(
request: Request,
jobs: ClassroomGenerationJob[],
deps: ClassroomJobAccessDeps = {},
): Promise<ClassroomJobListAccessResult> {
if (modeFrom(deps) === 'shadow') return { allowed: true, jobs };
const principal = await resolvePrincipalSafely(request, deps.resolvePrincipal);
if (principal.state !== 'authenticated') return denialFor(principal);
if (principal.principal.capabilities.includes(CLASSROOM_JOB_MANAGE_ANY_CAPABILITY)) {
return { allowed: true, jobs };
}
return {
allowed: true,
jobs: jobs.filter(
(job) =>
decideResourceAuthorization({
principal,
ownership: classroomJobOwnership(job),
action: 'read',
}).effect === 'allow',
),
};
}
export function classroomJobAccessError(result: ClassroomJobAccessDenial) {
return apiError(result.code, result.status, result.message);
}