History

Gate Google OAuth listings by user flag (#1947)

Rhys Sullivan committed 2 months ago

Commit 31a80421 parent 5e4da2a8 files · +274 −66Browse files

Unified · Split

8 files
  1. .changeset/first-party-oauth-listing-policy.md +5 −0
  2. apps/cloud/src/analytics/google-oauth-listing.test.ts +89 −0
  3. apps/cloud/src/analytics/google-oauth-listing.ts +44 −0
  4. apps/cloud/src/engine/first-party-oauth-clients.test.ts +9 −4
  5. apps/cloud/src/engine/first-party-oauth-clients.ts +12 −6
  6. packages/core/sdk/src/oauth-client.ts +8 −0
  7. packages/core/sdk/src/oauth-first-party.test.ts +46 −1
  8. packages/core/sdk/src/oauth-service.ts +61 −55
.changeset/first-party-oauth-listing-policy.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/sdk": patch
3---
4
5Allow hosts to control first-party OAuth app listings for each acting user without disrupting existing connections.
apps/cloud/src/analytics/google-oauth-listing.test.ts+89−0
@@ -0,0 +1,89 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3
4import { GOOGLE_OAUTH_REVIEW_FLAG, makeGoogleOAuthListing } from "./google-oauth-listing";
5
6const context = { userId: "review-user", organizationId: "review-org" };
7
8describe("Google OAuth review listing", () => {
9 it.effect("targets the authenticated user and re-evaluates after revocation", () =>
10 Effect.gen(function* () {
11 let enabled = true;
12 const policy = makeGoogleOAuthListing({
13 projectKey: "public-project-key",
14 host: "https://flags.example.com",
15 fetch: async (url, init) => {
16 expect(url).toBe("https://flags.example.com/flags?v=2");
17 expect(await new Response(String(init?.body)).json()).toMatchObject({
18 distinct_id: context.userId,
19 person_properties: { executor_user_id: context.userId },
20 flag_keys_to_evaluate: [GOOGLE_OAUTH_REVIEW_FLAG],
21 });
22 expect(init?.signal).toBeInstanceOf(AbortSignal);
23 return Response.json({ flags: { [GOOGLE_OAUTH_REVIEW_FLAG]: { enabled } } });
24 },
25 });
26 expect(yield* policy(context)).toBe(true);
27 enabled = false;
28 expect(yield* policy(context)).toBe(false);
29 }),
30 );
31
32 for (const body of [
33 { flags: {} },
34 { flags: { [GOOGLE_OAUTH_REVIEW_FLAG]: { enabled: "true" } } },
35 {},
36 ]) {
37 it.effect(`withholds an absent or invalid flag: ${JSON.stringify(body)}`, () =>
38 Effect.gen(function* () {
39 const policy = makeGoogleOAuthListing({
40 projectKey: "key",
41 host: "https://flags.example.com",
42 fetch: async () => Response.json(body),
43 });
44 expect(yield* policy(context)).toBe(false);
45 }),
46 );
47 }
48
49 it.effect("withholds on HTTP and transport failures", () =>
50 Effect.gen(function* () {
51 const unavailable = makeGoogleOAuthListing({
52 projectKey: "key",
53 host: "https://flags.example.com",
54 fetch: async () => new Response(null, { status: 503 }),
55 });
56 const failed = makeGoogleOAuthListing({
57 projectKey: "key",
58 host: "https://flags.example.com",
59 fetch: () =>
60 Effect.runPromise(Effect.fail(new DOMException("Network unavailable", "NetworkError"))),
61 });
62 expect(yield* unavailable(context)).toBe(false);
63 expect(yield* failed(context)).toBe(false);
64 }),
65 );
66
67 it.effect("does not evaluate without a user or project key", () =>
68 Effect.gen(function* () {
69 let requests = 0;
70 const fetch: typeof globalThis.fetch = async () => {
71 requests += 1;
72 return Response.json({ flags: {} });
73 };
74 const configured = makeGoogleOAuthListing({
75 projectKey: "key",
76 host: "https://flags.example.com",
77 fetch,
78 });
79 const unconfigured = makeGoogleOAuthListing({
80 projectKey: undefined,
81 host: "https://flags.example.com",
82 fetch,
83 });
84 expect(yield* configured({ ...context, userId: null })).toBe(false);
85 expect(yield* unconfigured(context)).toBe(false);
86 expect(requests).toBe(0);
87 }),
88 );
89});
apps/cloud/src/analytics/google-oauth-listing.ts+44−0
@@ -0,0 +1,44 @@
1import { Effect, Schema } from "effect";
2
3import type { FirstPartyOAuthClientConfig } from "@executor-js/sdk";
4
5/** Server-side rollout for offering the built-in Google OAuth app. Configure
6 * the PostHog flag's runtime as "all": direct HTTP clients are not recognized
7 * as server SDKs. The caller identity still comes only from the server. */
8export const GOOGLE_OAUTH_REVIEW_FLAG = "google-oauth-review";
9
10const FlagsResponse = Schema.Struct({
11 flags: Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })),
12});
13
14/** Evaluate each listing against the authenticated user. Missing configuration,
15 * timeouts, and invalid responses withhold the app. No verdict is cached. */
16export const makeGoogleOAuthListing =
17 (config: {
18 readonly projectKey: string | undefined;
19 readonly host: string;
20 readonly fetch: typeof globalThis.fetch;
21 }): NonNullable<FirstPartyOAuthClientConfig["isListed"]> =>
22 (context) =>
23 Effect.gen(function* () {
24 if (!config.projectKey || context.userId === null) return false;
25 const response = yield* Effect.tryPromise(() =>
26 config.fetch(`${config.host}/flags?v=2`, {
27 method: "POST",
28 headers: { "content-type": "application/json", "user-agent": "executor-cloud" },
29 body: JSON.stringify({
30 api_key: config.projectKey,
31 distinct_id: context.userId,
32 // Supplied by the server so targeting needs no prior browser identify.
33 person_properties: { executor_user_id: context.userId },
34 groups: { organization: context.organizationId },
35 flag_keys_to_evaluate: [GOOGLE_OAUTH_REVIEW_FLAG],
36 }),
37 signal: AbortSignal.timeout(1_000),
38 }),
39 );
40 if (!response.ok) return false;
41 const body: unknown = yield* Effect.tryPromise(() => response.json());
42 const parsed = yield* Schema.decodeUnknownEffect(FlagsResponse)(body);
43 return parsed.flags[GOOGLE_OAUTH_REVIEW_FLAG]?.enabled === true;
44 }).pipe(Effect.catch(() => Effect.succeed(false)));
apps/cloud/src/engine/first-party-oauth-clients.test.ts+9−4
@@ -1,4 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
2 3
3import {4import {
4 firstPartyOAuthClientsFor,5 firstPartyOAuthClientsFor,
@@ -99,7 +100,7 @@ describe("cloud first-party OAuth clients", () => { · 94 unmodified lines
99// The reviewed consumer scope boundary of the Executor-owned Google app.100// The reviewed consumer scope boundary of the Executor-owned Google app.
100//101//
101// These assertions used to live in `e2e/scenarios/first-party-oauth.test.ts`,102// These assertions used to live in `e2e/scenarios/first-party-oauth.test.ts`,
102// read off `listClients`. The app is now `unlisted`, so it has no read surface103// read off `listClients`. The app is now gated, so it has no public read surface
103// to introspect — the bundle is only observable on the config it is built from,104// to introspect — the bundle is only observable on the config it is built from,
104// which is here. The e2e still owns the BEHAVIOUR the boundary produces (which105// which is here. The e2e still owns the BEHAVIOUR the boundary produces (which
105// scopes an `oauth.start` requests, and that admin scopes are refused).106// scopes an `oauth.start` requests, and that admin scopes are refused).
@@ -109,13 +110,17 @@ describe("cloud first-party Google app", () => { · 3 unmodified lines
109 const google = () =>110 const google = () =>
110 firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google");111 firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google");
111 112
112 it("declares the Google app but withholds it from every listing", () => {113 it("declares the Google app but withholds it without a configured rollout", async () => {
113 const client = google();114 const client = google();
114 expect(client, "the env-declared first-party Google app is configured").toBeDefined();115 expect(client, "the env-declared first-party Google app is configured").toBeDefined();
115 // The entry MUST stay declared: `loadClient` resolves it by slug for every116 // The entry MUST stay declared: `loadClient` resolves it by slug for every
116 // existing connection's refresh and reconnect. `unlisted` is what stops it117 // existing connection's refresh and reconnect. The listing policy stops it
117 // being offered for new connections.118 // being offered for new connections.
118 expect(client?.unlisted).toBe(true);119 expect(client?.isListed).toBeDefined();
120 if (client?.isListed === undefined) return;
121 expect(
122 await Effect.runPromise(client.isListed({ userId: "test-user", organizationId: "test-org" })),
123 ).toBe(false);
119 });124 });
120 125
121 it("covers the reviewed consumer bundle", () => {126 it("covers the reviewed consumer bundle", () => {
apps/cloud/src/engine/first-party-oauth-clients.ts+12−6
@@ -7,9 +7,14 @@ import { · 6 unmodified lines
7import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";7import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
8import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk";8import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk";
9 9
10import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing";
11import { POSTHOG_INGEST_HOST } from "../edge/passthrough";
12
10/** Cloud secret bindings that enable host-operated OAuth clients. A provider13/** Cloud secret bindings that enable host-operated OAuth clients. A provider
11 * is absent unless both values in its pair are present. */14 * is absent unless both values in its pair are present. */
12export interface FirstPartyOAuthClientEnv {15export interface FirstPartyOAuthClientEnv {
16 readonly VITE_PUBLIC_POSTHOG_KEY?: string;
17 readonly VITE_PUBLIC_POSTHOG_HOST?: string;
13 readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string;18 readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string;
14 readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string;19 readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string;
15 readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string;20 readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string;
@@ -286,12 +291,13 @@ export const firstPartyOAuthClientsFor = ( · 270 unmodified lines
286 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",291 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
287 tokenUrl: "https://oauth2.googleapis.com/token",292 tokenUrl: "https://oauth2.googleapis.com/token",
288 allowedScopes: GOOGLE_ALLOWED_SCOPES,293 allowedScopes: GOOGLE_ALLOWED_SCOPES,
289 // Withdrawn from the connect picker: no new connection is offered the294 // Offer the app only to the review rollout. Resolution remains available
290 // Executor-owned Google app. The entry stays declared on purpose — every295 // for existing connections regardless of the current listing decision.
291 // connection already minted against it keeps refreshing and reconnecting296 isListed: makeGoogleOAuthListing({
292 // through it. Deleting this block, or unsetting the env vars, would strand297 projectKey: env.VITE_PUBLIC_POSTHOG_KEY,
293 // those connections instead.298 host: env.VITE_PUBLIC_POSTHOG_HOST ?? `https://${POSTHOG_INGEST_HOST}`,
294 unlisted: true,299 fetch: (input, init) => globalThis.fetch(input, init),
300 }),
295 }),301 }),
296 ...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, {302 ...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, {
297 name: "hubspot",303 name: "hubspot",
packages/core/sdk/src/oauth-client.ts+8−0
@@ -243,6 +243,14 @@ export interface FirstPartyOAuthClientConfig { · 242 unmodified lines
243 * instead removes the config entry itself, which strands every existing243 * instead removes the config entry itself, which strands every existing
244 * connection on a client the host can no longer resolve. */244 * connection on a client the host can no longer resolve. */
245 readonly unlisted?: boolean;245 readonly unlisted?: boolean;
246 /** Optional host policy for offering this app to the acting user. Evaluated
247 * on each listing; false withholds the app without disrupting existing
248 * connections. `unlisted: true` always withholds it. This controls discovery,
249 * not authorization to resolve an already-known first-party client slug. */
250 readonly isListed?: (context: {
251 readonly userId: string | null;
252 readonly organizationId: string;
253 }) => Effect.Effect<boolean>;
246 /** OAuth scopes this deployment permits the app to request. Omit to allow254 /** OAuth scopes this deployment permits the app to request. Omit to allow
247 * every scope declared by a matching integration. For declared scopes,255 * every scope declared by a matching integration. For declared scopes,
248 * start and completion fail unless every requested scope belongs to this256 * start and completion fail unless every requested scope belongs to this
packages/core/sdk/src/oauth-first-party.test.ts+46−1
@@ -324,13 +324,58 @@ describe("first-party oauth clients", () => { · 323 unmodified lines
324 ),324 ),
325 );325 );
326 326
327 it.effect("listing policies use the acting identity and re-evaluate within an executor", () =>
328 Effect.scoped(
329 Effect.gen(function* () {
330 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
331 let enabled = true;
332 const { executor } = yield* makeTestWorkspaceHarness({
333 plugins,
334 subject: "review-user",
335 tenant: "review-org",
336 firstPartyOAuthClients: [
337 {
338 ...firstPartyClientFor(server),
339 isListed: (context) =>
340 Effect.sync(() => {
341 expect(context).toEqual({ userId: "review-user", organizationId: "review-org" });
342 return enabled;
343 }),
344 },
345 ],
346 });
347 expect((yield* executor.oauth.listClients()).map((client) => String(client.slug))).toEqual([
348 "first-party:acme",
349 ]);
350 enabled = false;
351 expect(yield* executor.oauth.listClients()).toEqual([]);
352 yield* executor.acme.seed();
353 const started = yield* executor.oauth.start({
354 owner: "org",
355 client: FIRST_PARTY,
356 clientOwner: "org",
357 name: ConnectionName.make("main"),
358 integration: INTEG,
359 template: TEMPLATE,
360 });
361 expect(started.status).toBe("redirect");
362 }),
363 ),
364 );
365
327 it.effect("an unlisted first-party app is withheld from listings but still refreshes", () =>366 it.effect("an unlisted first-party app is withheld from listings but still refreshes", () =>
328 Effect.scoped(367 Effect.scoped(
329 Effect.gen(function* () {368 Effect.gen(function* () {
330 const server = yield* serveOAuthTestServer({ scopes: ["read"] });369 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
331 const harness = yield* makeTestWorkspaceHarness({370 const harness = yield* makeTestWorkspaceHarness({
332 plugins,371 plugins,
333 firstPartyOAuthClients: [{ ...firstPartyClientFor(server), unlisted: true }],372 firstPartyOAuthClients: [
373 {
374 ...firstPartyClientFor(server),
375 unlisted: true,
376 isListed: () => Effect.succeed(true),
377 },
378 ],
334 });379 });
335 const { executor, config } = harness;380 const { executor, config } = harness;
336 yield* executor.acme.seed();381 yield* executor.acme.seed();
packages/core/sdk/src/oauth-service.ts+61−55
@@ -1477,19 +1477,25 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1476 unmodified lines
1477 // tenant's org rows + this subject's own user rows, so no explicit filter is1477 // tenant's org rows + this subject's own user rows, so no explicit filter is
1478 // needed. The `client_secret` column is deliberately never projected.1478 // needed. The `client_secret` column is deliberately never projected.
1479 // -----------------------------------------------------------------------1479 // -----------------------------------------------------------------------
1480 const listClients = (): Effect.Effect<readonly OAuthClientSummary[], StorageFailure> => {1480 const listClients = (): Effect.Effect<readonly OAuthClientSummary[], StorageFailure> =>
1481 // First-party apps lead the list: config-resolved, visible to every caller,1481 Effect.gen(function* () {
1482 // and projected exactly like stored rows — clientId only, never the secret.1482 // First-party apps lead the list: config-resolved, filtered by host policy,
1483 // Owner is reported as "org" (the widest visibility the summary shape can1483 // and projected exactly like stored rows — clientId only, never the secret.
1484 // express); the flow itself ignores owner for first-party slugs.1484 // Owner is reported as "org" (the widest visibility the summary shape can
1485 //1485 // express); the flow itself ignores owner for first-party slugs.
1486 // `unlisted` apps are withheld here and ONLY here: listing is what offers an1486 //
1487 // app for a NEW connection, so this is the whole of "stop offering it".1487 // `unlisted` apps are withheld here and ONLY here: listing is what offers an
1488 // `loadClient` still resolves them, keeping every existing connection's1488 // app for a NEW connection, so this is the whole of "stop offering it".
1489 // refresh and reconnect intact.1489 // `loadClient` still resolves them, keeping every existing connection's
1490 const firstPartySummaries: readonly OAuthClientSummary[] = [...firstPartyBySlug.values()]1490 // refresh and reconnect intact.
1491 .filter((config) => config.unlisted !== true)1491 const listed = yield* Effect.filter([...firstPartyBySlug.values()], (config) =>
1492 .map((config) => ({1492 config.unlisted === true
1493 ? Effect.succeed(false)
1494 : config.isListed === undefined
1495 ? Effect.succeed(true)
1496 : config.isListed({ userId: deps.subject, organizationId: deps.tenant }),
1497 );
1498 const firstPartySummaries: readonly OAuthClientSummary[] = listed.map((config) => ({
1493 owner: "org",1499 owner: "org",
1494 slug: firstPartyOAuthClientSlug(config.name),1500 slug: firstPartyOAuthClientSlug(config.name),
1495 grant: "authorization_code",1501 grant: "authorization_code",
@@ -1506,49 +1512,49 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 10 unmodified lines
1506 ...(config.allowedScopes !== undefined ? { allowedScopes: config.allowedScopes } : {}),1512 ...(config.allowedScopes !== undefined ? { allowedScopes: config.allowedScopes } : {}),
1507 },1513 },
1508 }));1514 }));
1509 return deps.fuma1515 return yield* deps.fuma
1510 .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {}))1516 .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {}))
1511 .pipe(1517 .pipe(
1512 Effect.flatMap((rows) =>1518 Effect.flatMap((rows) =>
1513 Effect.forEach(rows, (row) => {1519 Effect.forEach(rows, (row) => {
1514 const grant = parseGrant(row.grant);1520 const grant = parseGrant(row.grant);
1515 // EXPLICIT — a row with an unknown grant is corrupt; surface it1521 // EXPLICIT — a row with an unknown grant is corrupt; surface it
1516 // loudly rather than silently displaying it as authorization_code.1522 // loudly rather than silently displaying it as authorization_code.
1517 if (grant === null) {1523 if (grant === null) {
1518 return Effect.fail(1524 return Effect.fail(
1519 new StorageError({1525 new StorageError({
1520 message: `oauth_client ${String(row.slug)} has an unknown grant: ${String(row.grant)}`,1526 message: `oauth_client ${String(row.slug)} has an unknown grant: ${String(row.grant)}`,
1521 cause: undefined,1527 cause: undefined,
1522 }),1528 }),
1523 );1529 );
1524 }1530 }
1525 const tokenEndpointAuthMethod = parseStoredTokenEndpointAuthMethod(1531 const tokenEndpointAuthMethod = parseStoredTokenEndpointAuthMethod(
1526 row.token_endpoint_auth_method,1532 row.token_endpoint_auth_method,
1527 );
1528 if (tokenEndpointAuthMethod === null) {
1529 return Effect.fail(
1530 new StorageError({
1531 message: `oauth_client ${String(row.slug)} has an unknown token endpoint auth method: ${String(row.token_endpoint_auth_method)}`,
1532 cause: undefined,
1533 }),
1534 );1533 );
1535 }1534 if (tokenEndpointAuthMethod === null) {
1536 return Effect.succeed({1535 return Effect.fail(
1537 owner: String(row.owner) as Owner,1536 new StorageError({
1538 slug: OAuthClientSlug.make(String(row.slug)),1537 message: `oauth_client ${String(row.slug)} has an unknown token endpoint auth method: ${String(row.token_endpoint_auth_method)}`,
1539 grant,1538 cause: undefined,
1540 authorizationUrl: String(row.authorization_url),1539 }),
1541 tokenUrl: String(row.token_url),1540 );
1542 resource: row.resource == null ? null : String(row.resource),1541 }
1543 clientId: String(row.client_id),1542 return Effect.succeed({
1544 ...(tokenEndpointAuthMethod === undefined ? {} : { tokenEndpointAuthMethod }),1543 owner: String(row.owner) as Owner,
1545 origin: parseOAuthClientOrigin(row),1544 slug: OAuthClientSlug.make(String(row.slug)),
1546 } satisfies OAuthClientSummary);1545 grant,
1547 }),1546 authorizationUrl: String(row.authorization_url),
1548 ),1547 tokenUrl: String(row.token_url),
1549 Effect.map((stored) => [...firstPartySummaries, ...stored]),1548 resource: row.resource == null ? null : String(row.resource),
1550 );1549 clientId: String(row.client_id),
1551 };1550 ...(tokenEndpointAuthMethod === undefined ? {} : { tokenEndpointAuthMethod }),
1551 origin: parseOAuthClientOrigin(row),
1552 } satisfies OAuthClientSummary);
1553 }),
1554 ),
1555 Effect.map((stored) => [...firstPartySummaries, ...stored]),
1556 );
1557 });
1552 1558
1553 // -----------------------------------------------------------------------1559 // -----------------------------------------------------------------------
1554 // Load an oauth_client row by (owner, slug).1560 // Load an oauth_client row by (owner, slug).

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t