From 5c8d9f80b094520b200bd3da8f7a46bb135319af Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Wed, 15 Jul 2026 17:56:14 +0700 Subject: [PATCH] fix: update the permission for viewing provider pages --- docs/ARCHITECTURE.md | 5 +- .../dashboard/providers/[id]/page.js | 10 +- .../dashboard/providers/new/page.js | 220 ------------------ .../(dashboard)/dashboard/providers/page.js | 20 +- .../usage/components/RequestDetailsTab.js | 8 +- .../api/oauth/[provider]/[action]/route.js | 10 +- src/app/api/oauth/codex/bulk-import/route.js | 9 +- src/app/api/oauth/codex/import-token/route.js | 2 +- src/app/api/oauth/cursor/auto-import/route.js | 6 +- src/app/api/oauth/cursor/import/route.js | 2 +- src/app/api/oauth/gitlab/pat/route.js | 2 +- src/app/api/oauth/iflow/cookie/route.js | 2 +- src/app/api/oauth/kiro/api-key/route.js | 2 +- src/app/api/oauth/kiro/auto-import/route.js | 6 +- .../api/oauth/kiro/import-cli-proxy/route.js | 2 +- src/app/api/oauth/kiro/import/route.js | 2 +- .../api/oauth/kiro/social-authorize/route.js | 4 + .../api/oauth/kiro/social-exchange/route.js | 2 +- src/app/api/provider-nodes/[id]/route.js | 6 +- src/app/api/provider-nodes/route.js | 8 +- src/app/api/provider-nodes/validate/route.js | 4 + src/app/api/providers/[id]/models/route.js | 2 +- src/app/api/providers/[id]/route.js | 29 ++- .../api/providers/[id]/test-models/route.js | 5 +- src/app/api/providers/[id]/test/route.js | 5 +- src/app/api/providers/client/route.js | 5 +- .../api/providers/kilo/free-models/route.js | 9 + src/app/api/providers/route.js | 12 +- .../api/providers/suggested-models/route.js | 9 + src/app/api/providers/test-batch/route.js | 5 +- src/app/api/providers/validate/route.js | 4 + src/app/api/users/[userId]/route.js | 14 +- src/dashboardGuard.js | 20 +- src/lib/db/index.js | 7 +- .../007-admin-provider-connections.js | 33 +++ src/lib/db/migrations/index.js | 3 +- src/lib/db/repos/connectionsRepo.js | 13 ++ src/lib/db/schema.js | 2 +- src/lib/providers/connectionAccess.js | 25 +- src/shared/components/Sidebar.js | 2 +- tests/unit/admin-provider-connections.test.js | 65 ++++++ tests/unit/api-key-credential-access.test.js | 16 +- .../compatible-provider-connections.test.js | 18 +- tests/unit/dashboard-guard.test.js | 46 ++++ .../provider-connection-admin-access.test.js | 21 +- 45 files changed, 362 insertions(+), 340 deletions(-) delete mode 100644 src/app/(dashboard)/dashboard/providers/new/page.js create mode 100644 src/lib/db/migrations/007-admin-provider-connections.js create mode 100644 tests/unit/admin-provider-connections.test.js diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 548c3190..dc291362 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -138,7 +138,7 @@ Main flow modules: Primary state DB: - `src/lib/localDb.js` -- file: `${DATA_DIR}/db.json` (or `~/.9router/db.json` when `DATA_DIR` is unset) +- file: `${DATA_DIR}/db/data.sqlite` (or `~/.9router/db/data.sqlite` when `DATA_DIR` is unset) - entities: providerConnections, providerNodes, modelAliases, combos, apiKeys, settings, pricing Usage DB: @@ -151,7 +151,8 @@ Usage DB: - Dashboard cookie auth: `src/proxy.js`, `src/app/api/auth/login/route.js` - API key generation/verification: `src/shared/utils/apiKey.js` -- Provider secrets persisted in `providerConnections` entries +- Provider secrets persisted in `providerConnections` entries. Provider management is administrator-only: regular users cannot access the Providers dashboard or provider/OAuth management APIs, but their API keys route through the shared active administrator credential pool. +- Schema migration 007 deletes legacy provider connections owned by regular users or missing users, and assigns legacy ownerless connections to the first administrator. - Optional proxy support for upstream calls via env proxy variables (`open-sse/utils/proxyFetch.js`) ## 5) Cloud Sync diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index b87a1ade..845eed85 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -10,7 +10,6 @@ import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useModelCaps } from "@/shared/hooks/useModelCaps"; -import useUserStore from "@/store/userStore"; import { translate } from "@/i18n/runtime"; import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher"; import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels"; @@ -39,7 +38,6 @@ export default function ProviderDetailPage() { const router = useRouter(); const providerId = params.id; const { getCaps } = useModelCaps(); - const user = useUserStore((state) => state.user); const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); const [providerNode, setProviderNode] = useState(null); @@ -81,8 +79,6 @@ export default function ProviderDetailPage() { const [importingQoderModels, setImportingQoderModels] = useState(false); const { copied, copy } = useCopyToClipboard(); - const canManageModelAvailability = user?.role === "admin"; - const AG_RISK_STORAGE_KEY = "ag_risk_confirmed"; const openOAuthConnection = () => { @@ -1121,7 +1117,7 @@ export default function ProviderDetailPage() { onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined} isTesting={testingModelIds.has(model.id)} isFree={model.isFree} - onDisable={canManageModelAvailability ? () => handleDisableModel(model.id) : undefined} + onDisable={() => handleDisableModel(model.id)} caps={getCaps(`${providerId}/${model.id}`)} thinkingSuffix={resolveThinkingSuffix(model.id)} /> @@ -1185,7 +1181,7 @@ export default function ProviderDetailPage() { })()} {/* Disabled models — restorable */} - {canManageModelAvailability && disabledDisplayModels.length > 0 && ( + {disabledDisplayModels.length > 0 && (

Disabled models ({disabledDisplayModels.length}):

