Files
openmaic/OpenMAIC/lib/server/ops-access.ts

156 lines
5.0 KiB
TypeScript

import type { NextRequest } from 'next/server';
import { resolveDeploymentRole, hasDeploymentCapability } from '@/lib/config/deployment-role';
import { verifyAccessToken } from '@/lib/server/access-token';
import { ACCESS_CODE_COOKIE_NAME } from '@/lib/server/access-token-policy';
import { apiError, API_ERROR_CODES } from '@/lib/server/api-response';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export const OPS_PUBLIC_ORIGIN_ENV = 'OPS_PUBLIC_ORIGIN';
export const WORKS_SQUARE_API_BASE_URL_ENV = 'WORKS_SQUARE_API_BASE_URL';
export const WORKS_OPERATIONS_ADMIN_USERNAME = 'jiaoyuop';
function configuredOpsOrigin(): string | null | undefined {
const configured = process.env[OPS_PUBLIC_ORIGIN_ENV]?.trim();
if (!configured) return undefined;
try {
const url = new URL(configured);
if (
(url.protocol !== 'https:' && url.protocol !== 'http:') ||
url.username ||
url.password ||
url.search ||
url.hash ||
(url.pathname !== '/' && url.pathname !== '')
) {
return null;
}
return url.origin;
} catch {
return null;
}
}
function expectedOpsOrigin(request: Request): string | null {
const configured = configuredOpsOrigin();
if (configured !== undefined) return configured;
try {
return new URL(request.url).origin;
} catch {
return null;
}
}
export function hasValidOpsMutationOrigin(request: Request): boolean {
if (SAFE_METHODS.has(request.method.toUpperCase())) return true;
const origin = request.headers.get('origin');
if (!origin) return false;
try {
const expected = expectedOpsOrigin(request);
return expected !== null && new URL(origin).origin === expected;
} catch {
return false;
}
}
export function getServerDeploymentRole() {
return resolveDeploymentRole(
process.env.OPENMAIC_DEPLOYMENT_ROLE ?? process.env.NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE,
process.env.NODE_ENV,
);
}
export function isOpsDeployment(role = getServerDeploymentRole()): boolean {
return hasDeploymentCapability(role, 'manage_courses');
}
/**
* First-stage operations authorization.
*
* Deployment role limits which instance can host the workbench. ACCESS_CODE
* then authenticates the browser session. A future account/role system can
* replace this helper without changing every course route again.
*/
export function configuredWorksSquareApiOrigin(): string | null {
const raw = process.env[WORKS_SQUARE_API_BASE_URL_ENV]?.trim();
if (!raw) return null;
try {
const url = new URL(raw);
if (
(url.protocol !== 'https:' && url.protocol !== 'http:') ||
url.username ||
url.password ||
url.search ||
url.hash ||
(url.pathname !== '/' && url.pathname !== '')
) {
return null;
}
return url.origin;
} catch {
return null;
}
}
async function authorizeWithWorksSession(request: NextRequest, origin: string) {
const authorization = request.headers.get('authorization')?.trim() ?? '';
if (!/^Bearer\s+\S+$/.test(authorization) || authorization.length > 8192) {
return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Works administrator session required');
}
try {
const response = await fetch(`${origin}/api/auth/me`, {
method: 'GET',
headers: { Authorization: authorization, Accept: 'application/json' },
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Works administrator session is invalid');
}
const identity = (await response.json()) as { username?: unknown; site_role?: unknown };
if (
identity.username !== WORKS_OPERATIONS_ADMIN_USERNAME ||
identity.site_role !== 'admin'
) {
return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Works administrator role required');
}
return null;
} catch {
return apiError(API_ERROR_CODES.INTERNAL_ERROR, 503, 'Works identity service is unavailable');
}
}
export async function requireOpsAccess(request: NextRequest) {
if (!isOpsDeployment()) {
return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Operations capability is disabled');
}
if (!hasValidOpsMutationOrigin(request)) {
return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Cross-origin operations request denied');
}
const worksOrigin = configuredWorksSquareApiOrigin();
if (worksOrigin) return authorizeWithWorksSession(request, worksOrigin);
// Production Learning Ops always delegates identity to Works. The legacy
// ACCESS_CODE fallback is deliberately development-only.
if (process.env.NODE_ENV === 'production') {
return apiError(
API_ERROR_CODES.INTERNAL_ERROR,
503,
`Operations identity is not configured (${WORKS_SQUARE_API_BASE_URL_ENV})`,
);
}
const accessCode = process.env.ACCESS_CODE;
if (!accessCode) return null;
const token = request.cookies.get(ACCESS_CODE_COOKIE_NAME)?.value;
if (!token || !verifyAccessToken(token, accessCode)) {
return apiError(API_ERROR_CODES.INVALID_CREDENTIALS, 401, 'Operations access required');
}
return null;
}