Skip to content

Commit b872060

Browse files
committed
fix(search): close retired document writes and refresh Search consent
1 parent 3f3eea4 commit b872060

12 files changed

Lines changed: 617 additions & 200 deletions

File tree

‎apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ export function SearchSourceSetup({
133133
}
134134
const failedQuery = index.isError ? index : null
135135
const initialMode = (type: string) =>
136-
type === 'github' || type === 'slack' ? ('members' as const) : ('admin' as const)
136+
type === 'github' || type === 'slack' || setup['source-access'] === 'members'
137+
? ('members' as const)
138+
: ('admin' as const)
137139

138140
const selectedAccessMode = selectedType ? initialMode(selectedType) : undefined
139141
const selectedAvailability = selectedMeta

‎apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { sha256Hex } from '@sim/security/hash'
33
import {
44
dbChainMockFns,
55
flattenMockConditions,
6+
queueTableRows,
67
resetDbChainMock,
78
schemaMock,
89
storageServiceMock,
@@ -105,6 +106,12 @@ function mappedDocumentPlan(): ForkContentPlan {
105106
describe('copyForkResourceContent', () => {
106107
beforeEach(() => {
107108
resetDbChainMock()
109+
for (let attempt = 0; attempt < 4; attempt++)
110+
queueTableRows(schemaMock.knowledgeBase, [
111+
{ id: 'src-kb', isSearchIndex: false },
112+
{ id: 'child-kb', isSearchIndex: false },
113+
{ id: 'existing-target-kb', isSearchIndex: false },
114+
])
108115
dbChainMockFns.returning.mockResolvedValue([{ id: 'activated-document' }])
109116
dbChainMockFns.for.mockResolvedValue([{ workspaceId: 'child-ws' }])
110117
storageServiceMockFns.mockHeadObject.mockResolvedValue(null)
@@ -1138,11 +1145,15 @@ describe('planForkMappedKbDocumentCopies', () => {
11381145
let selectCalls = 0
11391146
const tx = {
11401147
select: () => {
1141-
const rows = selectCalls++ === 0 ? docs : existingTargets
11421148
return {
1143-
from: () => ({
1149+
from: (table: unknown) => ({
11441150
where: (condition: unknown) => {
1151+
if (table === schemaMock.knowledgeBase) {
1152+
const rows = Promise.resolve([{ id: 'target-kb' }])
1153+
return Object.assign(rows, { for: () => rows })
1154+
}
11451155
wheres.push(condition)
1156+
const rows = selectCalls++ === 0 ? docs : existingTargets
11461157
return Promise.resolve(rows)
11471158
},
11481159
}),

‎apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ import {
4848
hashDurableSecretProvenanceValue,
4949
} from '@/lib/execution/durable-secret-provenance'
5050
import { WORKSPACE_ACCESS_TOKEN } from '@/lib/knowledge/access/types'
51+
import {
52+
connectorIndexingCondition,
53+
requiresConnectorIndexing,
54+
} from '@/lib/knowledge/connectors/indexing-policy'
5155
import {
5256
createKnowledgeDocumentSourceValue,
5357
type KnowledgeDocumentSourceValue,
@@ -758,6 +762,7 @@ export async function copyForkResourceContainers(
758762
and(
759763
inArray(knowledgeBase.id, selection.knowledgeBases),
760764
eq(knowledgeBase.workspaceId, sourceWorkspaceId),
765+
connectorIndexingCondition(),
761766
isNull(knowledgeBase.deletedAt)
762767
)
763768
)
@@ -951,17 +956,43 @@ export async function planForkMappedKbDocumentCopies(params: {
951956
.where(
952957
and(
953958
inArray(document.id, candidateIds),
959+
exists(
960+
tx
961+
.select({ id: knowledgeBase.id })
962+
.from(knowledgeBase)
963+
.where(
964+
and(
965+
eq(knowledgeBase.id, document.knowledgeBaseId),
966+
connectorIndexingCondition(),
967+
isNull(knowledgeBase.deletedAt)
968+
)
969+
)
970+
),
954971
isNull(document.connectorId),
955972
isNull(document.deletedAt),
956973
isNull(document.archivedAt)
957974
)
958975
)
959976

960-
const planned = docs.flatMap((doc) => {
977+
const candidates = docs.flatMap((doc) => {
961978
const targetKbId = resolver('knowledge-base', doc.knowledgeBaseId)
962979
if (targetKbId == null) return []
963980
return [{ doc, targetKbId, childDocId: deriveCopyIdentity('document', targetKbId, doc.id) }]
964981
})
982+
if (candidates.length === 0) return { documents, docIdMap, mappingEntries }
983+
const targets = await tx
984+
.select({ id: knowledgeBase.id })
985+
.from(knowledgeBase)
986+
.where(
987+
and(
988+
inArray(knowledgeBase.id, [...new Set(candidates.map(({ targetKbId }) => targetKbId))]),
989+
connectorIndexingCondition(),
990+
isNull(knowledgeBase.deletedAt)
991+
)
992+
)
993+
.for('share')
994+
const targetIds = new Set(targets.map(({ id }) => id))
995+
const planned = candidates.filter(({ targetKbId }) => targetIds.has(targetKbId))
965996
const existingTargets =
966997
planned.length === 0
967998
? []
@@ -1690,14 +1721,20 @@ async function finalizeKbDocument(params: {
16901721
} = params
16911722
return db.transaction(async (tx) => {
16921723
const [lockedKnowledgeBase] = await tx
1693-
.select({ workspaceId: knowledgeBase.workspaceId })
1724+
.select({
1725+
workspaceId: knowledgeBase.workspaceId,
1726+
isSearchIndex: knowledgeBase.isSearchIndex,
1727+
})
16941728
.from(knowledgeBase)
16951729
.where(eq(knowledgeBase.id, childKnowledgeBaseId))
16961730
.for('update')
16971731
assertForkCopyActive(params.control)
16981732
if (!lockedKnowledgeBase) {
16991733
throw new Error(`Copied document knowledge base ${childKnowledgeBaseId} is missing`)
17001734
}
1735+
if (!requiresConnectorIndexing(lockedKnowledgeBase.isSearchIndex)) {
1736+
throw new Error('Retired Search documents cannot be activated by a workspace copy')
1737+
}
17011738
if (lockedKnowledgeBase.workspaceId !== billingContext.workspaceId) {
17021739
throw new Error(
17031740
`Copied document knowledge base ${childKnowledgeBaseId} moved from workspace ${billingContext.workspaceId}; refusing stale storage charge`
@@ -1809,6 +1846,24 @@ async function copyKbDocument(params: {
18091846
billingContext,
18101847
} = params
18111848
assertForkCopyActive(params.control)
1849+
const bases = await db
1850+
.select({ id: knowledgeBase.id, isSearchIndex: knowledgeBase.isSearchIndex })
1851+
.from(knowledgeBase)
1852+
.where(
1853+
and(
1854+
inArray(knowledgeBase.id, [source.knowledgeBaseId, childKnowledgeBaseId]),
1855+
isNull(knowledgeBase.deletedAt)
1856+
)
1857+
)
1858+
const basesById = new Map(bases.map((base) => [base.id, base]))
1859+
if (
1860+
[source.knowledgeBaseId, childKnowledgeBaseId].some((id) => {
1861+
const base = basesById.get(id)
1862+
return !base || !requiresConnectorIndexing(base.isSearchIndex)
1863+
})
1864+
) {
1865+
throw new Error('Workspace copies require active ordinary knowledge bases')
1866+
}
18121867
const sourceSecretContext = await loadKnowledgeDocumentDurableSecretProvenance(source.id)
18131868
const sourceSnapshotHash = hashDurableSecretProvenanceValue(
18141869
createKnowledgeDocumentSourceValue(source)

‎apps/sim/lib/credential-groups/service.ts‎

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provi
2525
import { decryptCredentialGroupProviderConfiguration } from '@/lib/credential-groups/provider-configuration'
2626
import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry'
2727
import {
28-
type CredentialGroupStandardOAuthProvider,
28+
type CredentialGroupProvider,
2929
isCredentialGroupProvider,
3030
} from '@/lib/credential-groups/providers'
3131
import { credentialGroupScope } from '@/lib/credential-groups/scope'
@@ -87,7 +87,13 @@ function scopesEqual(left: string[], right: string[]): boolean {
8787

8888
async function buildOption(
8989
scope: ResourceScope,
90-
option: CredentialGroupOptionInput,
90+
option: {
91+
provider: CredentialGroupProvider
92+
label: string
93+
required: boolean
94+
slackBotCredentialId?: string
95+
requiredScopes?: string[]
96+
},
9197
credentialGroupId?: string,
9298
executor: DbOrTx = db
9399
): Promise<CredentialGroupOptionConfig> {
@@ -395,11 +401,11 @@ export async function ensureWorkspaceAccountsGroup(
395401
}
396402
}
397403

398-
/** Adds a provider explicitly selected by an organization administrator, preserving all grants. */
404+
/** Adds a provider or extends its required consent during an explicit administrator action. */
399405
export async function addOrganizationAccountProvider(
400406
organizationId: string,
401407
userId: string,
402-
option: { provider: CredentialGroupStandardOAuthProvider; label: string },
408+
option: { provider: CredentialGroupProvider; label: string; requiredScopes?: string[] },
403409
executor: DbTransaction
404410
): Promise<{ groupId: string; changed: boolean }> {
405411
const scope = { kind: 'organization', organizationId } as const
@@ -418,11 +424,37 @@ export async function addOrganizationAccountProvider(
418424
`Connected accounts contains duplicate ${option.label} settings`
419425
)
420426
if (matching[0]) {
421-
if (matching[0].status !== 'active')
427+
const current = matching[0]
428+
if (current.status !== 'active')
422429
throw new OrchestrationError(
423430
'validation',
424431
`Enable ${option.label} in Connected accounts first`
425432
)
433+
if (option.requiredScopes) {
434+
const previousScopes =
435+
current.provider === 'slack'
436+
? resolveSlackManagedUserScopes(current.requiredScopes)
437+
: current.requiredScopes
438+
const requiredScopes = [...new Set([...previousScopes, ...option.requiredScopes])]
439+
const scopeVersion = credentialGroupScopePolicyVersion(requiredScopes)
440+
if (!scopesEqual(requiredScopes, previousScopes) || scopeVersion !== current.scopeVersion) {
441+
const [updated] = await executor
442+
.update(credentialGroup)
443+
.set({
444+
options: existing.options.map((entry) =>
445+
entry.id === current.id ? { ...entry, requiredScopes, scopeVersion } : entry
446+
),
447+
updatedAt: new Date(),
448+
})
449+
.where(
450+
and(eq(credentialGroup.id, group.id), resourceScopeCondition(credentialGroup, scope))
451+
)
452+
.returning({ id: credentialGroup.id })
453+
if (!updated) throw new Error('Connected accounts policy update returned no row')
454+
await invalidateOptionGrants(executor, group.id, [current.id])
455+
return { groupId: group.id, changed: true }
456+
}
457+
}
426458
return { groupId: group.id, changed: group.created }
427459
}
428460
if (
@@ -449,6 +481,27 @@ export async function addOrganizationAccountProvider(
449481
return { groupId: group.id, changed: true }
450482
}
451483

484+
async function invalidateOptionGrants(
485+
executor: DbTransaction,
486+
groupId: string,
487+
optionIds: string[]
488+
) {
489+
const enrollmentIds = executor
490+
.select({ id: credentialGroupEnrollment.id })
491+
.from(credentialGroupEnrollment)
492+
.where(eq(credentialGroupEnrollment.credentialGroupId, groupId))
493+
await executor
494+
.update(credential)
495+
.set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() })
496+
.where(
497+
and(
498+
eq(credential.type, 'managed_oauth'),
499+
inArray(credential.credentialGroupEnrollmentId, enrollmentIds),
500+
inArray(credential.credentialGroupOptionId, optionIds)
501+
)
502+
)
503+
}
504+
452505
/**
453506
* Refuses to remove account options while a knowledge
454507
* connector syncs per member through one of them: the connector would be left
@@ -570,20 +623,7 @@ export async function updateCredentialGroup(
570623

571624
if (!updated) throw new Error('Credential group update returned no row')
572625
if (invalidatedOptionIds.length > 0) {
573-
const enrollmentIds = tx
574-
.select({ id: credentialGroupEnrollment.id })
575-
.from(credentialGroupEnrollment)
576-
.where(eq(credentialGroupEnrollment.credentialGroupId, groupId))
577-
await tx
578-
.update(credential)
579-
.set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() })
580-
.where(
581-
and(
582-
eq(credential.type, 'managed_oauth'),
583-
inArray(credential.credentialGroupEnrollmentId, enrollmentIds),
584-
inArray(credential.credentialGroupOptionId, invalidatedOptionIds)
585-
)
586-
)
626+
await invalidateOptionGrants(tx, groupId, invalidatedOptionIds)
587627
}
588628
return toCredentialGroup(updated, await listLinkedMcpServers(updated.id, tx))
589629
})

‎apps/sim/lib/credentials/managed-oauth.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,10 @@ describe('managed OAuth token resolution', () => {
410410
})
411411

412412
it('allows Search reads when Slack retains a broader grant', async () => {
413-
seedSlackSearchCredential('option-1', 'active', SLACK_MANAGED_USER_SCOPES)
413+
seedSlackSearchCredential('option-1', 'active', [
414+
...SLACK_MANAGED_USER_SCOPES,
415+
...SLACK_SEARCH_USER_SCOPES,
416+
])
414417
await expect(
415418
resolveManagedOAuthToken({
416419
credentialId: 'credential-1',

0 commit comments

Comments
 (0)