189 lines
6.0 KiB
TypeScript
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);
|
|
}
|