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
53 changes: 53 additions & 0 deletions apps/connection-broker/src/corpus-resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type CorpusCustomResource,
} from "./corpus-resource.js";
import { ConfluenceDriver } from "./drivers/confluence.js";
import { JazzHRDriver } from "./drivers/jazzhr.js";

function connection(overrides: Partial<ConnectionCustomResource["spec"]> = {}): ConnectionCustomResource {
return {
Expand Down Expand Up @@ -197,3 +198,55 @@ describe("reconcileIntervalMs", () => {
expect(reconcileIntervalMs(corpus({ sync: { mode: "poll" } }))).toBeUndefined();
});
});

describe("jazzhr provider", () => {
const jazzConnection = (overrides: Partial<ConnectionCustomResource["spec"]> = {}): ConnectionCustomResource => ({
metadata: { name: "bitovi-jazz", namespace: "clients" },
spec: {
provider: "jazzhr",
secretEnv: [{ name: SERVICE_TOKEN_ENV, secretRef: { name: "jazz", key: "apiKey" } }],
...overrides,
},
});
const jazzCorpus = (scope: CorpusCustomResource["spec"]["scope"]): CorpusCustomResource => ({
metadata: { name: "senior-eng", namespace: "clients" },
spec: { connectionRef: "bitovi-jazz", allowedRoles: ["recruiter"], scope },
status: { collection: "corpus_clients_senior-eng" },
});

it("dispatches to the JazzHR driver for a named job", async () => {
const binding = await toBinding(jazzCorpus({ jobID: "job-1" }), jazzConnection(), secrets());
expect(binding.driver).toBeInstanceOf(JazzHRDriver);
expect(binding.scope).toMatchObject({ jobID: "job-1" });
expect(binding.scope.all).toBeUndefined();
});

it("carries the Connection's jobIDs cap into a wildcard scope as allowedUnits", async () => {
const binding = await toBinding(
jazzCorpus({ all: true }),
jazzConnection({ allowedScopes: { jobIDs: ["job-1", "job-2"] } }),
secrets(),
);
expect(binding.scope.all).toBe(true);
expect(binding.scope.allowedUnits).toEqual(["job-1", "job-2"]);
});

it("leaves allowedUnits unset for an uncapped wildcard", async () => {
const binding = await toBinding(jazzCorpus({ all: true }), jazzConnection(), secrets());
expect(binding.scope.all).toBe(true);
expect(binding.scope.allowedUnits).toBeUndefined();
});

it("permits a wildcard even when the Connection caps scopes", async () => {
// A wildcard is bounded by the cap at enumeration time, not rejected here.
await expect(
toBinding(jazzCorpus({ all: true }), jazzConnection({ allowedScopes: { jobIDs: ["job-1"] } }), secrets()),
).resolves.toBeDefined();
});

it("still enforces the cap for a NAMED job outside it", async () => {
await expect(
toBinding(jazzCorpus({ jobID: "job-9" }), jazzConnection({ allowedScopes: { jobIDs: ["job-1"] } }), secrets()),
).rejects.toBeInstanceOf(CorpusConfigError);
});
});
52 changes: 49 additions & 3 deletions apps/connection-broker/src/corpus-resource.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ConfluenceDriver } from "./drivers/confluence.js";
import { GDriveDriver } from "./drivers/gdrive.js";
import { JazzHRDriver } from "./drivers/jazzhr.js";
import { SlackDriver } from "./drivers/slack.js";
import type { Driver, Scope } from "./drivers/types.js";
import type { CorpusBinding } from "./registry.js";
Expand All @@ -20,7 +21,7 @@ export interface CorpusCustomResource {
connectionRef: string;
displayName?: string;
allowedRoles?: string[];
scope: { space?: string; channel?: string; folderID?: string };
scope: { space?: string; channel?: string; folderID?: string; jobID?: string; all?: boolean };
sync?: { mode?: string; reconcileInterval?: string };
};
status?: { collection?: string; provider?: string };
Expand All @@ -36,7 +37,7 @@ export interface ConnectionCustomResource {
provider: string;
displayName?: string;
site?: { baseURL: string; cloudId?: string };
allowedScopes?: { spaces?: string[]; channels?: string[]; folderIDs?: string[] };
allowedScopes?: { spaces?: string[]; channels?: string[]; folderIDs?: string[]; jobIDs?: string[] };
autoJoin?: boolean;
secretEnv?: { name: string; secretRef: { name: string; key: string } }[];
};
Expand Down Expand Up @@ -85,10 +86,17 @@ export async function toBinding(
}

const serviceToken = await resolveServiceToken(name, connection, readSecret);
const wildcard = corpus.spec.scope?.all === true;
const scope: Scope = {
space: corpus.spec.scope?.space,
channel: corpus.spec.scope?.channel,
folderID: corpus.spec.scope?.folderID,
jobID: corpus.spec.scope?.jobID,
all: wildcard || undefined,
// A wildcard carries its bound to the driver: the Connection's allowlist for
// this provider, or undefined when it set none (enumerate everything the
// credential reaches). Only meaningful with `all`.
...(wildcard ? { allowedUnits: allowedUnitsFor(connection) } : {}),
};

