146 lines
4.8 KiB
TypeScript
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);
|
|
}
|