@@ -1616,7 +1612,7 @@ export default function ProviderDetailPage() { )}
- {canManageModelAvailability && !isCompatible && (() => { + {!isCompatible && (() => { const allIds = [ ...models, ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), diff --git a/src/app/(dashboard)/dashboard/providers/new/page.js b/src/app/(dashboard)/dashboard/providers/new/page.js deleted file mode 100644 index 57d0a7f5..00000000 --- a/src/app/(dashboard)/dashboard/providers/new/page.js +++ /dev/null @@ -1,220 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { Card, Button, Input, Select, Toggle } from "@/shared/components"; -import { AI_PROVIDERS, AUTH_METHODS } from "@/shared/constants/config"; - -const providerOptions = Object.values(AI_PROVIDERS).map((p) => ({ - value: p.id, - label: p.name, -})); - -const authMethodOptions = Object.values(AUTH_METHODS).map((m) => ({ - value: m.id, - label: m.name, -})); - -export default function NewProviderPage() { - const router = useRouter(); - const [loading, setLoading] = useState(false); - const [formData, setFormData] = useState({ - provider: "", - authMethod: "api_key", - apiKey: "", - displayName: "", - isActive: true, - }); - const [errors, setErrors] = useState({}); - - const handleChange = (field, value) => { - setFormData((prev) => ({ ...prev, [field]: value })); - if (errors[field]) { - setErrors((prev) => ({ ...prev, [field]: null })); - } - }; - - const validate = () => { - const newErrors = {}; - if (!formData.provider) newErrors.provider = "Please select a provider"; - if (formData.authMethod === "api_key" && !formData.apiKey) { - newErrors.apiKey = "API Key is required"; - } - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = async (e) => { - e.preventDefault(); - if (!validate()) return; - - setLoading(true); - try { - const response = await fetch("/api/providers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - - if (response.ok) { - router.push("/dashboard/providers"); - } else { - const data = await response.json(); - setErrors({ submit: data.error || "Failed to create provider" }); - } - } catch (error) { - setErrors({ submit: "An error occurred. Please try again." }); - } finally { - setLoading(false); - } - }; - - const selectedProvider = AI_PROVIDERS[formData.provider]; - - return ( -
- {/* Header */} -
- - arrow_back - Back to Providers - -

Add New Provider

-

- Configure a new AI provider to use with your applications. -

-
- - {/* Form */} - -
- {/* Provider Selection */} - handleChange("apiKey", e.target.value)} - error={errors.apiKey} - hint="Your API key will be encrypted and stored securely." - required - /> - )} - - {/* OAuth2 Button */} - {formData.authMethod === "oauth2" && ( - -

- Connect your account using OAuth2 authentication. -

- -
- )} - - {/* Display Name */} - handleChange("displayName", e.target.value)} - hint="Optional. A friendly name to identify this configuration." - /> - - {/* Active Toggle */} - handleChange("isActive", checked)} - label="Active" - description="Enable this provider for use in your applications" - /> - - {/* Error Message */} - {errors.submit && ( -
- {errors.submit} -
- )} - - {/* Actions */} -
- - - - -
- -
-
- ); -} - diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 6db6c9e8..7d52d30b 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -22,7 +22,6 @@ import Link from "next/link"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { useNotificationStore } from "@/store/notificationStore"; import { useHeaderSearchStore } from "@/store/headerSearchStore"; -import useUserStore from "@/store/userStore"; import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; import AddCompatibleModal from "./components/AddCompatibleModal"; @@ -106,12 +105,9 @@ export default function ProvidersPage() { const [testingMode, setTestingMode] = useState(null); const [testResults, setTestResults] = useState(null); const notify = useNotificationStore(); - const user = useUserStore((state) => state.user); const searchQuery = useHeaderSearchStore((s) => s.query); const registerSearch = useHeaderSearchStore((s) => s.register); const unregisterSearch = useHeaderSearchStore((s) => s.unregister); - const isAdmin = user?.role === "admin"; - useEffect(() => { registerSearch("Search providers..."); return () => unregisterSearch(); @@ -287,7 +283,6 @@ export default function ProvidersPage() { .filter( ([, info]) => !info.hidden && - (user?.role === "admin" || !info.noAuth) && matchSearch(info.name), ) .sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); @@ -295,7 +290,6 @@ export default function ProvidersPage() { Object.entries(FREE_TIER_PROVIDERS).filter( ([, info]) => !info.hidden && - (user?.role === "admin" || !info.noAuth) && matchSearch(info.name) && (info.serviceKinds ?? ["llm"]).includes("llm"), ), @@ -336,10 +330,10 @@ export default function ProvidersPage() { freeEntries.length > 0 || freeTierEntries.length > 0 || apikeyEntries.length > 0 || - (isAdmin && ( + ( compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0 - )); + ); return (
@@ -353,7 +347,6 @@ export default function ProvidersPage() { )} {/* Custom provider configuration is administered centrally. */} - {isAdmin && (

@@ -404,7 +397,6 @@ export default function ProvidersPage() {

)}
- )} {/* OAuth Providers */} {oauthEntries.length > 0 && ( @@ -582,7 +574,7 @@ export default function ProvidersPage() {
*/} - {isAdmin && setShowAddCompatibleModal(false)} @@ -590,8 +582,8 @@ export default function ProvidersPage() { setProviderNodes((prev) => [...prev, node]); setShowAddCompatibleModal(false); }} - />} - {isAdmin && + setShowAddAnthropicCompatibleModal(false)} @@ -599,7 +591,7 @@ export default function ProvidersPage() { setProviderNodes((prev) => [...prev, node]); setShowAddAnthropicCompatibleModal(false); }} - />} + /> {/* Test Results Modal */} {testResults && ( diff --git a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js index f65d3bb3..69cb55b3 100644 --- a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js +++ b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js @@ -17,13 +17,13 @@ async function fetchProviderNames() { return { providerNameCache, providerNodesCache }; } - const nodesRes = await fetch("/api/provider-nodes"); - const nodesData = await nodesRes.json(); - const nodes = nodesData.nodes || []; + const topologyRes = await fetch("/api/usage/topology-providers"); + const topologyData = topologyRes.ok ? await topologyRes.json() : {}; + const nodes = topologyData.providers || []; providerNodesCache = {}; for (const node of nodes) { - providerNodesCache[node.id] = node.name; + providerNodesCache[node.provider] = node.nodeName || node.name || node.provider; } providerNameCache = { diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 2edc3932..9980d755 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -70,7 +70,7 @@ async function completeXaiManualCode(code, state, ownerId) { // GET /api/oauth/[provider]/device-code - Request device code (for device_code flow) export async function GET(request, { params }) { try { - const { user } = await getProviderConnectionAccess(); + const { user } = await getProviderConnectionAccess(request); const { provider, action } = await params; const { searchParams } = new URL(request.url); @@ -184,6 +184,9 @@ export async function GET(request, { params }) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("OAuth GET error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); } @@ -193,7 +196,7 @@ export async function GET(request, { params }) { // POST /api/oauth/[provider]/poll - Poll for token (device_code flow) export async function POST(request, { params }) { try { - const { user } = await getProviderConnectionAccess(); + const { user } = await getProviderConnectionAccess(request); const { provider, action } = await params; let body; try { @@ -362,6 +365,9 @@ export async function POST(request, { params }) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("OAuth POST error:", error); return NextResponse.json({ error: error.message }, { status: error.status || 500 }); } diff --git a/src/app/api/oauth/codex/bulk-import/route.js b/src/app/api/oauth/codex/bulk-import/route.js index 5cba4f48..6cb31ed6 100644 --- a/src/app/api/oauth/codex/bulk-import/route.js +++ b/src/app/api/oauth/codex/bulk-import/route.js @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { createProviderConnection } from "@/models"; import { extractCodexAccountInfo } from "@/lib/oauth/providers"; -import { requireCurrentDashboardUser } from "@/lib/auth/currentUser"; +import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; /** * POST /api/oauth/codex/bulk-import @@ -20,11 +20,10 @@ import { requireCurrentDashboardUser } from "@/lib/auth/currentUser"; export async function POST(request) { let user; try { - user = await requireCurrentDashboardUser(); + user = await requireProviderAdministrator(request); } catch (error) { - if (error.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (error.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (error.message === "Forbidden") return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); return NextResponse.json({ error: "Failed to authenticate user" }, { status: 500 }); } diff --git a/src/app/api/oauth/codex/import-token/route.js b/src/app/api/oauth/codex/import-token/route.js index 84458898..899056b6 100644 --- a/src/app/api/oauth/codex/import-token/route.js +++ b/src/app/api/oauth/codex/import-token/route.js @@ -12,7 +12,7 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; */ export async function POST(request) { try { - const { user } = await getProviderConnectionAccess(); + const { user } = await getProviderConnectionAccess(request); const { accessToken, name } = await request.json(); if (!accessToken || typeof accessToken !== "string") { diff --git a/src/app/api/oauth/cursor/auto-import/route.js b/src/app/api/oauth/cursor/auto-import/route.js index 5f84fa92..15ffa667 100644 --- a/src/app/api/oauth/cursor/auto-import/route.js +++ b/src/app/api/oauth/cursor/auto-import/route.js @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; import { access, constants } from "fs/promises"; import { homedir } from "os"; import { join } from "path"; @@ -174,8 +175,9 @@ async function extractTokensViaCLI(dbPath) { * Auto-detect and extract Cursor tokens from local SQLite database. * Strategy: better-sqlite3 → sqlite3 CLI → manual fallback */ -export async function GET() { +export async function GET(request) { try { + await requireProviderAdministrator(request); const platform = process.platform; const candidates = getCandidatePaths(platform); @@ -249,6 +251,8 @@ export async function GET() { // Strategy 3: ask user to paste manually return NextResponse.json({ found: false, windowsManual: true, dbPath }); } catch (error) { + if (error.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (error.message === "Forbidden") return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); console.log("Cursor auto-import error:", error); return NextResponse.json( { found: false, error: error.message }, diff --git a/src/app/api/oauth/cursor/import/route.js b/src/app/api/oauth/cursor/import/route.js index 68240453..30ef79e9 100644 --- a/src/app/api/oauth/cursor/import/route.js +++ b/src/app/api/oauth/cursor/import/route.js @@ -13,7 +13,7 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; */ export async function POST(request) { try { - const { user } = await getProviderConnectionAccess(); + const { user } = await getProviderConnectionAccess(request); const { accessToken, machineId } = await request.json(); if (!accessToken || typeof accessToken !== "string") { diff --git a/src/app/api/oauth/gitlab/pat/route.js b/src/app/api/oauth/gitlab/pat/route.js index e901b525..9da59cb2 100644 --- a/src/app/api/oauth/gitlab/pat/route.js +++ b/src/app/api/oauth/gitlab/pat/route.js @@ -10,7 +10,7 @@ const GITLAB_DEFAULT_BASE = "https://gitlab.com"; */ export async function POST(request) { try { - const { user: dashboardUser } = await getProviderConnectionAccess(); + const { user: dashboardUser } = await getProviderConnectionAccess(request); let body; try { body = await request.json(); diff --git a/src/app/api/oauth/iflow/cookie/route.js b/src/app/api/oauth/iflow/cookie/route.js index f164be41..7f9166ef 100644 --- a/src/app/api/oauth/iflow/cookie/route.js +++ b/src/app/api/oauth/iflow/cookie/route.js @@ -9,7 +9,7 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; */ export async function POST(request) { try { - const { user } = await getProviderConnectionAccess(); + const { user } = await getProviderConnectionAccess(request); const { cookie } = await request.json(); if (!cookie || typeof cookie !== "string") { diff --git a/src/app/api/oauth/kiro/api-key/route.js b/src/app/api/oauth/kiro/api-key/route.js index a5a417da..d947ddb2 100644 --- a/src/app/api/oauth/kiro/api-key/route.js +++ b/src/app/api/oauth/kiro/api-key/route.js @@ -11,7 +11,7 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; */ export async function POST(request) { try { - const { user } = await getProviderConnectionAccess(); + const { user } = await getProviderConnectionAccess(request); const { apiKey, region } = await request.json(); if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { diff --git a/src/app/api/oauth/kiro/auto-import/route.js b/src/app/api/oauth/kiro/auto-import/route.js index 0d28ea6e..1ad634af 100644 --- a/src/app/api/oauth/kiro/auto-import/route.js +++ b/src/app/api/oauth/kiro/auto-import/route.js @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; import { readFile, readdir } from "fs/promises"; import { homedir } from "os"; import { join } from "path"; @@ -9,8 +10,9 @@ import { join } from "path"; * For IDC (organization) tokens, also resolves clientId/clientSecret from the * linked client registration file so token refresh works. */ -export async function GET() { +export async function GET(request) { try { + await requireProviderAdministrator(request); const cachePath = join(homedir(), ".aws/sso/cache"); let files; @@ -123,6 +125,8 @@ export async function GET() { profileArn, }); } catch (error) { + if (error.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (error.message === "Forbidden") return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); console.log("Kiro auto-import error:", error); return NextResponse.json( { found: false, error: error.message }, diff --git a/src/app/api/oauth/kiro/import-cli-proxy/route.js b/src/app/api/oauth/kiro/import-cli-proxy/route.js index 7572fd88..3512465d 100644 --- a/src/app/api/oauth/kiro/import-cli-proxy/route.js +++ b/src/app/api/oauth/kiro/import-cli-proxy/route.js @@ -9,7 +9,7 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; */ export async function POST(request) { try { - const { user } = await getProviderConnectionAccess(); + const { user } = await getProviderConnectionAccess(request); const body = await request.json(); const rawAuth = body?.cliProxyAuth ?? body?.auth ?? body?.json ?? body; const tokenData = normalizeKiroExternalIdpAuth(rawAuth); diff --git a/src/app/api/oauth/kiro/import/route.js b/src/app/api/oauth/kiro/import/route.js index df1b17ef..360821f7 100644 --- a/src/app/api/oauth/kiro/import/route.js +++ b/src/app/api/oauth/kiro/import/route.js @@ -11,7 +11,7 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; */ export async function POST(request) { try { - const { user } = await getProviderConnectionAccess(); + const { user } = await getProviderConnectionAccess(request); const { refreshToken, clientId, clientSecret, region, authMethod, profileArn } = await request.json(); if (!refreshToken || typeof refreshToken !== "string") { diff --git a/src/app/api/oauth/kiro/social-authorize/route.js b/src/app/api/oauth/kiro/social-authorize/route.js index e5e12810..012d9216 100644 --- a/src/app/api/oauth/kiro/social-authorize/route.js +++ b/src/app/api/oauth/kiro/social-authorize/route.js @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { generatePKCE } from "@/lib/oauth/utils/pkce"; import { KiroService } from "@/lib/oauth/services/kiro"; +import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; /** * GET /api/oauth/kiro/social-authorize @@ -9,6 +10,7 @@ import { KiroService } from "@/lib/oauth/services/kiro"; */ export async function GET(request) { try { + await requireProviderAdministrator(request); const { searchParams } = new URL(request.url); const provider = searchParams.get("provider"); // "google" or "github" @@ -37,6 +39,8 @@ export async function GET(request) { provider, }); } catch (error) { + if (error.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (error.message === "Forbidden") return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); console.log("Kiro social authorize error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); } diff --git a/src/app/api/oauth/kiro/social-exchange/route.js b/src/app/api/oauth/kiro/social-exchange/route.js index d4a79a0f..85439188 100644 --- a/src/app/api/oauth/kiro/social-exchange/route.js +++ b/src/app/api/oauth/kiro/social-exchange/route.js @@ -10,7 +10,7 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; */ export async function POST(request) { try { - const { user } = await getProviderConnectionAccess(); + const { user } = await getProviderConnectionAccess(request); const { code, codeVerifier, provider } = await request.json(); if (!code || !codeVerifier) { diff --git a/src/app/api/provider-nodes/[id]/route.js b/src/app/api/provider-nodes/[id]/route.js index ab8411fb..f4cde619 100644 --- a/src/app/api/provider-nodes/[id]/route.js +++ b/src/app/api/provider-nodes/[id]/route.js @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { deleteProviderConnectionsByProvider, deleteProviderNode, getProviderConnections, getProviderNodeById, updateProviderConnection, updateProviderNode } from "@/models"; -import { requireAdminUser } from "@/lib/auth/currentUser"; +import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; function getAccessErrorResponse(error) { if (error.message === "Unauthorized") { @@ -15,7 +15,7 @@ function getAccessErrorResponse(error) { // PUT /api/provider-nodes/[id] - Update provider node export async function PUT(request, { params }) { try { - await requireAdminUser(); + await requireProviderAdministrator(request); const { id } = await params; const body = await request.json(); const { name, prefix, apiType, baseUrl } = body; @@ -98,7 +98,7 @@ export async function PUT(request, { params }) { // DELETE /api/provider-nodes/[id] - Delete provider node and its connections export async function DELETE(request, { params }) { try { - await requireAdminUser(); + await requireProviderAdministrator(request); const { id } = await params; const node = await getProviderNodeById(id); diff --git a/src/app/api/provider-nodes/route.js b/src/app/api/provider-nodes/route.js index 32cb2fc1..b92a3f95 100644 --- a/src/app/api/provider-nodes/route.js +++ b/src/app/api/provider-nodes/route.js @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { createProviderNode, getProviderNodes } from "@/models"; -import { requireAdminUser } from "@/lib/auth/currentUser"; +import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, CUSTOM_EMBEDDING_PREFIX } from "@/shared/constants/providers"; import { generateId } from "@/shared/utils"; @@ -29,9 +29,9 @@ function getAccessErrorResponse(error) { } // GET /api/provider-nodes - List all provider nodes -export async function GET() { +export async function GET(request) { try { - await requireAdminUser(); + await requireProviderAdministrator(request); const nodes = await getProviderNodes(); return NextResponse.json({ nodes }); } catch (error) { @@ -46,7 +46,7 @@ export async function GET() { // POST /api/provider-nodes - Create provider node export async function POST(request) { try { - await requireAdminUser(); + await requireProviderAdministrator(request); const body = await request.json(); const { name, prefix, apiType, baseUrl, type } = body; diff --git a/src/app/api/provider-nodes/validate/route.js b/src/app/api/provider-nodes/validate/route.js index 4148ab41..cf023647 100644 --- a/src/app/api/provider-nodes/validate/route.js +++ b/src/app/api/provider-nodes/validate/route.js @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { assertPublicUrl } from "@/shared/utils/ssrfGuard.js"; import { isLocalRequest } from "@/dashboardGuard"; +import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; // Fetch with timeout wrapper const fetchWithTimeout = (url, options, timeout = 10000) => { @@ -54,6 +55,7 @@ const getChatErrorMessage = (status) => { // POST /api/provider-nodes/validate - Validate API key against base URL export async function POST(request) { try { + await requireProviderAdministrator(request); const body = await request.json(); const { baseUrl, apiKey, type, modelId } = body; @@ -197,6 +199,8 @@ export async function POST(request) { return NextResponse.json({ valid: false, error: getModelsErrorMessage(res.status) }); } catch (error) { + if (error.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (error.message === "Forbidden") return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); const errorMessage = getErrorMessage(error); console.error("Error validating provider node:", { message: error.message, diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index c8d35d72..451e7a60 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -394,7 +394,7 @@ const PROVIDER_MODELS_CONFIG = { export async function GET(request, { params }) { try { const { id } = await params; - const { ownerId } = await getProviderConnectionAccess(); + const { ownerId } = await getProviderConnectionAccess(request); const connection = await getProviderConnectionById(id, ownerId); if (!connection) { diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js index 93a958ea..92337c4d 100644 --- a/src/app/api/providers/[id]/route.js +++ b/src/app/api/providers/[id]/route.js @@ -6,20 +6,8 @@ import { deleteProviderConnection, } from "@/models"; import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; -import { - isAnthropicCompatibleProvider, - isCustomEmbeddingProvider, - isOpenAICompatibleProvider, -} from "@/shared/constants/providers"; - -function isAdministratorManagedProvider(provider) { - return isOpenAICompatibleProvider(provider) - || isAnthropicCompatibleProvider(provider) - || isCustomEmbeddingProvider(provider); -} - function canMutateConnection(user, connection) { - return !isAdministratorManagedProvider(connection.provider) || user.role === "admin"; + return user.role === "admin"; } function normalizeProxyConfig(body = {}) { @@ -79,7 +67,7 @@ function shouldMergeProviderSpecificData(existing, incoming, hasLegacyProxy, has export async function GET(request, { params }) { try { const { id } = await params; - const { ownerId } = await getProviderConnectionAccess(); + const { ownerId } = await getProviderConnectionAccess(request); const connection = await getProviderConnectionById(id, ownerId); if (!connection) { @@ -98,6 +86,9 @@ export async function GET(request, { params }) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("Error fetching connection:", error); return NextResponse.json({ error: "Failed to fetch connection" }, { status: 500 }); } @@ -107,7 +98,7 @@ export async function GET(request, { params }) { export async function PUT(request, { params }) { try { const { id } = await params; - const { user, ownerId } = await getProviderConnectionAccess(); + const { user, ownerId } = await getProviderConnectionAccess(request); const body = await request.json(); const { name, @@ -193,6 +184,9 @@ export async function PUT(request, { params }) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("Error updating connection:", error); return NextResponse.json({ error: "Failed to update connection" }, { status: 500 }); } @@ -202,7 +196,7 @@ export async function PUT(request, { params }) { export async function DELETE(request, { params }) { try { const { id } = await params; - const { user, ownerId } = await getProviderConnectionAccess(); + const { user, ownerId } = await getProviderConnectionAccess(request); const existing = await getProviderConnectionById(id, ownerId); if (!existing) { @@ -222,6 +216,9 @@ export async function DELETE(request, { params }) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("Error deleting connection:", error); return NextResponse.json({ error: "Failed to delete connection" }, { status: 500 }); } diff --git a/src/app/api/providers/[id]/test-models/route.js b/src/app/api/providers/[id]/test-models/route.js index f953d45e..4e45e71a 100644 --- a/src/app/api/providers/[id]/test-models/route.js +++ b/src/app/api/providers/[id]/test-models/route.js @@ -14,7 +14,7 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; export async function POST(request, { params }) { try { const { id } = await params; - const { ownerId } = await getProviderConnectionAccess(); + const { ownerId } = await getProviderConnectionAccess(request); const connection = await getProviderConnectionById(id, ownerId); if (!connection) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); @@ -65,6 +65,9 @@ export async function POST(request, { params }) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("Error testing models:", error); return NextResponse.json({ error: "Test failed" }, { status: 500 }); } diff --git a/src/app/api/providers/[id]/test/route.js b/src/app/api/providers/[id]/test/route.js index a3f65e2c..a415dfac 100644 --- a/src/app/api/providers/[id]/test/route.js +++ b/src/app/api/providers/[id]/test/route.js @@ -7,7 +7,7 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; export async function POST(request, { params }) { try { const { id } = await params; - const { ownerId } = await getProviderConnectionAccess(); + const { ownerId } = await getProviderConnectionAccess(request); const connection = await getProviderConnectionById(id, ownerId); if (!connection) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); @@ -27,6 +27,9 @@ export async function POST(request, { params }) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("Error testing connection:", error); return NextResponse.json({ error: "Test failed" }, { status: 500 }); } diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js index 81e67fe5..0eb54438 100644 --- a/src/app/api/providers/client/route.js +++ b/src/app/api/providers/client/route.js @@ -77,7 +77,7 @@ function sortConnections(connections, sort) { export async function GET(request) { try { - const { ownerId } = await getProviderConnectionAccess(); + const { ownerId } = await getProviderConnectionAccess(request); await backfillCodexEmails(); const { searchParams } = new URL(request.url); @@ -126,6 +126,9 @@ export async function GET(request) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("Error fetching providers for client:", error); return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 }); } diff --git a/src/app/api/providers/kilo/free-models/route.js b/src/app/api/providers/kilo/free-models/route.js index 784b4594..61b5a070 100644 --- a/src/app/api/providers/kilo/free-models/route.js +++ b/src/app/api/providers/kilo/free-models/route.js @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; const KILO_MODELS_URL = "https://api.kilo.ai/api/gateway/models"; @@ -8,6 +9,14 @@ let cacheTimestamp = 0; const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour export async function GET() { + try { + await requireProviderAdministrator(request); + } catch (error) { + if (error.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (error.message === "Forbidden") return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + return NextResponse.json({ error: "Failed to authenticate user" }, { status: 500 }); + } + const now = Date.now(); // Return cached result if still valid diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 045bec41..2ae286a9 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -48,9 +48,9 @@ async function normalizeProxyPoolId(proxyPoolId) { } // GET /api/providers - List all connections -export async function GET() { +export async function GET(request) { try { - const { ownerId } = await getProviderConnectionAccess(); + const { ownerId } = await getProviderConnectionAccess(request); const connections = await getProviderConnections(ownerId ? { ownerId } : {}); // Build nodeNameMap for compatible providers (id → name) @@ -83,6 +83,9 @@ export async function GET() { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("Error fetching providers:", error); return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 }); } @@ -91,7 +94,7 @@ export async function GET() { // POST /api/providers - Create new connection (API Key only, OAuth via separate flow) export async function POST(request) { try { - const { user } = await getProviderConnectionAccess(); + const { user } = await getProviderConnectionAccess(request); const body = await request.json(); const provider = normalizeProviderId(body.provider); const { apiKey, name, displayName, priority, globalPriority, defaultModel, testStatus } = body; @@ -205,6 +208,9 @@ export async function POST(request) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("Error creating provider:", error); return NextResponse.json( { error: error.status === 409 ? error.message : "Failed to create provider" }, diff --git a/src/app/api/providers/suggested-models/route.js b/src/app/api/providers/suggested-models/route.js index c14f70c8..697ec46f 100644 --- a/src/app/api/providers/suggested-models/route.js +++ b/src/app/api/providers/suggested-models/route.js @@ -1,9 +1,18 @@ import { NextResponse } from "next/server"; import { FILTERS } from "./filters.js"; +import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; export const dynamic = "force-dynamic"; export async function GET(request) { + try { + await requireProviderAdministrator(request); + } catch (error) { + if (error.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (error.message === "Forbidden") return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + return NextResponse.json({ error: "Failed to authenticate user" }, { status: 500 }); + } + const { searchParams } = new URL(request.url); const url = searchParams.get("url"); const type = searchParams.get("type"); diff --git a/src/app/api/providers/test-batch/route.js b/src/app/api/providers/test-batch/route.js index deba3303..debc10dd 100644 --- a/src/app/api/providers/test-batch/route.js +++ b/src/app/api/providers/test-batch/route.js @@ -43,7 +43,7 @@ function isCompatibleProvider(providerId) { // POST /api/providers/test-batch - Test multiple connections by group export async function POST(request) { try { - const { ownerId } = await getProviderConnectionAccess(); + const { ownerId } = await getProviderConnectionAccess(request); const body = await request.json(); const { mode, providerId } = body; @@ -133,6 +133,9 @@ export async function POST(request) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } console.log("Error in batch test:", error); return NextResponse.json({ error: "Batch test failed" }, { status: 500 }); } diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index d5684091..0d761276 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -5,6 +5,7 @@ import { getDefaultModel } from "open-sse/config/providerModels.js"; import { resolveOllamaLocalHost, resolveXiaomiTokenplanBaseUrl, PROVIDERS } from "open-sse/config/providers.js"; import { openaiToCommandCodeRequest } from "open-sse/translator/request/openai-to-commandcode.js"; import { normalizeProviderId } from "@/lib/providerNormalization"; +import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; // Probe a webSearch/webFetch provider using its searchConfig/fetchConfig. // Returns true if API key is accepted (status !== 401 && !== 403). @@ -83,6 +84,7 @@ async function probeMediaProvider(provider, apiKey) { // POST /api/providers/validate - Validate API key with provider export async function POST(request) { try { + await requireProviderAdministrator(request); const body = await request.json(); const provider = normalizeProviderId(body.provider); const { apiKey, providerSpecificData } = body; @@ -628,6 +630,8 @@ export async function POST(request) { error: isValid ? null : (error || "Invalid API key"), }); } catch (error) { + if (error.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (error.message === "Forbidden") return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); console.log("Error validating API key:", error); return NextResponse.json({ error: "Validation failed" }, { status: 500 }); } diff --git a/src/app/api/users/[userId]/route.js b/src/app/api/users/[userId]/route.js index 8a2e1e08..4af0b78b 100644 --- a/src/app/api/users/[userId]/route.js +++ b/src/app/api/users/[userId]/route.js @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { countActiveAdmins, deleteUser, getUserById, updateUser } from "@/lib/db"; +import { countActiveAdmins, countProviderConnectionsByOwnerId, deleteUser, getUserById, updateUser } from "@/lib/db"; import { requireCurrentDashboardUser } from "@/lib/auth/currentUser"; const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; @@ -25,6 +25,12 @@ function wouldRemoveLastActiveAdmin(target, updates, activeAdminCount) { return (nextRole !== "admin" || !nextActive) && activeAdminCount <= 1; } +function wouldDemoteAdmin(target, updates) { + return target.role === "admin" + && Object.hasOwn(updates, "role") + && updates.role !== "admin"; +} + export async function PATCH(request, { params }) { try { const actor = await requireCurrentDashboardUser(); @@ -47,6 +53,9 @@ export async function PATCH(request, { params }) { if (wouldRemoveLastActiveAdmin(target, updates, await countActiveAdmins())) { throw new Error("At least one active administrator is required"); } + if (wouldDemoteAdmin(target, updates) && await countProviderConnectionsByOwnerId(target.id) > 0) { + throw new Error("Delete this administrator's provider connections before changing their role"); + } const user = await updateUser(target.id, updates); return NextResponse.json({ user }, { headers: NO_STORE_HEADERS }); @@ -65,6 +74,9 @@ export async function DELETE(request, { params }) { if (target.role === "admin" && target.isActive && await countActiveAdmins() <= 1) { throw new Error("At least one active administrator is required"); } + if (target.role === "admin" && await countProviderConnectionsByOwnerId(target.id) > 0) { + throw new Error("Delete this administrator's provider connections before deleting their account"); + } await deleteUser(target.id); return NextResponse.json({ success: true }, { headers: NO_STORE_HEADERS }); diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index b0b8ecfd..a02a8fa3 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -12,7 +12,7 @@ async function getCliToken() { return cachedCliToken; } -async function hasValidCliToken(request) { +export async function hasValidCliToken(request) { const token = request.headers.get(CLI_TOKEN_HEADER); if (!token) return false; return token === await getCliToken(); @@ -44,6 +44,9 @@ const ALWAYS_PROTECTED = [ // is disabled for local single-user deployments. const ADMIN_ONLY_PATHS = [ "/api/users", + "/api/providers", + "/api/provider-nodes", + "/api/oauth", "/api/tunnel", "/api/headroom", "/api/pxpipe", @@ -55,6 +58,7 @@ const ADMIN_ONLY_PATHS = [ // Dashboard paths requiring an administrator. Combo access is handled by its // owner-scoped API routes and is available to authenticated users. const ADMIN_ONLY_DASHBOARD_PATHS = [ + "/dashboard/providers", "/dashboard/token-saver", "/dashboard/pxpipe", "/dashboard/media-providers", @@ -234,13 +238,6 @@ export async function proxy(request) { } } - // Always protected - require valid JWT or local CLI token (machineId-based) - if (ALWAYS_PROTECTED.some((p) => pathname.startsWith(p))) { - if (await hasValidCliToken(request) || await hasValidToken(request)) - return NextResponse.next(); - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (isPublicLlmApi(pathname)) { if (await canAccessPublicLlmApi(request)) return NextResponse.next(); return NextResponse.json({ error: "API key required for remote API access" }, { status: 401 }); @@ -251,6 +248,13 @@ export async function proxy(request) { return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); } + // Always protected - require valid JWT or local CLI token (machineId-based) + if (ALWAYS_PROTECTED.some((p) => pathname.startsWith(p))) { + if (await hasValidCliToken(request) || await hasValidToken(request)) + return NextResponse.next(); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + // Deny-by-default for /api/* — public allow-list bypasses, everything else requires auth. if (pathname.startsWith("/api/")) { if (isPublicApi(pathname)) return NextResponse.next(); diff --git a/src/lib/db/index.js b/src/lib/db/index.js index bea9d172..ddaedfcb 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -18,7 +18,7 @@ export { getProviderConnections, getProviderConnectionById, createProviderConnection, updateProviderConnection, deleteProviderConnection, deleteProviderConnectionsByProvider, - reorderProviderConnections, cleanupProviderConnections, + reorderProviderConnections, cleanupProviderConnections, countProviderConnectionsByOwnerId, } from "./repos/connectionsRepo.js"; // Provider nodes @@ -146,9 +146,14 @@ export async function importDb(payload) { } } + const adminOwnerIds = new Set( + db.all(`SELECT id FROM users WHERE role = 'admin'`).map((user) => user.id), + ); const fallbackOwnerId = db.get(`SELECT id FROM users WHERE role = 'admin' ORDER BY createdAt ASC LIMIT 1`)?.id || null; + if (!fallbackOwnerId) throw new Error("Database import requires an administrator owner for provider connections"); for (const c of payload.providerConnections || []) { const { id, provider, authType, name, email, ownerId, priority, isActive, createdAt, updatedAt, ...rest } = c; + if (ownerId && !adminOwnerIds.has(ownerId)) continue; db.run( `INSERT OR REPLACE INTO providerConnections(id, provider, authType, name, email, ownerId, priority, isActive, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, provider, authType || "oauth", name || null, email || null, ownerId || fallbackOwnerId, priority || null, isActive === false ? 0 : 1, stringifyJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()] diff --git a/src/lib/db/migrations/007-admin-provider-connections.js b/src/lib/db/migrations/007-admin-provider-connections.js new file mode 100644 index 00000000..a6511f2e --- /dev/null +++ b/src/lib/db/migrations/007-admin-provider-connections.js @@ -0,0 +1,33 @@ +// Provider credentials are shared system infrastructure and may only belong +// to administrators. Remove legacy regular-user/orphaned credentials rather +// than silently preserving credentials a regular user can no longer manage. +const adminProviderConnectionsMigration = { + version: 7, + name: "admin-provider-connections", + up(db) { + const fallbackAdmin = db.get( + `SELECT id FROM users WHERE role = 'admin' ORDER BY createdAt ASC LIMIT 1`, + ); + + if (fallbackAdmin) { + db.run( + `UPDATE providerConnections + SET ownerId = ? + WHERE ownerId IS NULL OR ownerId = ''`, + [fallbackAdmin.id], + ); + } + + db.run( + `DELETE FROM providerConnections + WHERE NOT EXISTS ( + SELECT 1 + FROM users + WHERE users.id = providerConnections.ownerId + AND users.role = 'admin' + )`, + ); + }, +}; + +export default adminProviderConnectionsMigration; \ No newline at end of file diff --git a/src/lib/db/migrations/index.js b/src/lib/db/migrations/index.js index 39a9e158..a43c71a1 100644 --- a/src/lib/db/migrations/index.js +++ b/src/lib/db/migrations/index.js @@ -7,8 +7,9 @@ import m003 from "./003-api-key-owners.js"; import m004 from "./004-provider-connection-owners.js"; import m005 from "./005-usage-user-attribution.js"; import m006 from "./006-combo-owners.js"; +import m007 from "./007-admin-provider-connections.js"; -export const MIGRATIONS = [m001, m002, m003, m004, m005, m006].sort((a, b) => a.version - b.version); +export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007].sort((a, b) => a.version - b.version); export function latestVersion() { return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0; diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index b7cac877..c6b6f39e 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -157,6 +157,14 @@ function reorderInTx(db, providerId) { export async function createProviderConnection(data) { const db = await getAdapter(); + const owner = data.ownerId + ? db.get(`SELECT id, role FROM users WHERE id = ?`, [data.ownerId]) + : null; + if (!owner || owner.role !== "admin") { + const error = new Error("Provider connections require an administrator owner"); + error.status = 403; + throw error; + } const now = new Date().toISOString(); let result; @@ -210,6 +218,11 @@ export async function createProviderConnection(data) { return result; } +export async function countProviderConnectionsByOwnerId(ownerId) { + const db = await getAdapter(); + return db.get(`SELECT COUNT(*) AS count FROM providerConnections WHERE ownerId = ?`, [ownerId])?.count || 0; +} + // Critical: OAuth refresh token race — atomic merge inside transaction export async function updateProviderConnection(id, data) { const db = await getAdapter(); diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index c5cec44a..fe81886c 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -3,7 +3,7 @@ // pre-change safety backup in migrate.js: when the stored version is lower, // one lightweight DB backup is taken before applying schema changes. Forgetting // to bump only skips that backup — it does NOT break the additive auto-sync. -export const SCHEMA_VERSION = 6; +export const SCHEMA_VERSION = 7; export const PRAGMA_SQL = ` PRAGMA journal_mode = WAL; diff --git a/src/lib/providers/connectionAccess.js b/src/lib/providers/connectionAccess.js index ea1ce3e3..d599c7ec 100644 --- a/src/lib/providers/connectionAccess.js +++ b/src/lib/providers/connectionAccess.js @@ -1,9 +1,26 @@ -import { requireCurrentDashboardUser } from "@/lib/auth/currentUser"; +import { requireAdminUser } from "@/lib/auth/currentUser"; +import { getUsers } from "@/lib/db"; +import { hasValidCliToken } from "@/dashboardGuard"; -export async function getProviderConnectionAccess() { - const user = await requireCurrentDashboardUser(); +/** + * Provider credentials are system-managed. Only administrators may inspect + * or mutate their connections; request API-key ownership remains separate + * and is used solely for authentication and usage attribution. + */ +export async function requireProviderAdministrator(request) { + if (request && await hasValidCliToken(request)) { + const admin = (await getUsers()).find((user) => user.role === "admin" && user.isActive); + if (!admin) throw new Error("No active administrator available for provider management"); + return admin; + } + + return requireAdminUser(); +} + +export async function getProviderConnectionAccess(request) { + const user = await requireProviderAdministrator(request); return { user, - ownerId: user.role === "admin" ? null : user.id, + ownerId: null, }; } diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 5283cbcb..06d2ab13 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -16,7 +16,7 @@ const COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "trave const navItems = [ { href: "/dashboard/endpoint", label: "Endpoint & Key", icon: "api" }, - { href: "/dashboard/providers", label: "Providers", icon: "dns" }, + { href: "/dashboard/providers", label: "Providers", icon: "dns", adminOnly: true }, { href: "/dashboard/models", label: "Models", icon: "view_list" }, // { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden { href: "/dashboard/combos", label: "Combos", icon: "layers" }, diff --git a/tests/unit/admin-provider-connections.test.js b/tests/unit/admin-provider-connections.test.js new file mode 100644 index 00000000..72ab14cb --- /dev/null +++ b/tests/unit/admin-provider-connections.test.js @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let tempDir; +const originalDataDir = process.env.DATA_DIR; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-admin-providers-")); + process.env.DATA_DIR = tempDir; + delete global._dbAdapter; + vi.resetModules(); +}); + +afterEach(() => { + try { global._dbAdapter?.instance?.close?.(); } catch {} + delete global._dbAdapter; + fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +describe("administrator provider connections", () => { + it("rejects provider credentials owned by a regular user", async () => { + const db = await import("@/lib/db/index.js"); + const user = await db.createUser({ username: "provider-member", password: "password", role: "user" }); + + await expect(db.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "Member key", + apiKey: "secret", + ownerId: user.id, + })).rejects.toMatchObject({ + message: "Provider connections require an administrator owner", + status: 403, + }); + }); + + it("removes user and orphan credentials while retaining legacy credentials for an administrator", async () => { + const db = await import("@/lib/db/index.js"); + const { getAdapter } = await import("@/lib/db/driver.js"); + const migration = (await import("@/lib/db/migrations/007-admin-provider-connections.js")).default; + const admin = await db.createUser({ username: "migration-admin", password: "password", role: "admin" }); + const member = await db.createUser({ username: "migration-member", password: "password", role: "user" }); + const adapter = await getAdapter(); + const now = new Date().toISOString(); + + for (const [id, ownerId] of [["legacy", null], ["member", member.id], ["orphan", "missing-user"], ["admin", admin.id]]) { + adapter.run( + `INSERT INTO providerConnections(id, provider, authType, ownerId, isActive, data, createdAt, updatedAt) + VALUES(?, 'openai', 'apikey', ?, 1, '{}', ?, ?)`, + [id, ownerId, now, now], + ); + } + + adapter.transaction(() => migration.up(adapter)); + + const remaining = await db.getProviderConnections(); + expect(remaining.map((connection) => connection.id).sort()).toEqual(["admin", "legacy"]); + const firstAdmin = (await db.getUsers()).find((user) => user.role === "admin"); + expect(remaining.find((connection) => connection.id === "legacy")?.ownerId).toBe(firstAdmin.id); + }); +}); \ No newline at end of file diff --git a/tests/unit/api-key-credential-access.test.js b/tests/unit/api-key-credential-access.test.js index e48d08f0..683a2ce3 100644 --- a/tests/unit/api-key-credential-access.test.js +++ b/tests/unit/api-key-credential-access.test.js @@ -26,8 +26,6 @@ describe("API-key credential access", () => { const db = await import("@/lib/db/index.js"); const { getProviderCredentials } = await import("@/sse/services/auth.js"); const admin = await db.createUser({ username: "credential-admin", password: "password", role: "admin" }); - const userA = await db.createUser({ username: "credential-user-a", password: "password", role: "user" }); - const userB = await db.createUser({ username: "credential-user-b", password: "password", role: "user" }); const userC = await db.createUser({ username: "credential-user-c", password: "password", role: "user" }); const adminConnection = await db.createProviderConnection({ provider: "antigravity", @@ -36,19 +34,21 @@ describe("API-key credential access", () => { accessToken: "admin-token", ownerId: admin.id, }); + const secondAdmin = await db.createUser({ username: "credential-admin-two", password: "password", role: "admin" }); + const thirdAdmin = await db.createUser({ username: "credential-admin-three", password: "password", role: "admin" }); const userAConnection = await db.createProviderConnection({ provider: "antigravity", authType: "oauth", - name: "user-a-antigravity", - accessToken: "user-a-token", - ownerId: userA.id, + name: "second-admin-antigravity", + accessToken: "second-admin-token", + ownerId: secondAdmin.id, }); const userBConnection = await db.createProviderConnection({ provider: "antigravity", authType: "oauth", - name: "user-b-antigravity", - accessToken: "user-b-token", - ownerId: userB.id, + name: "third-admin-antigravity", + accessToken: "third-admin-token", + ownerId: thirdAdmin.id, }); const firstCredentials = await getProviderCredentials("antigravity", new Set(), "gemini-2.5-pro", { diff --git a/tests/unit/compatible-provider-connections.test.js b/tests/unit/compatible-provider-connections.test.js index 0fe146f0..5809664d 100644 --- a/tests/unit/compatible-provider-connections.test.js +++ b/tests/unit/compatible-provider-connections.test.js @@ -8,6 +8,8 @@ const originalDataDir = process.env.DATA_DIR; async function setupTestContext(nodeData) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-compatible-provider-")); process.env.DATA_DIR = tempDir; + try { global._dbAdapter?.instance?.close?.(); } catch {} + delete global._dbAdapter; vi.resetModules(); vi.doMock("next/server", () => ({ NextResponse: { @@ -19,12 +21,16 @@ async function setupTestContext(nodeData) { }, }, })); - - const { POST } = await import("@/app/api/providers/route.js"); const { createProviderNode, getProviderConnections, } = await import("@/models/index.js"); + const { createUser } = await import("@/lib/db/index.js"); + const admin = await createUser({ username: "provider-admin", password: "password", role: "admin" }); + vi.doMock("@/lib/providers/connectionAccess", () => ({ + getProviderConnectionAccess: vi.fn().mockResolvedValue({ user: admin, ownerId: null }), + })); + const { POST } = await import("@/app/api/providers/route.js"); const node = await createProviderNode(nodeData); @@ -38,13 +44,13 @@ async function setupTestContext(nodeData) { }; } -function makeRequest(provider, name = "Test Connection") { +function makeRequest(provider, name = "Test Connection", apiKey = "test-key") { return new Request("https://9router.local/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, - apiKey: "test-key", + apiKey, name, defaultModel: "test-model", }), @@ -74,6 +80,8 @@ describe("compatible provider connections API", () => { }); afterEach(() => { + try { global._dbAdapter?.instance?.close?.(); } catch {} + delete global._dbAdapter; vi.doUnmock("next/server"); vi.resetModules(); vi.clearAllMocks(); @@ -157,7 +165,7 @@ describe("compatible provider connections API", () => { cleanup = ctx.cleanup; const firstResponse = await ctx.POST(makeRequest(ctx.node.id, "Key A")); - const secondResponse = await ctx.POST(makeRequest(ctx.node.id, "Key B")); + const secondResponse = await ctx.POST(makeRequest(ctx.node.id, "Key B", "test-key-b")); const storedConnections = await ctx.getProviderConnections({ provider: ctx.node.id }); expect(firstResponse.status).toBe(201); diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js index 67e3de6a..847f43b2 100644 --- a/tests/unit/dashboard-guard.test.js +++ b/tests/unit/dashboard-guard.test.js @@ -340,6 +340,52 @@ describe("dashboard guard token saver administration access", () => { }); }); +describe("dashboard guard provider administration access", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSettings.mockResolvedValue({}); + mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "user" }); + mocks.getConsistentMachineId.mockResolvedValue("cli-token"); + mocks.getDashboardAuthSession.mockResolvedValue({ userId: "user-1" }); + mocks.verifyDashboardAuthToken.mockResolvedValue(true); + }); + + it("rejects normal users from provider pages and management APIs", async () => { + for (const pathname of [ + "/api/providers", + "/api/providers/connection-id/test", + "/api/provider-nodes", + "/api/oauth/codex/authorize", + ]) { + const response = await proxy(request(pathname, { host: "localhost:20128" }, "user-token")); + + expect(response.status).toBe(403); + expect(response.body.error).toBe("Administrator access required"); + } + + for (const pathname of ["/dashboard/providers", "/dashboard/providers/openai"]) { + const response = await proxy(request(pathname, { host: "localhost:20128" }, "user-token")); + + expect(response.status).toBe(307); + expect(response.url.href).toBe("http://localhost/dashboard"); + } + }); + + it("allows administrators to access provider pages and APIs", async () => { + mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "admin" }); + + for (const pathname of [ + "/dashboard/providers", + "/dashboard/providers/openai", + "/api/providers", + "/api/provider-nodes", + "/api/oauth/codex/authorize", + ]) { + expect(await proxy(request(pathname, { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse); + } + }); +}); + describe("dashboard guard helpers", () => { it("extracts bearer API keys before x-api-key", () => { const apiRequest = request("/v1/chat/completions", { diff --git a/tests/unit/provider-connection-admin-access.test.js b/tests/unit/provider-connection-admin-access.test.js index 309cb80e..b71ac237 100644 --- a/tests/unit/provider-connection-admin-access.test.js +++ b/tests/unit/provider-connection-admin-access.test.js @@ -30,7 +30,7 @@ const adminAccess = { ownerId: null, }; -describe("provider connection administrator-managed access", () => { +describe("provider connection administrator-only access", () => { beforeEach(() => { getProviderConnectionById.mockReset(); getProxyPoolById.mockReset(); @@ -39,7 +39,7 @@ describe("provider connection administrator-managed access", () => { getProviderConnectionAccess.mockReset(); }); - it("prevents a member from updating their legacy compatible connection", async () => { + it("prevents a member from updating a provider connection", async () => { getProviderConnectionAccess.mockResolvedValue(memberAccess); getProviderConnectionById.mockResolvedValue({ id: "legacy-compatible", @@ -56,7 +56,7 @@ describe("provider connection administrator-managed access", () => { expect(updateProviderConnection).not.toHaveBeenCalled(); }); - it("prevents a member from deleting their legacy compatible connection", async () => { + it("prevents a member from deleting a provider connection", async () => { getProviderConnectionAccess.mockResolvedValue(memberAccess); getProviderConnectionById.mockResolvedValue({ id: "legacy-compatible", @@ -89,7 +89,7 @@ describe("provider connection administrator-managed access", () => { expect(deleteProviderConnection).toHaveBeenCalledWith("compatible"); }); - it("preserves member control over their non-compatible connection", async () => { + it("prevents a member from updating a non-compatible connection", async () => { getProviderConnectionAccess.mockResolvedValue(memberAccess); getProviderConnectionById.mockResolvedValue({ id: "openai-connection", @@ -98,21 +98,12 @@ describe("provider connection administrator-managed access", () => { providerSpecificData: {}, authType: "apikey", }); - updateProviderConnection.mockResolvedValue({ - id: "openai-connection", - provider: "openai", - name: "Changed", - }); - const response = await PUT(new Request("http://localhost/api/providers/openai-connection", { method: "PUT", body: JSON.stringify({ name: "Changed" }), }), { params: Promise.resolve({ id: "openai-connection" }) }); - expect(response.status).toBe(200); - expect(updateProviderConnection).toHaveBeenCalledWith("openai-connection", { - name: "Changed", - providerSpecificData: {}, - }); + expect(response.status).toBe(403); + expect(updateProviderConnection).not.toHaveBeenCalled(); }); });