-
Notifications
You must be signed in to change notification settings - Fork 0
Protect real RAG search and answer APIs with authenticated owner scope #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a7c45eb
870b0ca
8ae342e
5952f1a
a732265
971ebe4
ae29521
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ import { searchChunksWithTelemetry } from "@/lib/rag"; | |
| import { classifyRagQuery, normalizedClinicalSearchTokens } from "@/lib/clinical-search"; | ||
| import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; | ||
| import { createAdminClient } from "@/lib/supabase/admin"; | ||
| import * as serverAuth from "@/lib/supabase/auth"; | ||
| import { consumePublicSearchRateLimit } from "@/lib/public-rate-limit"; | ||
| import type { ChunkImage, ClinicalSourceMetadata, SearchResult } from "@/lib/types"; | ||
|
|
||
|
|
@@ -28,10 +29,11 @@ const searchSchema = z.object({ | |
|
|
||
| type SearchRequestBody = z.infer<typeof searchSchema>; | ||
|
|
||
| const publicSearchInflight = new Map<string, Promise<unknown>>(); | ||
| const scopedSearchInflight = new Map<string, Promise<unknown>>(); | ||
|
|
||
| function publicSearchKey(body: SearchRequestBody) { | ||
| function scopedSearchKey(body: SearchRequestBody, ownerId: string) { | ||
| return JSON.stringify({ | ||
| ownerId, | ||
| query: body.query.toLowerCase().replace(/\s+/g, " ").trim(), | ||
| topK: body.topK ?? null, | ||
| documentId: body.documentId ?? null, | ||
|
|
@@ -42,14 +44,14 @@ function publicSearchKey(body: SearchRequestBody) { | |
| }); | ||
| } | ||
|
|
||
| async function coalescePublicSearch<T extends Record<string, unknown>>(key: string, producer: () => Promise<T>) { | ||
| const existing = publicSearchInflight.get(key) as Promise<T> | undefined; | ||
| async function coalesceScopedSearch<T extends Record<string, unknown>>(key: string, producer: () => Promise<T>) { | ||
| const existing = scopedSearchInflight.get(key) as Promise<T> | undefined; | ||
| if (existing) return { payload: await existing, coalesced: true }; | ||
|
|
||
| const pending = producer().finally(() => { | ||
| publicSearchInflight.delete(key); | ||
| scopedSearchInflight.delete(key); | ||
| }); | ||
| publicSearchInflight.set(key, pending); | ||
| scopedSearchInflight.set(key, pending); | ||
| return { payload: await pending, coalesced: false }; | ||
| } | ||
|
|
||
|
|
@@ -294,6 +296,7 @@ function candidatePromotions(query: string, results: SearchResult[]) { | |
|
|
||
| function logWeakSearch(args: { | ||
| supabase: ReturnType<typeof createAdminClient>; | ||
| ownerId: string; | ||
| query: string; | ||
| queryClass: string; | ||
| route?: string | null; | ||
|
|
@@ -317,7 +320,7 @@ function logWeakSearch(args: { | |
| void args.supabase | ||
| .from("rag_query_misses") | ||
| .insert({ | ||
| owner_id: null, | ||
| owner_id: args.ownerId, | ||
| query: args.query, | ||
| normalized_query: args.query.toLowerCase().replace(/\s+/g, " ").trim(), | ||
| query_class: args.queryClass, | ||
|
|
@@ -356,7 +359,9 @@ function latencyBucket(ms: number) { | |
| return "gte_5000ms"; | ||
| } | ||
|
|
||
| function logPublicSearchObservation(args: { | ||
| function logSearchObservation(args: { | ||
| supabase: ReturnType<typeof createAdminClient>; | ||
| ownerId: string; | ||
| query: string; | ||
| results: SearchResult[]; | ||
| payload: Record<string, unknown>; | ||
|
|
@@ -371,48 +376,49 @@ function logPublicSearchObservation(args: { | |
| const payloadBytes = Buffer.byteLength(JSON.stringify(args.payload), "utf8"); | ||
| const topResult = args.results[0] ?? null; | ||
| const latencyMs = telemetryLatencyMs(telemetry); | ||
| await createAdminClient() | ||
| .from("rag_queries") | ||
| .insert({ | ||
| owner_id: null, | ||
| query: args.query, | ||
| answer: null, | ||
| source_chunk_ids: args.results.map((result) => result.id), | ||
| model: "search", | ||
| metadata: { | ||
| event_type: args.failure ? "public_search_failure" : "public_search", | ||
| failure_code: args.failure?.code ?? null, | ||
| failure_cause_name: args.failure?.causeName ?? null, | ||
| failure_cause_message: args.failure?.causeMessage ?? null, | ||
| failure_sql_state: args.failure?.sqlState ?? null, | ||
| query_class: telemetry.query_class ?? null, | ||
| retrieval_strategy: telemetry.retrieval_strategy ?? null, | ||
| payload_bytes: payloadBytes, | ||
| result_count: args.results.length, | ||
| top_document_id: topResult?.document_id ?? null, | ||
| top_document_title: topResult?.title ?? null, | ||
| top_file_name: topResult?.file_name ?? null, | ||
| top_score: topResult ? (topResult.hybrid_score ?? topResult.similarity ?? null) : null, | ||
| latency_ms: latencyMs, | ||
| latency_bucket: latencyBucket(latencyMs), | ||
| search_cache_hit: telemetry.search_cache_hit ?? null, | ||
| embedding_skipped: telemetry.embedding_skipped ?? null, | ||
| }, | ||
| }); | ||
| await args.supabase.from("rag_queries").insert({ | ||
| owner_id: args.ownerId, | ||
| query: args.query, | ||
| answer: null, | ||
| source_chunk_ids: args.results.map((result) => result.id), | ||
| model: "search", | ||
| metadata: { | ||
| event_type: args.failure ? "private_search_failure" : "private_search", | ||
| failure_code: args.failure?.code ?? null, | ||
| failure_cause_name: args.failure?.causeName ?? null, | ||
| failure_cause_message: args.failure?.causeMessage ?? null, | ||
| failure_sql_state: args.failure?.sqlState ?? null, | ||
| query_class: telemetry.query_class ?? null, | ||
| retrieval_strategy: telemetry.retrieval_strategy ?? null, | ||
| payload_bytes: payloadBytes, | ||
| result_count: args.results.length, | ||
| top_document_id: topResult?.document_id ?? null, | ||
| top_document_title: topResult?.title ?? null, | ||
| top_file_name: topResult?.file_name ?? null, | ||
| top_score: topResult ? (topResult.hybrid_score ?? topResult.similarity ?? null) : null, | ||
| latency_ms: latencyMs, | ||
| latency_bucket: latencyBucket(latencyMs), | ||
| search_cache_hit: telemetry.search_cache_hit ?? null, | ||
| embedding_skipped: telemetry.embedding_skipped ?? null, | ||
| }, | ||
| }); | ||
| } catch { | ||
| // Search telemetry must not affect the user-facing search path. | ||
| } | ||
| })(); | ||
| } | ||
|
|
||
| async function buildPublicSearchPayload(body: SearchRequestBody) { | ||
| const supabase = createAdminClient(); | ||
| async function buildScopedSearchPayload( | ||
| body: SearchRequestBody, | ||
| supabase: ReturnType<typeof createAdminClient>, | ||
| ownerId: string, | ||
| ) { | ||
| const search = await searchChunksWithTelemetry({ | ||
| query: body.query, | ||
| topK: body.mode === "documents" ? Math.max(body.topK ?? 12, Math.min(20, body.documentLimit)) : (body.topK ?? 8), | ||
| documentId: body.documentId, | ||
| documentIds: body.documentIds, | ||
| ownerId: undefined, | ||
| ownerId, | ||
| }); | ||
| const resultLimit = | ||
| body.mode === "documents" ? Math.max(body.topK ?? 12, Math.min(20, body.documentLimit)) : (body.topK ?? 8); | ||
|
|
@@ -421,7 +427,7 @@ async function buildPublicSearchPayload(body: SearchRequestBody) { | |
| const relatedDocuments = body.includeRelatedDocuments | ||
| ? await fetchRelatedDocuments({ | ||
| supabase, | ||
| ownerId: undefined, | ||
| ownerId, | ||
| query: body.query, | ||
| results, | ||
| limit: body.mode === "documents" ? body.documentLimit : undefined, | ||
|
|
@@ -444,6 +450,7 @@ async function buildPublicSearchPayload(body: SearchRequestBody) { | |
| }); | ||
| logWeakSearch({ | ||
| supabase, | ||
| ownerId, | ||
| query: body.query, | ||
| queryClass, | ||
| route: body.mode, | ||
|
|
@@ -497,7 +504,7 @@ async function buildPublicSearchPayload(body: SearchRequestBody) { | |
| rrf_top_score: search.telemetry.rrf_top_score, | ||
| }, | ||
| }; | ||
| logPublicSearchObservation({ query: body.query, results, payload }); | ||
| logSearchObservation({ supabase, ownerId, query: body.query, results, payload }); | ||
| return payload; | ||
| } | ||
|
|
||
|
|
@@ -531,6 +538,9 @@ function extractSqlState(error: unknown) { | |
| } | ||
|
|
||
| export async function POST(request: Request) { | ||
| let supabase: ReturnType<typeof createAdminClient> | null = null; | ||
| let ownerId: string | null = null; | ||
|
|
||
| try { | ||
| const body = searchSchema.parse(await request.json()); | ||
| if (isDemoMode()) { | ||
|
|
@@ -564,6 +574,10 @@ export async function POST(request: Request) { | |
| }); | ||
| } | ||
|
|
||
| supabase = createAdminClient(); | ||
| const user = await serverAuth.requireAuthenticatedUser(request, supabase); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For requests that include any invalid Useful? React with 👍 / 👎. |
||
| ownerId = user.id; | ||
|
Comment on lines
+578
to
+579
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding this auth gate changes the real-mode contract, but the existing Useful? React with 👍 / 👎.
Comment on lines
+577
to
+579
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This new real-mode auth requirement also breaks the existing Useful? React with 👍 / 👎.
Comment on lines
+577
to
+579
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After this auth check, the dashboard still enables the main search button in real mode whenever setup is ready because Useful? React with 👍 / 👎. |
||
|
|
||
| const rateLimit = consumePublicSearchRateLimit(request.headers); | ||
| if (rateLimit.limited) { | ||
| return NextResponse.json( | ||
|
|
@@ -580,8 +594,10 @@ export async function POST(request: Request) { | |
| ); | ||
| } | ||
|
|
||
| const key = publicSearchKey(body); | ||
| const { payload, coalesced } = await coalescePublicSearch(key, () => buildPublicSearchPayload(body)); | ||
| const key = scopedSearchKey(body, ownerId); | ||
| const { payload, coalesced } = await coalesceScopedSearch(key, () => | ||
| buildScopedSearchPayload(body, supabase!, ownerId!), | ||
| ); | ||
| return NextResponse.json({ | ||
| ...payload, | ||
| telemetry: { | ||
|
|
@@ -590,6 +606,9 @@ export async function POST(request: Request) { | |
| }, | ||
| }); | ||
| } catch (error) { | ||
| if (error instanceof serverAuth.AuthenticationError) { | ||
| return serverAuth.unauthorizedResponse(error); | ||
| } | ||
| if (error instanceof z.ZodError) { | ||
| return jsonError(error, 400); | ||
| } | ||
|
|
@@ -603,17 +622,21 @@ export async function POST(request: Request) { | |
| failure_code: code, | ||
| }, | ||
| }; | ||
| logPublicSearchObservation({ | ||
| query: "unknown", | ||
| results: [], | ||
| payload: failurePayload, | ||
| failure: { | ||
| code, | ||
| causeName: error.name, | ||
| causeMessage: error.message, | ||
| sqlState: extractSqlState(error), | ||
| }, | ||
| }); | ||
| if (ownerId && supabase) { | ||
| logSearchObservation({ | ||
| supabase, | ||
| ownerId, | ||
| query: "unknown", | ||
| results: [], | ||
| payload: failurePayload, | ||
| failure: { | ||
| code, | ||
| causeName: error.name, | ||
| causeMessage: error.message, | ||
| sqlState: extractSqlState(error), | ||
| }, | ||
| }); | ||
| } | ||
| return jsonError( | ||
| new PublicApiError("Search failed. Retry with a narrower question.", 500, { | ||
| code, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a developer has the Supabase CLI configured in CI/local,
SUPABASE_ACCESS_TOKENis a personal access token for the Management API, not an Auth user JWT (Supabase docs describe it as a personal access token). This script now silently forwards that long-lived token asAuthorization: Bearerto whichever--base-urlis used and suppresses the no-token diagnostic, so real-mode evals still 401 while potentially exposing a management token to a preview/non-local URL. Please only auto-read the RAG-specific env vars/explicit flag, or otherwise require a real user session JWT.Useful? React with 👍 / 👎.