feat(search): add organization search and private assistant by waleedlatif1 · Pull Request #7477 · simstudioai/sim · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
77abaf3
feat(knowledge): resolve connector tokens through one service-account…
waleedlatif1 Sep 4, 2026
d21f661
feat(knowledge): map Google Drive permissions to document access tokens
waleedlatif1 Sep 4, 2026
a73add1
feat(knowledge): write mirrored document ACLs without re-embedding
waleedlatif1 Sep 4, 2026
4c0e817
feat(knowledge): crawl Google Drive as an administrator and mirror it…
waleedlatif1 Sep 4, 2026
0fd6df2
fix(knowledge): make one email address mean one account
waleedlatif1 Sep 4, 2026
140c772
feat(knowledge): resolve directory groups so mirrored grants reach th…
waleedlatif1 Sep 4, 2026
64053e8
feat(knowledge): let a connector be put into administrator mode
waleedlatif1 Sep 4, 2026
3053ec4
fix(knowledge): gate mirrored access on its own feature, not on Crede…
waleedlatif1 Sep 4, 2026
faab96f
refactor(knowledge): drop directory-group columns nothing reads
waleedlatif1 Sep 4, 2026
972422f
feat(knowledge): mirror Confluence space permissions and page restric…
waleedlatif1 Sep 4, 2026
de993af
feat(knowledge): refresh mirrored directories on their own clock
waleedlatif1 Sep 4, 2026
9e68ad3
fix(knowledge): close two admin-mode gaps found in an architecture audit
waleedlatif1 Sep 4, 2026
8932ca2
fix(knowledge): read shared-drive permissions, and finish a pending s…
waleedlatif1 Sep 4, 2026
7ecdc94
docs(knowledge): state why the ACL ceiling exists rather than where t…
waleedlatif1 Sep 4, 2026
822625b
chore(knowledge): register the directory-sync cron, and collapse the …
waleedlatif1 Sep 4, 2026
8ca4ff1
refactor(auth): one email fold, in SQL and TypeScript, and no reads o…
waleedlatif1 Sep 4, 2026
cc92806
fix(knowledge): Drive field mask named a permission field that does n…
waleedlatif1 Sep 4, 2026
2c86087
fix(knowledge): close the audit findings on administrator access
waleedlatif1 Sep 4, 2026
6f204a0
refactor(knowledge): tighten administrator access after the mechanics…
waleedlatif1 Sep 4, 2026
399149c
refactor(knowledge): collapse the access-mode vocabulary and the dupl…
waleedlatif1 Sep 4, 2026
6e07a0c
Merge remote-tracking branch 'origin/staging' into feat/connector-ser…
Sep 5, 2026
6b9ecfb
Merge remote-tracking branch 'origin/staging' into feat/connector-ser…
Sep 5, 2026
caf82ac
feat(search): unify source access and scale indexing
Sep 5, 2026
30ce06e
fix(ci): align search cleanup checks and chart version
Sep 5, 2026
0689c67
fix(search): clean up source access and directory refresh
waleedlatif1 Sep 5, 2026
cbfe70d
improvement(search): remove Answer with Sim action
waleedlatif1 Sep 5, 2026
9507aa0
Merge remote-tracking branch 'origin/staging' into feat/connector-ser…
Sep 5, 2026
27d7796
improvement(search): simplify source setup and preserve account handoffs
waleedlatif1 Sep 5, 2026
ca68b75
feat(assistant): use canonical search and personal integration accounts
Sep 5, 2026
ee7ab5a
Merge remote-tracking branch 'origin/feat/connector-service-account-a…
Sep 5, 2026
3a3d38a
improvement(search): simplify connection setup and merge staging
waleedlatif1 Sep 5, 2026
93fc4d7
merge: preserve latest assistant and personal account changes
waleedlatif1 Sep 5, 2026
9a1821a
merge: bring in latest staging parsing fixes
waleedlatif1 Sep 5, 2026
a6616d5
Merge remote-tracking branch 'origin/staging' into feat/connector-ser…
Sep 5, 2026
8f687a3
feat(assistant): connect personal accounts in chat and reuse desktop …
Sep 5, 2026
f15777d
Merge remote-tracking branch 'origin/feat/connector-service-account-a…
Sep 5, 2026
b0857a3
fix(slack): keep channel listing independent of credential storage
Sep 5, 2026
c65cc33
improvement(assistant): streamline account checks and bound catalog c…
Sep 5, 2026
febebf4
Merge remote-tracking branch 'origin/staging' into feat/connector-ser…
Sep 5, 2026
066ecb5
improvement(search): unify source setup and member connection actions
waleedlatif1 Sep 5, 2026
4356872
Merge remote-tracking branch 'origin/feat/connector-service-account-a…
waleedlatif1 Sep 5, 2026
fd6fc44
improvement(search): clarify setup requirements and focus account con…
waleedlatif1 Sep 5, 2026
015948a
feat(search): add Gmail, Jira, GitHub and Calendar sources
waleedlatif1 Sep 5, 2026
b939801
Merge remote-tracking branch 'origin/staging' into codex/pr-7477
waleedlatif1 Sep 5, 2026
4770c98
feat(search): document connector setup and harden source access
waleedlatif1 Sep 6, 2026
6e12f9e
Merge remote-tracking branch 'origin/staging' into codex/pr-7477
waleedlatif1 Sep 6, 2026
9810f1f
feat(org): organization surface, app entry, and shared rail chrome
emir-karabeg Sep 6, 2026
987eb2b
fix(search): support GitHub App grants and verify connector ACLs
waleedlatif1 Sep 6, 2026
1bc27a7
fix(search): deny revoked Atlassian grants and bound provider requests
waleedlatif1 Sep 6, 2026
149eb03
Merge remote-tracking branch 'origin/staging' into codex/pr-7477
waleedlatif1 Sep 6, 2026
7e5a792
test(search): verify unchanged document indexing recovery
waleedlatif1 Sep 6, 2026
179d80c
fix(search): harden connector recovery and file lifecycle
waleedlatif1 Sep 6, 2026
90b5bd8
Merge remote-tracking branch 'origin/staging' into codex/pr-7477
waleedlatif1 Sep 6, 2026
4f1e635
feat(org): home composer, page shell, and completion blue
emir-karabeg Sep 7, 2026
c996233
Merge remote-tracking branch 'origin/feat/org' into codex/pr-7477
waleedlatif1 Sep 7, 2026
cc128f1
Merge remote-tracking branch 'origin/staging' into codex/pr-7477
waleedlatif1 Sep 7, 2026
3b63c42
feat(search): integrate organization sources and private assistant
waleedlatif1 Sep 7, 2026
72d067c
Merge remote-tracking branch 'origin/staging' into codex/pr-7477
waleedlatif1 Sep 7, 2026
5f69ee0
fix(org): clarify membership navigation and role-aware settings
waleedlatif1 Sep 7, 2026
b3524ec
Merge remote-tracking branch 'origin/staging' into codex/pr-7477
waleedlatif1 Sep 7, 2026
46fd0cb
fix(ui): align organization and workspace sidebar interactions
waleedlatif1 Sep 7, 2026
715de0d
Merge staging into enterprise organization search
waleedlatif1 Sep 8, 2026
cedf551
feat(org): organization surface UI, unified settings, and Sim Search …
emir-karabeg Sep 8, 2026
9bcb8fd
feat(search): enforce organization integration approvals
Sep 8, 2026
df7b170
feat(connected-accounts): manage shared accounts at organization scop…
TheodoreSpeaks Sep 8, 2026
84b8719
merge: sync organization search with staging
waleedlatif1 Sep 8, 2026
653f23e
Merge remote-tracking branch 'origin/staging' into codex/resolve-pr-7…
waleedlatif1 Sep 8, 2026
73c6cad
Merge remote-tracking branch 'origin/staging' into codex/resolve-pr-7…
waleedlatif1 Sep 8, 2026
73f6b88
fix(search): harden organization setup and document access
waleedlatif1 Sep 8, 2026
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
Prev Previous commit
Next Next commit
fix(knowledge): close two admin-mode gaps found in an architecture audit
Two real bugs, then the duplication and drift a full re-read of the branch
turned up.

