Skip to content
Open
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
28 changes: 22 additions & 6 deletions docs/pages/platform-knowledge-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,31 @@ Connectors pull data from external tools into Knowledge Bases. A connector can b

Each connector has a visibility setting that determines which users can retrieve its data when an agent calls `query_knowledge_sources`. Connectors and Knowledge Bases are filtered by visibility throughout the UI: users only see sources they have access to, and only those can be assigned to agents and MCP Gateways.

| Mode | Behavior |
| ------------------------- | --------------------------------------------------------------------------------- |
| **Org-wide** | All documents accessible to every user in the organization. |
| **Team-scoped** | Documents accessible only to members of the assigned teams. |
| **Auto-sync permissions** | ACL entries synced from the source system (user emails, groups). Coming soon — see [#3218](https://github.com/archestra-ai/archestra/issues/3218). |
| Mode | Behavior |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Org-wide** | All documents accessible to every user in the organization. |
| **Team-scoped** | Documents accessible only to members of the assigned teams. |
| **Auto-sync permissions** | Each document inherits its access list from the source system. Supported for Jira and Confluence today (see details below). |

Users with the `knowledgeSource:admin` role can view and query every connector regardless of visibility.

> **Enterprise only.** Team-scoped visibility and auto-synced ACLs require an enterprise license. Contact [sales@archestra.ai](mailto:sales@archestra.ai) for licensing information.
> **Enterprise only.** Team-scoped visibility and auto-sync permissions both require an enterprise license. Contact [sales@archestra.ai](mailto:sales@archestra.ai) for licensing information.

### Auto-sync permissions

When a connector uses **Auto-sync permissions** visibility, Archestra extracts the read ACL for every document from the source system during sync and stores it alongside the document. At query time, `query_knowledge_sources` filters chunks so users only see results they can already read upstream.

**Supported connectors:** Jira, Confluence.

**Identity mapping.** Users are matched by their email address — the email on the Archestra account must match the email on the upstream user profile. Atlassian Cloud can hide profile emails for privacy reasons; users with hidden emails are skipped during ACL ingestion and will not see their restricted documents until the email is exposed to the integration account.

**Jira ACL source.** For each Jira project, Archestra collects the actors (users + groups) of every project role and uses that as the per-issue ACL. Issue-level security levels are not consulted today — those documents fall back to the project ACL. ACL refresh is incremental: every time a connector syncs an issue, its ACL is re-fetched and replaced.

**Confluence ACL source.** For each Confluence page, Archestra first checks page-level read restrictions. When a page has no explicit restriction it falls back to the space-level read permissions. Both lookups are cached per sync run.

**Group sync (limitation).** Group ACL entries are stored on the document but Archestra does not currently sync upstream group memberships back to user identities. Pages and issues that are restricted to a group will be invisible to callers until the upstream system also lists those users explicitly. Plan accordingly when migrating from team-scoped access.

**Fail-closed behavior.** When the connector fails to fetch ACL for a document (network error, missing scope, hidden email), the document is ingested with an empty ACL and remains invisible to every caller. Look for `Document has no resolvable ACL under auto-sync-permissions` warnings in the connector run logs.

## Jira

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,57 @@ describe("knowledge-management tool execution", () => {
);
});

test("create_knowledge_connector rejects auto-sync-permissions without enterprise license", async () => {
const result = await executeArchestraTool(
t("create_knowledge_connector"),
{
name: "Auto Sync Connector",
connector_type: "jira",
visibility: "auto-sync-permissions",
config: {
jiraBaseUrl: "https://test.atlassian.net",
isCloud: true,
projectKey: "TEST",
},
},
mockContext,
);

expect(result.isError).toBe(true);
expect((result.content[0] as any).text).toContain(
"auto-sync-permissions visibility requires the knowledge-base enterprise license",
);
});

test("create_knowledge_connector rejects auto-sync-permissions for unsupported connector type", async () => {
const configModule = await import("@/config");
const originalKb = configModule.default.enterpriseFeatures.knowledgeBase;
configModule.default.enterpriseFeatures.knowledgeBase = true;

try {
const result = await executeArchestraTool(
t("create_knowledge_connector"),
{
name: "Auto Sync Unsupported",
connector_type: "github",
visibility: "auto-sync-permissions",
config: {
githubUrl: "https://github.com",
owner: "test",
},
},
mockContext,
);

expect(result.isError).toBe(true);
expect((result.content[0] as any).text).toContain(
'auto-sync-permissions visibility is not supported for connector type "github"',
);
} finally {
configModule.default.enterpriseFeatures.knowledgeBase = originalKb;
}
});

