chore: establish learning module baseline
This commit is contained in:
commit
2d04197f3f
2409 files changed
+533050
No files matched your search
@@ -0,0 +1,188 @@
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user