Administrator mode was unreachable for Confluence. Entering the mode required
an impersonation subject on every connector, but a Confluence service account
holds an API token that already speaks for the site and impersonates nobody, so
the check refused every attempt. A subject is now required only of a connector
whose auth declares a subject field, and a test pins the token-backed case.

An incremental listing could not carry a revoked grant. A permission change
moves no content — re-sharing a file does not touch its modified time in Drive,
restricting a page does not touch its version in Confluence — so an incremental
run listed only edited documents and the ACL pass refreshed only those. A grant
revoked on an unchanged document stood until the next full sync happened to
run, which is the over-grant direction. Administrator mode now always lists
the whole corpus; content is still hydrated by hash, so unchanged documents are
never re-fetched or re-embedded, and the cost is metadata pages only.

Configuration validation minted without impersonation. The shared token
resolver took the source config as optional, and the validate path did not pass
it, so a Drive service account checked its configuration against an empty
domain. The config is required now, and every caller — sync, validation, mode
switch, creation, and the directory scheduler — passes it.

The Workspace domain was derived in two places with a comment saying they must
agree. It is one function now, beside the Google directory adapter, which moves
from the knowledge library to the Drive connector where Confluence's equivalent
already lives; provider-specific API clients belong with their connector, and
the orchestration that calls them stays provider-agnostic. The Confluence
connector memoised its cloud id in four separate copies, which became one.