assertWithinConnectionScopes(name, scope, connection);
Expand Down Expand Up @@ -116,6 +124,12 @@ function assertWithinConnectionScopes(
scope: Scope,
connection: ConnectionCustomResource,
): void {
// A wildcard is permitted by construction: it reaches exactly the Connection's
// allowlist (carried as `allowedUnits`), or everything the credential reaches
// when no cap is set. There is no single unit to check against the cap —
// the cap IS the enumeration bound, applied by the driver.
if (scope.all) return;

const caps = connection.spec.allowedScopes;
if (!caps) return; // No cap set is not an empty cap — it permits anything.

Expand All @@ -124,7 +138,9 @@ function assertWithinConnectionScopes(
? [scope.space, caps.spaces]
: scope.channel !== undefined
? [scope.channel, caps.channels]
: [scope.folderID, caps.folderIDs];
: scope.folderID !== undefined
? [scope.folderID, caps.folderIDs]
: [scope.jobID, caps.jobIDs];

if (!allowed || allowed.length === 0) return;
if (unit !== undefined && allowed.includes(unit)) return;
Expand All @@ -135,6 +151,28 @@ function assertWithinConnectionScopes(
);
}

/**
* The Connection's wildcard bound for its provider — the allowlist a wildcard
* corpus enumerates. Undefined when the Connection set no cap, which a driver
* reads as "everything the service credential can reach".
*/
function allowedUnitsFor(connection: ConnectionCustomResource): string[] | undefined {
const caps = connection.spec.allowedScopes;
if (!caps) return undefined;
switch (connection.spec.provider) {
case "confluence":
return caps.spaces;
case "slack":
return caps.channels;
case "gdrive":
return caps.folderIDs;
case "jazzhr":
return caps.jobIDs;
default:
return undefined;
}
}

function driverFor(name: string, connection: ConnectionCustomResource, serviceToken: string): Driver {
const spec = connection.spec;
switch (spec.provider) {
Expand Down Expand Up @@ -191,6 +229,14 @@ function driverFor(name: string, connection: ConnectionCustomResource, serviceTo
process.env.GDRIVE_API_ORIGIN ? { apiOrigin: process.env.GDRIVE_API_ORIGIN } : {},
);

case "jazzhr":
// JazzHR has one site (api.resumatorapi.com) and no per-tenant address,
// so no site coordinates. Same API-origin seam as the others, for the e2e
// fake. The service token is the JazzHR API key.
return new JazzHRDriver({
...(process.env.JAZZHR_API_ORIGIN ? { apiOrigin: process.env.JAZZHR_API_ORIGIN } : {}),
});

default:
// A provider the CRD's enum does not cover, or one added to the enum
// ahead of its driver. Serving it with a stand-in would be worse than
Expand Down
2 changes: 1 addition & 1 deletion apps/connection-broker/src/drivers/confluence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ describe("scope validation", () => {
const driver = driverWith(vi.fn());

it("requires a space", () => {
expect(() => driver.validateScope({})).toThrow(/scoped to a space/);
expect(() => driver.validateScope({})).toThrow(/exactly one of scope.space or scope.all/);
});

it("rejects a scope that also names another provider's unit", () => {
Expand Down
51 changes: 47 additions & 4 deletions apps/connection-broker/src/drivers/confluence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type WebhookRequest,
} from "./types.js";
import { resolveQuery } from "./query.js";
import { listWildcard } from "./wildcard.js";
import { hmacHex, signaturesMatch } from "./webhook-signature.js";

/** Minimal HTTP surface, injectable so the driver is testable without a tenant. */
Expand Down Expand Up @@ -260,12 +261,16 @@ export class ConfluenceDriver implements Driver {
}

validateScope(scope: Scope): void {
if (!scope.space) throw new Error("a confluence connection must be scoped to a space");
if (scope.channel || scope.folderID) {
throw new Error("a confluence connection must set scope.space and nothing else");
if (scope.channel || scope.folderID || scope.jobID) {
throw new Error("a confluence connection must set scope.space (or scope.all) and nothing else");
}
const named = scope.space !== undefined;
// Exactly one of a named space or the wildcard.
if (named === Boolean(scope.all)) {
throw new Error("a confluence corpus must set exactly one of scope.space or scope.all");
}
// A space key reaches a URL path, so it is constrained rather than trusted.
if (!/^[A-Za-z0-9._~-]+$/.test(scope.space)) {
if (scope.space !== undefined && !/^[A-Za-z0-9._~-]+$/.test(scope.space)) {
throw new Error(`illegal confluence space key: ${scope.space}`);
}
}
Expand All @@ -275,6 +280,44 @@ export class ConfluenceDriver implements Driver {
}

async list(scope: Scope, credentials: Credentials, since: Cursor): Promise<ListPage> {
this.validateScope(scope);
if (scope.all) {
return listWildcard(
scope,
credentials,
(creds) => this.enumerateSpaces(creds),
(space) => ({ space }),
(unit, creds, cur) => this.listSpace(unit, creds, cur),
);
}
return this.listSpace(scope, credentials, since);
}

/**
* Every space the ingestion credential can see — the units a wildcard corpus
* covers when the Connection sets no cap.
*/
private async enumerateSpaces(credentials: Credentials): Promise<string[]> {
const token = requireToken(credentials.service);
const apiBase = await this.apiBaseFor(token);
const keys: string[] = [];
let url = `${apiBase}/api/v2/spaces?limit=${this.pageSize}`;
for (;;) {
const body = (await this.request(url, token)) as {
results?: { key?: string }[];
_links?: { next?: string };
};
for (const space of body.results ?? []) {
if (space.key) keys.push(space.key);
}
const next = nextCursor(body._links?.next);
if (!next) break;
url = `${apiBase}/api/v2/spaces?limit=${this.pageSize}&cursor=${encodeURIComponent(next)}`;
}
return keys;
}

private async listSpace(scope: Scope, credentials: Credentials, since: Cursor): Promise<ListPage> {
this.validateScope(scope);
const token = requireToken(credentials.service);
const apiBase = await this.apiBaseFor(token);
Expand Down
12 changes: 11 additions & 1 deletion apps/connection-broker/src/drivers/gdrive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe("scope validation", () => {
const d = driver(vi.fn());

it("requires a folder", () => {
expect(() => d.validateScope({})).toThrow(/scoped to a folder/);
expect(() => d.validateScope({})).toThrow(/exactly one of scope.folderID or scope.all/);
});

it("rejects an id that would break out of the query term", () => {
Expand Down Expand Up @@ -818,3 +818,13 @@ describe("shortcuts", () => {
expect(page.resources[0].version).toBe("1");
});
});

describe("wildcard scope", () => {
it("refuses an uncapped wildcard — Drive has no cheap 'every folder' query", async () => {
// With no allowedScopes.folderIDs the driver cannot bound the wildcard, so
// it refuses rather than trying to index the whole account.
await expect(
driver(vi.fn()).list({ all: true }, { service: "t" }, undefined),
).rejects.toBeInstanceOf(PermanentError);
});
});
42 changes: 38 additions & 4 deletions apps/connection-broker/src/drivers/gdrive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { FetchLike } from "./confluence.js";
import { resolveQuery } from "./query.js";
import { GoogleServiceCredential, type TokenFetch } from "./google-auth.js";
import { extractDocxText, extractXlsxText } from "./ooxml.js";
import { listWildcard } from "./wildcard.js";

export interface GDriveDriverOptions {
fetch?: FetchLike;
Expand Down Expand Up @@ -140,13 +141,17 @@ export class GDriveDriver implements Driver {
}

validateScope(scope: Scope): void {
if (!scope.folderID) throw new Error("a gdrive connection must be scoped to a folder");
if (scope.space || scope.channel) {
throw new Error("a gdrive connection must set scope.folderID and nothing else");
if (scope.space || scope.channel || scope.jobID) {
throw new Error("a gdrive connection must set scope.folderID (or scope.all) and nothing else");
}
const named = scope.folderID !== undefined;
// Exactly one of a named folder or the wildcard.
if (named === Boolean(scope.all)) {
throw new Error("a gdrive corpus must set exactly one of scope.folderID or scope.all");
}
// The folder id is interpolated into a query string, where an apostrophe
// would terminate the quoted term and let the rest be read as query syntax.
if (!/^[A-Za-z0-9_-]+$/.test(scope.folderID)) {
if (scope.folderID !== undefined && !/^[A-Za-z0-9_-]+$/.test(scope.folderID)) {
throw new Error(`illegal gdrive folder id: ${scope.folderID}`);
}
}
Expand All @@ -172,6 +177,35 @@ export class GDriveDriver implements Driver {
* against a live folder, where two of two nested documents were missing.
*/
async list(scope: Scope, credentials: Credentials, since: Cursor): Promise<ListPage> {
this.validateScope(scope);
if (scope.all) {
return listWildcard(
scope,
credentials,
(creds) => this.enumerateFolders(creds),
(folderID) => ({ folderID }),
(unit, creds, cur) => this.listFolder(unit, creds, cur),
);
}
return this.listFolder(scope, credentials, since);
}

/**
* A wildcard gdrive corpus must name its folders. Drive has no cheap,
* complete "every folder this credential can reach" query, and "the whole
* Drive account" is both unbounded and rarely what an operator means — so
* wildcard is supported only when the Connection caps it with
* allowedScopes.folderIDs (carried here as scope.allowedUnits, which
* listWildcard uses before it ever calls this).
*/
private enumerateFolders(_credentials: Credentials): Promise<string[]> {
throw new PermanentError(
"a wildcard gdrive corpus requires the Connection to cap it with allowedScopes.folderIDs",
);
}

/** Lists ONE folder's indexable files, RECURSIVELY (see the method doc below). */
private async listFolder(scope: Scope, credentials: Credentials, since: Cursor): Promise<ListPage> {
this.validateScope(scope);
// Ingestion: an SA key is minted + refreshed here; a raw token is used as-is.
const token = await this.serviceCreds.bearer(credentials.service);
Expand Down
Loading
Loading