Files
OmniRoute/src/lib/api/requireManagementAuth.ts
Webman 0023a9ec04 fix: make release/v3.8.50 CI gates green (run 32786966560 root causes) (#11450)
Validated in a combined 3-PR batch worktree off release/v3.8.51 tip. This PR conflicted against today's accumulated merges (mostly pure provider-count drift: 353 vs its 352 snapshot across 51 docs/i18n/SVG files — resolved to the release's current 353, confirmed byte-identical besides the count on diff). Two real code conflicts:
- src/lib/usage/providerLimits.ts: this PR's `syntheticCooldownOutlivedByRealWindows()` is genuinely new (didn't exist on the tip; a caller already referencing it elsewhere in the file confirmed it was required) — kept in full.
- tests/unit/providers-constants-split.test.ts: both sides' running-count comments land at the same 233 via different additions (this PR's volcengine-agent/coding-plan vs the v3.8.50 back-merge's Synthetic + Kilo Gateway, both already present in providers.ts) — combined as sequential history, no functional change.

Resolution pushed to the PR branch and re-validated:
- Focused tests: 8134-github-t5-fallback-filter, cc-compatible-provider, cli-oneproxy-commands, hard-session-lease-bypass-inventory, llm-selector-custom-vision-models, model-capabilities-registry, openapi-coverage, provider-limits-recovery, providers-constants-split, repro-glm-iso-reset-24h-cap, startup-stale-cooldown-recovery, memory-pipeline, security-hardening, skills-pipeline — part of batch's 165/165 node:test run; glmCodingProviderConfig.test.ts (vitest) 10/10
- typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity, check:docs-counts-sync — all OK
- Full-repo lint: 228 pre-existing dashboard react-hooks/* findings, unrelated to this diff

Thanks for this — root-causing all 18 failed jobs from a single CI run with gate-by-gate evidence (including the harder-to-spot ones like the antigravity BYOP legacy-ack misread and the reserved-alias `cc` guard) is exactly the kind of base-red drain this release needs.
2026-08-25 18:55:18 -03:00

165 lines
5.9 KiB
TypeScript

import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { getApiKeyMetadata } from "@/lib/db/apiKeys";
import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth";
import { evaluateAccessTokenAuth } from "@/server/authz/accessTokenAuth";
import { isTrustedLoopbackInternalServiceRequest } from "@/lib/api/internalServiceAuth";
import { AUTHZ_HEADER_AUTH_KIND, AUTHZ_HEADER_AUTH_LABEL } from "@/server/authz/headers";
import {
MANAGE_SCOPE,
MCP_CONNECT_SCOPE,
hasManageScope as hasManageScopeShared,
hasMcpConnectOrManageScope,
} from "@/shared/constants/managementScopes";
export { MANAGE_SCOPE };
/**
* Check whether any of the supplied scopes authorizes management API access.
*
* Re-exported here for backwards compatibility with existing callers. The
* canonical definition lives in `@/shared/constants/managementScopes`.
*/
export function hasManageScope(scopes: string[] = []): boolean {
return hasManageScopeShared(scopes);
}
interface RequireManagementAuthOptions {
alwaysRequireAuth?: boolean;
invalidApiKeyStatus?: 401 | 403;
/**
* Accept the narrow `mcp:connect` scope in the API-key branch, mirroring the
* #9159 carve-out the central managementPolicy already applies to /api/mcp/*
* paths. Only the MCP transport routes (stream/sse/status/tools) may enable
* this — every other management route stays manage/admin-only.
*/
acceptMcpConnectScope?: boolean;
}
function invalidManagementTokenResponse(options: RequireManagementAuthOptions): Response {
const status = options.invalidApiKeyStatus ?? 403;
return createErrorResponse({
status,
message: status === 401 ? "Invalid API key" : "Invalid management token",
type: "invalid_request",
});
}
export async function requireManagementAuth(
request?: Request | null,
options: RequireManagementAuthOptions = {}
): Promise<Response | null> {
// Direct in-process invocation without a Request (unit/integration tests call
// route handlers as plain functions) is a trusted local caller — Next.js always
// supplies a real Request on the HTTP path, so this branch is unreachable there.
if (request === undefined || request === null) {
return null;
}
if (!options.alwaysRequireAuth && !(await isAuthRequired(request))) {
return null;
}
if (await isDashboardSessionAuthenticated(request)) {
return null;
}
if (isTrustedLoopbackInternalServiceRequest(request)) {
return null;
}
// The authz pipeline strips the raw machine-token header after it validates it
// and forwards this trusted subject stamp to route handlers.
if (
request.headers.get(AUTHZ_HEADER_AUTH_KIND) === "management_key" &&
request.headers.get(AUTHZ_HEADER_AUTH_LABEL) === "local-cli-token"
) {
return null;
}
// Direct/raw-Node callers without the central pipeline can still validate the
// CLI token here, including the trusted peer-locality stamp path.
if (await isCliTokenAuthValid(request)) {
return null;
}
// Scoped CLI access token (remote mode). Intercepted BEFORE the API-key branch:
// these `oma_` tokens are management/CLI credentials, not inference API keys,
// and would otherwise be rejected by isValidApiKey. Same shared evaluation the
// central managementPolicy uses (no drift). Dashboard JWT, the loopback CLI
// token, and manage-scope API keys remain full-access above/below.
const accessVerdict = evaluateAccessTokenAuth(request);
switch (accessVerdict.kind) {
case "ok":
return null;
case "error":
return createErrorResponse({
status: 503,
message: "Service temporarily unavailable",
type: "server_error",
});
case "invalid":
return createErrorResponse({
status: 401,
message: "Invalid or expired access token",
type: "invalid_request",
});
case "insufficient":
return createErrorResponse({
status: 403,
message: `Access token scope '${accessVerdict.have}' is insufficient; '${accessVerdict.need}' required.`,
type: "invalid_request",
});
case "absent":
break; // no oma_ token → fall through to API-key auth
}
// Management auth never honours a URL-borne credential (header-only) — a token
// in the path/query must not authenticate a management route. See #3300 follow-up.
const apiKey = extractApiKey(request, { allowUrl: false });
if (apiKey) {
let meta: Awaited<ReturnType<typeof getApiKeyMetadata>>;
try {
if (!(await isValidApiKey(apiKey))) {
return invalidManagementTokenResponse(options);
}
meta = await getApiKeyMetadata(apiKey);
} catch {
return createErrorResponse({
status: 503,
message: "Service temporarily unavailable",
type: "server_error",
});
}
// API-key branch: with acceptMcpConnectScope (MCP transport routes) the
// #9159 carve-out applies — hasMcpConnectOrManageScope accepts manage,
// admin, and mcp:connect. Without it, the guard stays manage-only. A null
// meta (valid key, metadata unavailable — deleted mid-request) falls
// through to the same 403 as the default path for every caller, keeping
// the error contract uniform.
if (
meta &&
(options.acceptMcpConnectScope
? hasMcpConnectOrManageScope(meta.scopes)
: hasManageScope(meta.scopes))
) {
return null;
}
return createErrorResponse({
status: 403,
message: options.acceptMcpConnectScope
? `API key lacks '${MCP_CONNECT_SCOPE}' (or 'manage') scope. Enable it in the API Keys dashboard.`
: "API key lacks 'manage' scope. Enable it in the API Keys dashboard.",
type: "invalid_request",
});
}
return createErrorResponse({
status: 401,
message: "Authentication required",
type: "invalid_request",
});
}