A group identifier is canonicalised once, in `canonicalGroupId`, by the crawl
that writes a token and the directory sync that stores the membership it
resolves against. Both already lower-cased by different routes; now they cannot
drift. Confluence identifies groups by id, so the docs claiming "never an opaque
id" were wrong and are corrected in the token vocabulary and the schema.

Removed what nothing read: the impersonation subject the token resolver
returned, a `force` flag no caller passed, two vestigial type aliases, and a
nesting-depth constant that had one consumer and now lives with it. The
directory recency check is one aggregate query rather than two probes; the
Confluence ACL resolver enriches principals once rather than once per page; the
mode picker validates its value instead of casting it; and the admin-mode hint
no longer describes a field only Drive has. The create path had two copies of
the admin-role gate, one per permission-scoped mode, and has one.
  • Loading branch information
waleedlatif1 committed Sep 4, 2026
commit 9e68ad3dba9c19302fda41057d72ec3737d61ca0
76 changes: 43 additions & 33 deletions apps/sim/connectors/confluence/confluence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,25 @@ function cqlResultToStub(item: Record<string, unknown>, domain: string): Externa
})
}

/**
* The site's cloud id, memoised on the run so it is discovered once per sync
* rather than once per call — and taken from the credential where a service
* account already carries it, since its API token cannot call
* `accessible-resources` to discover one.
*/
async function resolveCloudId(
accessToken: string,
sourceConfig: Record<string, unknown>,
syncContext?: Record<string, unknown>
): Promise<string> {
const cached = syncContext?.cloudId
if (typeof cached === 'string' && cached) return cached
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
const cloudId = await getConfluenceCloudId(domain, accessToken)
if (syncContext) syncContext.cloudId = cloudId
return cloudId
}

