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
13 changes: 8 additions & 5 deletions apps/sim/lib/knowledge/access/connector-eligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ import { searchIntegrationAccessCondition } from '@/lib/knowledge/search/integra
*/
async function resolveConnectorEligibility(
knowledgeBaseIds: readonly string[]
): Promise<KnowledgeConnectorEligibility> {
): Promise<{ eligibility: KnowledgeConnectorEligibility; types: Map<string, string> }> {
const eligibility: {
workspace: string[]
admin: string[]
members: string[]
liveProofRequired: string[]
} = { workspace: [], admin: [], members: [], liveProofRequired: [] }
if (knowledgeBaseIds.length === 0) return eligibility
const types = new Map<string, string>()
if (knowledgeBaseIds.length === 0) return { eligibility, types }
const rows = await db
.select({
id: knowledgeConnector.id,
Expand All @@ -53,12 +54,13 @@ async function resolveConnectorEligibility(
else if (row.accessMode === 'admin') eligibility.admin.push(row.id)
else if (row.accessMode === 'members') eligibility.members.push(row.id)
else continue
types.set(row.id, row.connectorType)
const live =
(row.connectorType === 'github' && row.githubRepository) ||
(row.connectorType === 'confluence' && row.accessMode === 'admin')
if (live) eligibility.liveProofRequired.push(row.id)
}
return eligibility
return { eligibility, types }
}

/**
Expand Down Expand Up @@ -116,7 +118,8 @@ export async function resolveSearchAccessPlan(
knowledgeBaseIds: readonly string[],
access: KnowledgeAccessScope
): Promise<SearchAccessPlan> {
const connectors = await resolveConnectorEligibility(knowledgeBaseIds)
const { eligibility: connectors, types: connectorTypes } =
await resolveConnectorEligibility(knowledgeBaseIds)
const { observers, memberSources } = await resolveMemberObservers(access, connectors.members)
return { connectors, observers, memberSources }
return { connectors, observers, memberSources, connectorTypes, uploads: true }
}
51 changes: 50 additions & 1 deletion apps/sim/lib/knowledge/access/predicate.postgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const {
knowledgeAccessCondition,
knowledgeCandidateAccessConditionForConnectors,
projectionCandidateAccessCondition,
restrictSearchAccessPlan,
knowledgeMetadataCandidateAccessCondition,
} = await import('@/lib/knowledge/access/predicate')
const { confluencePageAcl } = await import('@/lib/knowledge/access/confluence-permissions')
Expand Down Expand Up @@ -557,7 +558,17 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => {
/** What `resolveSearchAccessPlan` resolves for this caller: their member identity, confirmed. */
const observers = { confirmed: [{ id: 'm-alice', connectorId: 'members' }], observed: [] }
const perRow = knowledgeMetadataCandidateAccessCondition(scope)
const plan = { connectors: eligibility, observers, memberSources: ['members'] }
const plan = {
connectors: eligibility,
observers,
memberSources: ['members'],
connectorTypes: new Map([
['ws-mode', 'slack'],
['admin', 'google_drive'],
['members', 'slack'],
]),
uploads: true,
}
const perQuery = knowledgeCandidateAccessConditionForConnectors(scope, plan)
for (const id of [...cases.map(([documentId]) => documentId), 'upload-doc']) {
expect([id, await admits(perQuery, id)]).toEqual([id, await admits(perRow, id)])
Expand Down Expand Up @@ -655,6 +666,44 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => {
'admin-current'
)
).toBe(false)
/**
* A plan confined to one kind of source admits that kind alone, on the document and on the
* row, and a plan confined to uploads admits only documents without a source.
*/
const drive = restrictSearchAccessPlan(plan, 'google_drive')
const driveOnRow = new PgDialect().sqlToQuery(
projectionCandidateAccessCondition(schema.embeddingSearch, scope, drive)
)
const driveOnRowAdmits = async (id: string) => {
const rows = await connection.unsafe(
`SELECT 1 FROM embedding_search WHERE ${driveOnRow.sql} AND document_id = $${driveOnRow.params.length + 1}`,
[...(driveOnRow.params as string[]), id]
)
return rows.length > 0
}
const drivePerQuery = knowledgeCandidateAccessConditionForConnectors(scope, drive)
for (const [id, expected] of [
['admin-current', true],
['workspace-doc', false],
['members-current', false],
['upload-doc', false],
] as const) {
expect([id, await admits(drivePerQuery, id)]).toEqual([id, expected])
expect([id, await driveOnRowAdmits(id)]).toEqual([id, expected])
}
/** Uploads carry the workspace ACL, so a caller with that token reads them and nothing sourced. */
await connection.unsafe("INSERT INTO document(id, acl) VALUES ('upload-mine', ARRAY['ws'])")
const wsScope = { ...scope, tokens: [...scope.tokens, 'ws'] }
const uploadsPerQuery = knowledgeCandidateAccessConditionForConnectors(
wsScope,
restrictSearchAccessPlan(plan, 'upload')
)
expect(await admits(knowledgeMetadataCandidateAccessCondition(wsScope), 'upload-mine')).toBe(
true
)
expect(await admits(uploadsPerQuery, 'upload-mine')).toBe(true)
expect(await admits(uploadsPerQuery, 'workspace-doc')).toBe(false)
expect(await admits(uploadsPerQuery, 'admin-current')).toBe(false)
/** A connector left out of the resolution is refused, however current its documents are. */
expect(
await admits(
Expand Down
93 changes: 88 additions & 5 deletions apps/sim/lib/knowledge/access/predicate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ process.env.DATABASE_URL ??= 'postgresql://user:pass@localhost:5432/test'

const { PgDialect } = await import('drizzle-orm/pg-core')
const { embeddingSearch } = await import('@sim/db/schema')
const { knowledgeAccessCondition, projectionCandidateAccessCondition } = await import(
'@/lib/knowledge/access/predicate'
)
const { sql: rawSql } = await import('drizzle-orm')
const sqlColumn = (name: string) => rawSql.raw(`"row"."${name}"`)
const {
knowledgeAccessCondition,
knowledgeCandidateAccessConditionForConnectors,
projectionCandidateAccessCondition,
restrictSearchAccessPlan,
} = await import('@/lib/knowledge/access/predicate')
const { SYSTEM_ACCESS_SCOPE } = await import('@/lib/knowledge/access/types')

function render(condition: ReturnType<typeof knowledgeAccessCondition>) {
Expand All @@ -32,6 +37,8 @@ describe('projectionCandidateAccessCondition', () => {
connectors: { workspace: ['ws-src'], admin: [], members: [], liveProofRequired: [] },
observers: { confirmed: [], observed: [] },
memberSources: [],
connectorTypes: new Map(),
uploads: true,
}

it('decides a filled row on its mirrored columns and an unfilled row on its document', () => {
Expand All @@ -46,8 +53,9 @@ describe('projectionCandidateAccessCondition', () => {
'("embedding_search"."acl" IS NULL AND EXISTS (\n SELECT 1 FROM "document"\n WHERE "document"."id" = "embedding_search"."document_id"\n AND ('
)
expect(sql).toContain('"document"."acl" && ARRAY[$1, $2]::text[]')
expect(sql).toMatch(
/OR \("embedding_search"\."acl" && ARRAY\[\$\d+, \$\d+\]::text\[\]\n {4}AND \("embedding_search"\."connector_id" IS NULL OR "embedding_search"\."connector_id" = ANY\(ARRAY\[\$\d+\]::text\[\]\)\)\)\)$/
expect(sql).toContain('OR ("embedding_search"."acl" && ARRAY[')
expect(sql).toContain(
'AND ("embedding_search"."connector_id" IS NULL OR "embedding_search"."connector_id" = ANY(ARRAY['
)
expect(params.slice(0, 2)).toEqual(['ws', 'u:alice'])
expect(params.slice(-3)).toEqual(['ws', 'u:alice', 'ws-src'])
Expand Down Expand Up @@ -128,3 +136,78 @@ describe('knowledgeAccessCondition', () => {
expect(sql).not.toContain('"document"."acl"')
})
})

describe('restrictSearchAccessPlan', () => {
const plan = {
connectors: {
workspace: ['slack-ws'],
admin: ['drive-admin', 'confluence-admin'],
members: ['slack-members'],
liveProofRequired: ['confluence-admin'],
},
observers: {
confirmed: [{ id: 'm-1', connectorId: 'slack-members' }],
observed: [{ id: 'm-2', connectorId: 'drive-admin' }],
},
memberSources: ['slack-members'],
connectorTypes: new Map([
['slack-ws', 'slack'],
['slack-members', 'slack'],
['drive-admin', 'google_drive'],
['confluence-admin', 'confluence'],
]),
uploads: true,
}
const reader = { kind: 'user' as const, userId: 'u', tokens: ['u:reader@example.com'] }

it('keeps only the connectors of that kind, in every list, and leaves uploads out', () => {
const slack = restrictSearchAccessPlan(plan, 'slack')
expect(slack.connectors).toEqual({
workspace: ['slack-ws'],
admin: [],
members: ['slack-members'],
liveProofRequired: [],
})
expect(slack.observers.confirmed).toEqual([{ id: 'm-1', connectorId: 'slack-members' }])
expect(slack.observers.observed).toEqual([])
expect(slack.memberSources).toEqual(['slack-members'])
expect(slack.uploads).toBe(false)
})

it('keeps no connector for uploads, which have none', () => {
const uploads = restrictSearchAccessPlan(plan, 'upload')
expect(uploads.connectors).toEqual({
workspace: [],
admin: [],
members: [],
liveProofRequired: [],
})
expect(uploads.memberSources).toEqual([])
expect(uploads.uploads).toBe(true)
})

it('drops source-less rows from both predicates once uploads are out of scope', () => {
const rowSql = (restricted: typeof plan) =>
render(
projectionCandidateAccessCondition(
{
connectorId: sqlColumn('connector_id'),
acl: sqlColumn('acl'),
documentId: sqlColumn('document_id'),
},
reader,
restricted
)
).sql
expect(rowSql(plan)).toContain('IS NULL OR')
expect(rowSql(restrictSearchAccessPlan(plan, 'slack'))).not.toContain(
'"row"."connector_id" IS NULL'
)
const documentSql = (restricted: typeof plan) =>
render(knowledgeCandidateAccessConditionForConnectors(reader, restricted)).sql
expect(documentSql(plan)).toContain('"document"."connector_id" IS NULL OR')
expect(documentSql(restrictSearchAccessPlan(plan, 'slack'))).not.toContain(
'"document"."connector_id" IS NULL'
)
})
})
41 changes: 37 additions & 4 deletions apps/sim/lib/knowledge/access/predicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,35 @@ export interface SearchAccessPlan {
observers: KnowledgeMemberObservers
/** Connectors the caller is an active member of, whose documents they read broadly. */
memberSources: readonly string[]
/** Each eligible connector's type, so a search may be confined to one kind of source. */
connectorTypes: ReadonlyMap<string, string>
/** Whether documents without a source — uploads — are in scope. */
uploads: boolean
}

/**
* The plan confined to one kind of source: the connectors of that type keep their eligibility and
* the rest lose it, so every predicate built from the plan — on the row and on the document — and
* every source the legs walk or rank are that kind alone. `upload` keeps only source-less documents.
*/
export function restrictSearchAccessPlan(plan: SearchAccessPlan, source: string): SearchAccessPlan {
const keep = (id: string) => source !== 'upload' && plan.connectorTypes.get(id) === source
const kept = (ids: readonly string[]) => ids.filter(keep)
return {
connectors: {
workspace: kept(plan.connectors.workspace),
admin: kept(plan.connectors.admin),
members: kept(plan.connectors.members),
liveProofRequired: kept(plan.connectors.liveProofRequired),
},
observers: {
confirmed: plan.observers.confirmed.filter((observer) => keep(observer.connectorId)),
observed: plan.observers.observed.filter((observer) => keep(observer.connectorId)),
},
memberSources: kept(plan.memberSources),
connectorTypes: plan.connectorTypes,
uploads: source === 'upload',
}
}

export interface KnowledgeConnectorEligibility {
Expand Down Expand Up @@ -290,12 +319,14 @@ export function knowledgeCandidateAccessConditionForConnectors(
))
)`
}
const workspaceOwned = plan.uploads
? sql`(${document.connectorId} IS NULL OR ${inConnectors(eligibility.workspace)})`
: inConnectors(eligibility.workspace)
return sql`(
${aclOverlap(tokens)}
AND ${aclRequirementsSatisfied(tokens)}
AND (
((${document.connectorId} IS NULL OR ${inConnectors(eligibility.workspace)})
AND ${document.acl} = ARRAY['ws']::text[])
(${workspaceOwned} AND ${document.acl} = ARRAY['ws']::text[])
OR ${mirrored(eligibility.admin, sql`${document.aclVerifiedAt} > ${cutoff}`)}
OR ${mirrored(eligibility.members, resolvedObservationCondition(plan.observers, cutoff))}
)
Expand Down Expand Up @@ -340,13 +371,15 @@ export function projectionCandidateAccessCondition(
...plan.connectors.admin,
...plan.connectors.members,
]
const owned = plan.uploads
? sql`(${projection.connectorId} IS NULL OR ${inSources(mirrored)})`
: inSources(mirrored)
const unfilled = sql`(${projection.acl} IS NULL AND EXISTS (
SELECT 1 FROM ${document}
WHERE ${document.id} = ${projection.documentId}
AND ${knowledgeCandidateAccessConditionForConnectors(scope, plan)}
))`
return sql`(${unfilled} OR (${projection.acl} && ${tokens}
AND (${projection.connectorId} IS NULL OR ${inSources(mirrored)})))`
return sql`(${unfilled} OR (${projection.acl} && ${tokens} AND ${owned}))`
}

/**
Expand Down
Loading
Loading