Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"governance:release": "npm run check:document-label-coverage && npm run check:document-label-governance && npm run audit:source-governance:release",
"check:document-label-coverage": "tsx scripts/check-document-label-coverage.ts",
"check:document-label-governance": "tsx scripts/check-document-label-governance.ts",
"check:retrieval-owner-migration": "tsx scripts/check-retrieval-owner-migration.ts",
"backfill:gold-labels": "tsx scripts/backfill-gold-document-labels.ts",
"backfill:smart-v2-labels": "npm run classify:documents -- --all-owners --limit 5000 --only-missing-smart-v2",
"tags:backfill": "tsx scripts/backfill-document-tags.ts",
Expand Down
41 changes: 41 additions & 0 deletions scripts/check-retrieval-owner-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createAdminClient } from "@/lib/supabase/admin";

const SENTINEL = "00000000-0000-0000-0000-000000000000";

async function main() {
const supabase = createAdminClient();
const { data, error } = await supabase.rpc("search_schema_health");
if (error) {
console.error("[Retrieval Owner Migration] FAIL: search_schema_health unavailable:", error.message);
process.exit(1);
}

const { data: sentinelCheck, error: sentinelError } = await supabase.rpc(
"retrieval_owner_matches" as never,
{
owner_filter: SENTINEL,
row_owner_id: null,
} as never,
);

if (sentinelError) {
console.error(
"[Retrieval Owner Migration] FAIL: retrieval_owner_matches RPC missing or broken:",
sentinelError.message,
);
process.exit(1);
}

if (sentinelCheck !== true) {
console.error("[Retrieval Owner Migration] FAIL: sentinel did not match public owner_id IS NULL.");
process.exit(1);
}

console.log("[Retrieval Owner Migration] PASS: retrieval_owner_matches sentinel is live.");
console.log("[Retrieval Owner Migration] search_schema_health:", JSON.stringify(data));
}

