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

100 lines
3.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';
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 requireOpsAccess(request: NextRequest) {
if (!isOpsDeployment()) {
return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Operations capability is disabled');
}
const accessCode = process.env.ACCESS_CODE;
if (!accessCode) {
if (process.env.NODE_ENV === 'production') {
return apiError(
API_ERROR_CODES.INTERNAL_ERROR,
503,
'Operations deployment is not configured with ACCESS_CODE',
);
}
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');
}
if (!hasValidOpsMutationOrigin(request)) {
return apiError(API_ERROR_CODES.FORBIDDEN, 403, 'Cross-origin operations request denied');
}
return null;
}