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

146 lines
4.8 KiB
TypeScript

import { apiError, type ApiErrorCode } from '@/lib/server/api-response';
import type {
PersistClassroomOwnership,
PersistedClassroomData,
} from '@/lib/server/classroom-storage';
import {
evaluateResourceAuthorization,
resolveIdentityEnforcementMode,
type IdentityEnforcementMode,
} from '@/lib/server/authz/identity-enforcement';
import {
resolveRequestPrincipal,
type PrincipalResolution,
type RequestPrincipalResolver,
} from '@/lib/server/authz/principal';
import { resourceOwnerBindingFromPrincipal } from '@/lib/server/authz/owner-binding';
import type { ResourceAuthorizationAction } from '@/lib/server/authz/resource-ownership';
export const CLASSROOM_MANAGE_ANY_CAPABILITY = 'classroom:manage:any';
export const CLASSROOM_CREATE_ANY_CAPABILITY = 'classroom:create:any';
export interface ClassroomAccessDeps {
readonly mode?: IdentityEnforcementMode;
readonly resolvePrincipal?: RequestPrincipalResolver;
}
export interface ClassroomAccessDenial {
readonly allowed: false;
readonly status: number;
readonly code: ApiErrorCode;
readonly message: string;
}
export interface ClassroomAccessAllowed {
readonly allowed: true;
}
export type ClassroomAccessResult = ClassroomAccessAllowed | ClassroomAccessDenial;
export type ClassroomCreationAccessResult =
| (ClassroomAccessAllowed & { readonly ownership: PersistClassroomOwnership })
| ClassroomAccessDenial;
function classroomOwnership(classroom: PersistedClassroomData) {
const hasOwner = Object.prototype.hasOwnProperty.call(classroom, 'ownerPrincipalId');
const hasGuest = Object.prototype.hasOwnProperty.call(classroom, 'guestPrincipalId');
if (hasOwner && hasGuest) return { state: 'unknown' as const };
if (hasOwner) {
return { state: 'owned' as const, ownerPrincipalId: classroom.ownerPrincipalId ?? '' };
}
if (hasGuest) {
return { state: 'guest-owned' as const, guestPrincipalId: classroom.guestPrincipalId ?? '' };
}
return { state: 'legacy-unowned' as const };
}
async function resolvePrincipalSafely(
request: Request,
resolver?: RequestPrincipalResolver,
): Promise<PrincipalResolution> {
try {
return await (resolver ?? resolveRequestPrincipal)(request);
} catch {
return { state: 'unavailable', reason: 'resolver_failed' };
}
}
function denialFor(principal: PrincipalResolution, indeterminate = false): ClassroomAccessDenial {
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 (indeterminate) {
return {
allowed: false,
status: 409,
code: 'OWNERSHIP_MIGRATION_REQUIRED',
message: 'This classroom 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 classroom.',
};
}
/** Resolve the server-side owner binding for a newly persisted classroom. */
export async function authorizeClassroomCreation(
request: Request,
deps: ClassroomAccessDeps = {},
): Promise<ClassroomCreationAccessResult> {
const principal = await resolvePrincipalSafely(request, deps.resolvePrincipal);
const ownership = resourceOwnerBindingFromPrincipal(principal);
const mode = deps.mode ?? resolveIdentityEnforcementMode();
if (mode === '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_CREATE_ANY_CAPABILITY)) {
return { allowed: true, ownership: {} };
}
return denialFor(principal);
}
/** Enforce access to an existing raw classroom when identity is required. */
export async function authorizeClassroomRequest(
request: Request,
classroom: PersistedClassroomData,
action: ResourceAuthorizationAction,
deps: ClassroomAccessDeps = {},
): Promise<ClassroomAccessResult> {
if ((deps.mode ?? resolveIdentityEnforcementMode()) === 'shadow') {
return { allowed: true };
}
const evaluation = await evaluateResourceAuthorization(
{
request,
ownership: classroomOwnership(classroom),
action,
overrideCapability: CLASSROOM_MANAGE_ANY_CAPABILITY,
},
deps.resolvePrincipal ? { resolvePrincipal: deps.resolvePrincipal } : {},
);
return evaluation.decision.effect === 'allow'
? { allowed: true }
: denialFor(evaluation.principal, evaluation.decision.effect === 'indeterminate');
}
export function classroomAccessError(result: ClassroomAccessDenial) {
return apiError(result.code, result.status, result.message);
}