main().catch((error) => {
console.error("[Retrieval Owner Migration] FAIL:", error instanceof Error ? error.message : error);
process.exit(1);
});
12 changes: 6 additions & 6 deletions src/lib/deep-memory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { buildClinicalTextSearchQuery, classifyRagQuery, normalizedClinicalSearchTokens } from "@/lib/clinical-search";
import { logger } from "@/lib/logger";
import { requireOwnerScope } from "@/lib/owner-scope";
import { retrievalOwnerFilter } from "@/lib/owner-scope";
import {
buildDocumentIndexUnitInputs,
countDocumentIndexUnitsByType,
Expand Down Expand Up @@ -823,11 +823,11 @@ export async function fetchMemoryCardsForQuery(args: {
match_count: args.matchCount ?? 32,
min_similarity: 0.1,
document_filters: args.documentIds?.length ? args.documentIds : null,
owner_filter: args.ownerId
? requireOwnerScope(args.ownerId)
: args.documentIds?.length
? null
: (requireOwnerScope(args.ownerId) ?? null),
owner_filter: retrievalOwnerFilter({
ownerId: args.ownerId,
documentIds: args.documentIds,
allowGlobalSearch: !args.ownerId && !args.documentIds?.length,
}),
});

if (error) {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/document-enrichment.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { classifyDocumentOrganization } from "@/lib/document-organization";
import { env } from "@/lib/env";
import { requireOwnerScope } from "@/lib/owner-scope";
import { retrievalOwnerFilter } from "@/lib/owner-scope";
import { isClinicalImageEvidence } from "@/lib/image-filtering";
import {
buildCoveragePromptNote,
Expand Down Expand Up @@ -713,7 +713,7 @@ export async function fetchRelatedDocumentMetadata(args: {
}) {
const { data: rpcData, error: rpcError } = await args.supabase.rpc("get_related_document_metadata", {
document_ids: args.documentIds,
owner_filter: args.ownerId ? requireOwnerScope(args.ownerId) : null,
owner_filter: retrievalOwnerFilter({ ownerId: args.ownerId, documentIds: args.documentIds }),
});

if (!rpcError) {
Expand Down
31 changes: 19 additions & 12 deletions src/lib/owner-scope.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";

/**
* Fail-closed guard for multi-tenant owner scoping.
*
* The hybrid retrieval RPCs treat a null `owner_filter` as "all owners"
* (fail-open), so calling them without an ownerId would silently return another
* tenant's data. Call this at every retrieval RPC boundary that filters by
* owner. In a real (multi-user) deployment a missing ownerId throws instead of
* leaking; in demo / local-no-auth / the test runner there is no multi-tenancy,
* so it stays permissive (returns undefined, preserving the previous behaviour).
*
* See the owner-scoping isolation audit.
*/
export const PUBLIC_OWNER_FILTER_SENTINEL = "00000000-0000-0000-0000-000000000000";

export function requireOwnerScope(ownerId: string | null | undefined): string | undefined {
if (ownerId) return ownerId;
if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") {
Expand All @@ -21,3 +11,20 @@ export function requireOwnerScope(ownerId: string | null | undefined): string |
"Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.",
);
}

export function retrievalOwnerFilter(args: {
ownerId?: string | null;
documentIds?: string[];
allowGlobalSearch?: boolean;
}): string | null | undefined {
if (args.ownerId) return requireOwnerScope(args.ownerId);
if (isDemoMode() || isLocalNoAuthMode() || process.env.NODE_ENV === "test") {
return undefined;
}
if (args.allowGlobalSearch || args.documentIds?.length) {
return PUBLIC_OWNER_FILTER_SENTINEL;
}
throw new Error(
"Owner-scoped retrieval was called without an ownerId; refusing to run to avoid returning another tenant's data.",
);
}
35 changes: 24 additions & 11 deletions src/lib/rag.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createAdminClient } from "@/lib/supabase/admin";
import { requireOwnerScope } from "@/lib/owner-scope";
import { requireOwnerScope, retrievalOwnerFilter } from "@/lib/owner-scope";
import type { Database, Json } from "@/lib/supabase/database.types";
import {
embedTextWithTelemetry,
Expand Down Expand Up @@ -2062,10 +2062,12 @@ function assertGlobalSearchAllowed(args: SearchChunksArgs) {
}
}

function ownerScopeForDocumentFilteredRetrieval(ownerId: string | undefined, documentIds: string[] | undefined) {
if (ownerId) return requireOwnerScope(ownerId);
if (documentIds?.length) return undefined;
return requireOwnerScope(ownerId);
function ownerScopeForDocumentFilteredRetrieval(
ownerId: string | undefined,
documentIds: string[] | undefined,
allowGlobalSearch?: boolean,
) {
return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch });
}

export function buildRetrievalQueryVariants(
Expand Down Expand Up @@ -2193,14 +2195,15 @@ async function searchTextChunkCandidates(args: {
queryVariants: string[];
ownerId?: string;
documentIds?: string[];
allowGlobalSearch?: boolean;
matchCount: number;
}) {
const runChunkText = async (queryText: string, matchCount: number) => {
const { data, error } = await args.supabase.rpc("match_document_chunks_text", {
query_text: queryText,
match_count: matchCount,
document_filters: args.documentIds ?? undefined,
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch),
});
return error || !data?.length ? ([] as SearchResult[]) : (data as SearchResult[]);
};
Expand Down Expand Up @@ -2347,13 +2350,14 @@ async function fetchBestDocumentLookupChunks(args: {
query: string;
limit: number;
ownerId?: string;
allowGlobalSearch?: boolean;
}) {
const terms = documentLookupChunkTerms(args.query);
const { data: rpcChunks, error: rpcError } = await args.supabase.rpc("match_document_lookup_chunks_text", {
query_text: args.query,
document_filters: args.documentIds ?? undefined,
match_count: Math.max(args.limit * 3, 24),
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch),
});
if (!rpcError && rpcChunks?.length) {
const ranked = (rpcChunks as DocumentLookupChunkRow[])
Expand Down Expand Up @@ -2741,6 +2745,7 @@ async function loadChunksForSignalMatches(args: {
.in("id", documentIds)
.eq("status", "indexed");
if (args.ownerId) documentQuery = documentQuery.eq("owner_id", args.ownerId);
else documentQuery = documentQuery.is("owner_id", null);
const { data: documents, error: documentsError } = await documentQuery;
if (documentsError || !documents?.length) return [] as SearchResult[];
const documentById = new Map(documents.map((document) => [document.id, document]));
Expand Down Expand Up @@ -2800,6 +2805,7 @@ async function searchTableFactCandidates(args: {
queryVariants?: string[];
ownerId?: string;
documentIds?: string[];
allowGlobalSearch?: boolean;
matchCount: number;
}) {
const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice(
Expand All @@ -2812,7 +2818,7 @@ async function searchTableFactCandidates(args: {
query_text: variant,
match_count: index === 0 ? args.matchCount : Math.min(args.matchCount, 24),
document_filters: args.documentIds ?? undefined,
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch),
});
if (error || !data?.length) return [] as TableFactRpcRow[];
return data as TableFactRpcRow[];
Expand Down Expand Up @@ -2851,6 +2857,7 @@ async function searchEmbeddingFieldCandidates(args: {
queryEmbedding: number[];
ownerId?: string;
documentIds?: string[];
allowGlobalSearch?: boolean;
matchCount: number;
telemetry?: SearchTelemetry;
}) {
Expand All @@ -2860,7 +2867,7 @@ async function searchEmbeddingFieldCandidates(args: {
match_count: args.matchCount,
min_similarity: 0.12,
document_filters: args.documentIds ?? undefined,
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch),
});
if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error);
if (error || !data?.length) return [] as SearchResult[];
Expand Down Expand Up @@ -2901,6 +2908,7 @@ async function searchIndexUnitCandidates(args: {
queryEmbedding: number[];
ownerId?: string;
documentIds?: string[];
allowGlobalSearch?: boolean;
matchCount: number;
telemetry?: SearchTelemetry;
}) {
Expand All @@ -2910,7 +2918,7 @@ async function searchIndexUnitCandidates(args: {
match_count: args.matchCount,
min_similarity: 0.1,
document_filters: args.documentIds ?? undefined,
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds),
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch),
});
if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error);
if (error || !data?.length) return [] as SearchResult[];
Expand Down Expand Up @@ -5525,6 +5533,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryVariants,
ownerId: args.ownerId,
documentIds: documentFilterList,
allowGlobalSearch: args.allowGlobalSearch,
matchCount: textCandidateCount,
});
telemetry.text_candidate_count = textData.length;
Expand Down Expand Up @@ -5623,6 +5632,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryVariants,
ownerId: args.ownerId,
documentIds: documentFilterList,
allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 48),
});
const tableFactLatencyMs = Date.now() - tableFactStartedAt;
Expand Down Expand Up @@ -5803,6 +5813,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryEmbedding: embedding,
ownerId: args.ownerId,
documentIds: documentFilterList,
allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 48),
telemetry,
});
Expand All @@ -5816,6 +5827,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
queryEmbedding: embedding,
ownerId: args.ownerId,
documentIds: documentFilterList,
allowGlobalSearch: args.allowGlobalSearch,
matchCount: Math.min(candidateCount, 64),
telemetry,
});
Expand All @@ -5829,7 +5841,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
match_count: candidateCount,
min_similarity: minSimilarity,
document_filters: documentFilterList ?? undefined,
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList),
owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, documentFilterList, args.allowGlobalSearch),
});
return { data, error, latencyMs: Date.now() - startedAt };
})(),
Expand Down Expand Up @@ -5928,6 +5940,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
owner_filter: ownerScopeForDocumentFilteredRetrieval(
args.ownerId,
documentFilter ? [documentFilter] : undefined,
documentFilter ? undefined : args.allowGlobalSearch,
),
});

Expand Down
Loading
Loading