test("get_knowledge_connectors returns empty list", async () => {
const result = await executeArchestraTool(
t("get_knowledge_connectors"),
Expand Down
39 changes: 39 additions & 0 deletions platform/backend/src/archestra-mcp-server/knowledge-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
TOOL_UPDATE_KNOWLEDGE_CONNECTOR_SHORT_NAME,
} from "@shared";
import { z } from "zod";
import config from "@/config";
import {
buildUserAccessControlList,
didKnowledgeSourceAclInputsChange,
Expand All @@ -36,8 +37,10 @@ import {
} from "@/models";
import {
type AclEntry,
connectorTypeSupportsAutoSyncPermissions,
InsertKnowledgeBaseConnectorSchema,
InsertKnowledgeBaseSchema,
type KnowledgeSourceVisibility,
KnowledgeSourceVisibilitySchema,
UpdateKnowledgeBaseConnectorSchema,
UpdateKnowledgeBaseSchema,
Expand Down Expand Up @@ -743,6 +746,13 @@ async function handleCreateKnowledgeConnector(params: {
"At least one team must be selected for team-scoped connectors",
);
}
const autoSyncError = validateAutoSyncPermissions({
visibility,
connectorType: args.connector_type,
});
if (autoSyncError) {
return errorResult(autoSyncError);
}

const connector = await KnowledgeBaseConnectorModel.create(
InsertKnowledgeBaseConnectorSchema.parse({
Expand Down Expand Up @@ -898,6 +908,13 @@ async function handleUpdateKnowledgeConnector(params: {
"At least one team must be selected for team-scoped connectors",
);
}
const autoSyncError = validateAutoSyncPermissions({
visibility: nextVisibility,
connectorType: existingConnector.connectorType,
});
if (autoSyncError) {
return errorResult(autoSyncError);
}
const connector = await KnowledgeBaseConnectorModel.update(
args.id,
updates,
Expand Down Expand Up @@ -1104,3 +1121,25 @@ async function handleUnassignKnowledgeConnectorFromAgent(params: {
return catchError(error, "unassigning knowledge connector from agent");
}
}

/**
* Validate that `auto-sync-permissions` is only used by connectors that can
* actually extract upstream ACL data, and only when the knowledge-base
* enterprise license is active. Returns an error message when the
* configuration is invalid and `null` otherwise.
*/
function validateAutoSyncPermissions(params: {
visibility: KnowledgeSourceVisibility | undefined;
connectorType: string;
}): string | null {
if (params.visibility !== "auto-sync-permissions") {
return null;
}
if (!config.enterpriseFeatures.knowledgeBase) {
return "auto-sync-permissions visibility requires the knowledge-base enterprise license";
}
if (!connectorTypeSupportsAutoSyncPermissions(params.connectorType)) {
return `auto-sync-permissions visibility is not supported for connector type "${params.connectorType}"`;
}
return null;
}
43 changes: 28 additions & 15 deletions platform/backend/src/knowledge-base/connector-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import type {
AclEntry,
ConnectorCredentials,
ConnectorDocument,
KnowledgeBaseConnector,
} from "@/types";
import { chunkDocument } from "./chunker";
import {
Expand Down Expand Up @@ -49,11 +48,16 @@ class ConnectorSyncService {
throw new Error(`Connector not found: ${connectorId}`);
}

// Load credentials from secrets manager
const [credentials, documentAcl] = await Promise.all([
this.loadCredentials(connector.secretId, log),
this.buildDocumentAccessControlList(connector),
]);
// Load credentials from secrets manager. For `auto-sync-permissions`
// visibility there is no connector-wide ACL — each document gets its
// own ACL computed from `doc.permissions` inside the ingest loop.
const credentials = await this.loadCredentials(connector.secretId, log);
const connectorAcl =
knowledgeSourceAccessControlService.buildConnectorDocumentAccessControlList(
{ connector },
);
const useAutoSyncPermissions =
connector.visibility === "auto-sync-permissions";

// Get the connector implementation
const connectorImpl = getConnector(connector.connectorType);
Expand Down Expand Up @@ -154,19 +158,36 @@ class ConnectorSyncService {
credentials,
checkpoint: connector.checkpoint as Record<string, unknown> | null,
embeddingInputModalities,
extractPermissions: useAutoSyncPermissions,
});

for await (const batch of syncGenerator) {
const ingestedDocumentIds: string[] = [];
for (const doc of batch.documents) {
documentsProcessed++;
try {
const acl = useAutoSyncPermissions
? knowledgeSourceAccessControlService.buildDocumentAccessControlListForDocument(
{ connector, permissions: doc.permissions },
)
: (connectorAcl ?? []);

if (useAutoSyncPermissions && acl.length === 0) {
runLog.warn(
{
documentId: doc.id,
hasPermissions: Boolean(doc.permissions),
},
"Document has no resolvable ACL under auto-sync-permissions; it will not be visible to any user",
);
}

const result = await this.ingestDocument({
doc,
connectorId,
connectorType: connector.connectorType,
organizationId: connector.organizationId,
acl: documentAcl,
acl,
log: runLog,
});
if (result.ingested) {
Expand Down Expand Up @@ -617,14 +638,6 @@ class ConnectorSyncService {
apiToken: (data.apiToken as string) || "",
};
}

private buildDocumentAccessControlList(
connector: KnowledgeBaseConnector,
): AclEntry[] {
return knowledgeSourceAccessControlService.buildConnectorDocumentAccessControlList(
{ connector },
);
}
}

export const connectorSyncService = new ConnectorSyncService();
51 changes: 51 additions & 0 deletions platform/backend/src/knowledge-base/connectors/base-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,59 @@ export abstract class BaseConnector implements Connector {
checkpoint: Record<string, unknown> | null;
startTime?: Date;
endTime?: Date;
extractPermissions?: boolean;
}): AsyncGenerator<ConnectorSyncBatch>;

/**
* Convenience helper used by connectors that support `auto-sync-permissions`.
* Builds the `ConnectorDocument.permissions` payload consumed by the sync
* pipeline. Returns `undefined` when there is nothing to publish — callers
* should leave the field unset on the document in that case.
*
* Emails are lower-cased so they line up with the lower-cased emails the
* query layer uses when building the caller's `user_email:<email>` ACL.
*/
protected buildDocumentPermissions(params: {
users?: Iterable<string | null | undefined>;
groups?: Iterable<string | null | undefined>;
isPublic?: boolean;
}): { users?: string[]; groups?: string[]; isPublic?: boolean } | undefined {
const users: string[] = [];
const groups: string[] = [];
const seenUsers = new Set<string>();
const seenGroups = new Set<string>();

for (const raw of params.users ?? []) {
if (typeof raw !== "string") continue;
const email = raw.trim().toLowerCase();
if (email.length === 0 || seenUsers.has(email)) continue;
seenUsers.add(email);
users.push(email);
}

for (const raw of params.groups ?? []) {
if (typeof raw !== "string") continue;
const group = raw.trim();
if (group.length === 0 || seenGroups.has(group)) continue;
seenGroups.add(group);
groups.push(group);
}

if (users.length === 0 && groups.length === 0 && params.isPublic !== true) {
return undefined;
}

const payload: {
users?: string[];
groups?: string[];
isPublic?: boolean;
} = {};
if (users.length > 0) payload.users = users;
if (groups.length > 0) payload.groups = groups;
if (params.isPublic === true) payload.isPublic = true;
return payload;
}

protected buildBasicAuthHeader(email: string, apiToken: string): string {
const encoded = Buffer.from(`${email}:${apiToken}`).toString("base64");
return `Basic ${encoded}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
buildCheckpoint,
extractErrorMessage,
} from "../base-connector";
import { ConfluencePermissionResolver } from "./confluence-permissions";

const DEFAULT_BATCH_SIZE = 50;

Expand Down Expand Up @@ -125,6 +126,7 @@ export class ConfluenceConnector extends BaseConnector {
checkpoint: Record<string, unknown> | null;
startTime?: Date;
endTime?: Date;
extractPermissions?: boolean;
}): AsyncGenerator<ConnectorSyncBatch> {
const parsed = parseConfluenceConfig(params.config);
if (!parsed) {
Expand All @@ -137,6 +139,14 @@ export class ConfluenceConnector extends BaseConnector {
const batchSize = parsed.batchSize ?? DEFAULT_BATCH_SIZE;
const cql = buildCql(parsed, checkpoint, params.startTime);
const client = createConfluenceClient(parsed, params.credentials, this.log);
const extractPermissions = params.extractPermissions === true;
const permissionResolver = extractPermissions
? new ConfluencePermissionResolver({
client,
log: this.log,
isCloud: parsed.isCloud,
})
: null;

this.log.debug(
{
Expand Down Expand Up @@ -198,9 +208,30 @@ export class ConfluenceConnector extends BaseConnector {
continue;
}

documents.push(
pageToDocument(page, parsed.confluenceUrl, parsed.isCloud),
const document = pageToDocument(
page,
parsed.confluenceUrl,
parsed.isCloud,
);

if (permissionResolver) {
const permissions = await permissionResolver.resolveForPage({
pageId: String(page.id),
spaceKey:
typeof page?.space?.key === "string"
? page.space.key
: undefined,
});
const payload = this.buildDocumentPermissions({
users: permissions?.users,
groups: permissions?.groups,
});
if (payload) {
document.permissions = payload;
}
}

documents.push(document);
}

const nextUrl: string | undefined = searchResult._links?.next;
Expand Down
Loading