/**
* The provider segment of every Confluence group token. Fixed, and baked into
* stored ACLs, so it must never change.
Expand All @@ -336,12 +355,7 @@ async function resolveConfluenceAcls(
externalIds: string[],
syncContext?: Record<string, unknown>
): Promise<Record<string, string[]>> {
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
let cloudId = syncContext?.cloudId as string | undefined
if (!cloudId) {
cloudId = await getConfluenceCloudId(domain, accessToken)
if (syncContext) syncContext.cloudId = cloudId
}
const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext)

const spaceKeys = parseMultiValue(sourceConfig.spaceKey)
const spacePrincipals: ConfluencePrincipal[] = []
Expand Down Expand Up @@ -382,8 +396,10 @@ async function resolveConfluenceAcls(
})

/**
* Addresses are resolved once for every account named anywhere, rather than
* per page: a space's own principals appear on every page that inherits them.
* Addresses are resolved once for every account named anywhere and written
* onto the principals in place: a space's own principals appear on every page
* that inherits them, and each restriction object is shared by every chain
* that walked through it.
*/
const accountIds = new Set<string>()
for (const principal of spacePrincipals) {
Expand All @@ -395,10 +411,16 @@ async function resolveConfluenceAcls(
}
}
const emails = await resolveUserEmails(cloudId, accessToken, [...accountIds])
const withEmail = (principals: readonly ConfluencePrincipal[]): ConfluencePrincipal[] =>
principals.map((principal) =>
principal.kind === 'user' ? { ...principal, email: emails.get(principal.id) } : principal
)
const withEmail = (principals: ConfluencePrincipal[]): void => {
for (const principal of principals) {
/** A restriction sometimes discloses the address itself; a lookup miss must not erase it. */
if (principal.kind === 'user') principal.email = emails.get(principal.id) ?? principal.email
}
}
withEmail(spacePrincipals)
for (const restriction of restrictions.values()) {
if (restriction !== null) withEmail(restriction)
}

const acls: Record<string, string[]> = {}
let unattributed = 0
Expand All @@ -407,8 +429,8 @@ async function resolveConfluenceAcls(
/** A page whose restrictions could not be read is readable by nobody, not by everyone. */
if (!chain) continue
const result = confluencePageAcl({
spacePrincipals: withEmail(spacePrincipals),
restrictionChain: chain.map((entry) => (entry === null ? null : withEmail(entry))),
spacePrincipals,
restrictionChain: chain,
providerId: CONFLUENCE_ACL_PROVIDER_ID,
tenantId: cloudId,
})
Expand Down Expand Up @@ -445,11 +467,7 @@ export const confluenceConnector: ConnectorConfig = {
throw new Error('At least one space key is required')
}

let cloudId = syncContext?.cloudId as string | undefined
if (!cloudId) {
cloudId = await getConfluenceCloudId(domain, accessToken)
if (syncContext) syncContext.cloudId = cloudId
}
const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext)

/**
* Route through CQL when a label filter is set, when multiple spaces are
Expand Down Expand Up @@ -508,15 +526,11 @@ export const confluenceConnector: ConnectorConfig = {

getDocumentAcls: resolveConfluenceAcls,

openDirectory: async (accessToken, sourceConfig, syncContext) => {
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
let cloudId = syncContext?.cloudId as string | undefined
if (!cloudId) {
cloudId = await getConfluenceCloudId(domain, accessToken)
if (syncContext) syncContext.cloudId = cloudId
}
return openConfluenceDirectory(cloudId, accessToken)
},
openDirectory: async (accessToken, sourceConfig, syncContext) =>
openConfluenceDirectory(
await resolveCloudId(accessToken, sourceConfig, syncContext),
accessToken
),

getDocument: async (
accessToken: string,
Expand All @@ -525,11 +539,7 @@ export const confluenceConnector: ConnectorConfig = {
syncContext?: Record<string, unknown>
): Promise<ExternalDocument | null> => {
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
let cloudId = syncContext?.cloudId as string | undefined
if (!cloudId) {
cloudId = await getConfluenceCloudId(domain, accessToken)
if (syncContext) syncContext.cloudId = cloudId
}
const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext)

/**
* Fetch the `view` representation rather than `storage`. Storage format only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_GROUP_NESTING_DEPTH } from '@/lib/knowledge/access/external-groups'
import { listDomainGroups, listGroupMembers } from '@/lib/knowledge/connectors/google-directory'
import { listDomainGroups, listGroupMembers } from '@/connectors/google-drive/directory'

const mockFetch = vi.fn()

Expand Down Expand Up @@ -115,7 +114,7 @@ describe('listGroupMembers', () => {

it('reports an incomplete walk rather than a truncated membership', async () => {
const members: Record<string, unknown[]> = {}
for (let depth = 0; depth <= MAX_GROUP_NESTING_DEPTH + 1; depth += 1) {
for (let depth = 0; depth <= 32; depth += 1) {
members[`g${depth}@corp.com`] = [USER(`u${depth}@corp.com`), NESTED(`g${depth + 1}@corp.com`)]
}
directory(members)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { MAX_GROUP_NESTING_DEPTH } from '@/lib/knowledge/access/external-groups'
import { canonicalGroupId } from '@/lib/knowledge/access/tokens'
import type {
ConnectorDirectory,
ConnectorDirectoryGroup,
Expand All @@ -14,8 +14,30 @@ const PAGE_SIZE = 200
/** Guards against a directory that keeps paginating; far above any real domain. */
const MAX_PAGES = 200

export type DirectoryGroup = ConnectorDirectoryGroup
export type DirectoryGroupMembership = ConnectorDirectoryMembership
/**
* How deep nested groups are followed when flattening membership.
*
* A directory can nest groups arbitrarily and can contain cycles, so the walk
* needs both a visited set and a depth bound. Onyx does not recurse at all,
* which silently drops everyone who is a member only through a subgroup; a
* bounded walk covers every real directory while still terminating.
*/
const MAX_GROUP_NESTING_DEPTH = 10

/**
* The Workspace domain an administrator's address belongs to, or undefined
* when the address is blank.
*
* This is the tenant of every group token a Drive crawl writes and of every
* group the directory sync stores, so it is derived in exactly one place: a
* crawl and a directory that spelled it differently would produce grants
* nothing ever resolves.
*/
export function googleWorkspaceDomain(adminEmail: unknown): string | undefined {
if (typeof adminEmail !== 'string') return undefined
const domain = adminEmail.trim().toLowerCase().split('@')[1]
return domain || undefined
}

interface DirectoryListResponse<T> {
nextPageToken?: string
Expand Down Expand Up @@ -75,11 +97,11 @@ interface RawMember {
export async function listDomainGroups(
accessToken: string,
domain: string
): Promise<DirectoryGroup[]> {
): Promise<ConnectorDirectoryGroup[]> {
const raw = await listAll<RawGroup>(`${DIRECTORY_BASE}/groups`, accessToken, 'groups', { domain })
const groups: DirectoryGroup[] = []
const groups: ConnectorDirectoryGroup[] = []
for (const group of raw) {
const id = group.email?.trim().toLowerCase()
const id = group.email ? canonicalGroupId(group.email) : ''
if (!id) continue
groups.push({ id })
}
Expand All @@ -100,8 +122,8 @@ export async function listDomainGroups(
*/
export async function listGroupMembers(
accessToken: string,
group: DirectoryGroup
): Promise<DirectoryGroupMembership> {
group: ConnectorDirectoryGroup
): Promise<ConnectorDirectoryMembership> {
const memberEmails = new Set<string>()
const visited = new Set<string>([group.id])
let complete = true
Expand Down Expand Up @@ -139,18 +161,12 @@ export async function listGroupMembers(
return { group, memberEmails: [...memberEmails], complete }
}

/**
* The Workspace domain the crawl is looking at, as a directory.
*
* The tenant is the impersonated administrator's email domain, which is what
* `driveAclContext` writes into every group token, so the two must derive it the
* same way or the tokens a crawl writes name a directory nothing resolves.
*/
/** The Workspace domain the crawl is looking at, as a directory. */
export function openGoogleDirectory(
accessToken: string,
adminEmail: string | undefined
adminEmail: unknown
): ConnectorDirectory | null {
const tenantId = adminEmail?.trim().toLowerCase().split('@')[1]
const tenantId = googleWorkspaceDomain(adminEmail)
if (!tenantId) return null
return {
tenantId,
Expand Down
16 changes: 7 additions & 9 deletions apps/sim/connectors/google-drive/google-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
driveFileAcl,
type OpenSharingPolicy,
} from '@/lib/knowledge/access/drive-permissions'
import { openGoogleDirectory } from '@/lib/knowledge/connectors/google-directory'
import {
attachRetryHeaders,
isRetryableError,
Expand All @@ -15,6 +14,7 @@ import {
retryWithExponentialBackoff,
VALIDATE_RETRY_OPTIONS,
} from '@/lib/knowledge/documents/utils'
import { googleWorkspaceDomain, openGoogleDirectory } from '@/connectors/google-drive/directory'
import {
GoogleDriveApiError,
readGoogleDriveApiError,
Expand Down Expand Up @@ -465,15 +465,13 @@ interface DriveAclContext {
* directory a group belongs to, and how far the admin has opted into open
* sharing being searchable.
*
* The tenant is the impersonated administrator's email domain, which is the
* Workspace domain the crawl is looking at. It has to be decided once and kept:
* it is baked into every stored `g:` token, so deriving it differently later
* would orphan every ACL already written. Null when no administrator is
* configured, which is every crawl that is not mirroring permissions.
* The tenant is the impersonated administrator's Workspace domain, derived by
* the same function the directory sync uses so the two can never disagree.
* Null when no administrator is configured, which is every crawl that is not
* mirroring permissions.
*/
function driveAclContext(sourceConfig: Record<string, unknown>): DriveAclContext | null {
const admin = typeof sourceConfig.adminEmail === 'string' ? sourceConfig.adminEmail : ''
const domain = admin.trim().toLowerCase().split('@')[1]
const domain = googleWorkspaceDomain(sourceConfig.adminEmail)
if (!domain) return null
const openSharing = sourceConfig.openSharing
return {
Expand Down Expand Up @@ -646,7 +644,7 @@ export const googleDriveConnector: ConnectorConfig = {
},

openDirectory: async (accessToken, sourceConfig) =>
openGoogleDirectory(accessToken, sourceConfig.adminEmail as string | undefined),
openGoogleDirectory(accessToken, sourceConfig.adminEmail),

getDocument: async (
accessToken: string,
Expand Down
10 changes: 0 additions & 10 deletions apps/sim/lib/knowledge/access/external-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,3 @@ export const EXTERNAL_GROUP_STALE_AFTER_MS = 24 * 60 * 60 * 1000
* thirty for the heavier APIs.
*/
export const EXTERNAL_GROUP_SYNC_INTERVAL_MS = 5 * 60 * 1000

/**
* How deep nested groups are followed when flattening membership.
*
* A directory can nest groups arbitrarily and can contain cycles, so the walk
* needs both a visited set and a depth bound. Onyx does not recurse at all,
* which silently drops everyone who is a member only through a subgroup; a
* bounded walk covers every real directory while still terminating.
*/
export const MAX_GROUP_NESTING_DEPTH = 10
17 changes: 12 additions & 5 deletions apps/sim/lib/knowledge/access/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ const emailHeldByAnotherAccount = sql<boolean>`EXISTS (
* is survivable, an abandoned sync is not.
*/
async function loadExternalGroupTokens(email: string, workspaceId: string): Promise<string[]> {
/**
* A query of its own rather than a fourth join on the credential query above:
* that one already fans out per managed credential, and joining groups onto
* it would multiply the two — every credential row repeated for every group.
* Two indexed reads cost less than one cross product.
*/
const freshEnough = new Date(Date.now() - EXTERNAL_GROUP_STALE_AFTER_MS)
const rows = await db
.select({
Expand Down Expand Up @@ -114,12 +120,13 @@ export interface KnowledgeAccessScopeContext {
}

/**
* The tokens a person holds in a workspace: the workspace pair plus one `s:`
* token per active managed credential bound to them through a credential-group
* enrollment. The person must be email-verified — the enrollment binding is by
* The tokens a person holds in a workspace: the workspace pair, one `s:` token
* per active managed credential bound to them through a credential-group
* enrollment, their own `u:` address, and a `g:` token per directory group it
* belongs to. The person must be email-verified — every binding here is by
* email, and an unverified address must not inherit grants made to whoever
* really owns it. Nothing here is cached: revoking or suspending a credential
* is visible on the next read.
* really owns it. Nothing here is cached: revoking a credential or leaving a
* group is visible on the next read.
*/
async function loadUserAccessTokens(
userId: string,
Expand Down
23 changes: 16 additions & 7 deletions apps/sim/lib/knowledge/access/tokens.ts
Loading