From af34005e2a88be1fcc4c78df822099710013c112 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:00:17 +0000 Subject: [PATCH 1/9] Restrict workspace-level settings to admins --- .changeset/workspace-writes-admin-only.md | 21 ++ .../api/protected-api-key-auth.node.test.ts | 3 + .../src/api/protected-jwt-auth.node.test.ts | 3 + apps/cloud/src/auth/organization.ts | 9 +- apps/cloud/src/auth/workos-auth-provider.ts | 3 + apps/cloud/src/mcp/session-durable-object.ts | 16 +- apps/host-selfhost/src/admin/require-admin.ts | 6 +- apps/host-selfhost/src/auth/identity.ts | 24 +- apps/host-selfhost/src/mcp/auth.ts | 20 ++ apps/host-selfhost/src/multi-user.test.ts | 23 +- packages/core/api/src/connections/api.ts | 6 +- packages/core/api/src/integrations/api.ts | 7 +- packages/core/api/src/oauth/api.ts | 9 +- packages/core/api/src/policies/api.ts | 14 +- .../src/server/execution-stack-middleware.ts | 3 + .../core/api/src/server/execution-stack.ts | 20 +- packages/core/api/src/server/identity.ts | 11 + packages/core/api/src/server/mcp-build.ts | 7 +- .../core/api/src/server/scoped-executor.ts | 9 +- packages/core/sdk/src/errors.ts | 26 ++ packages/core/sdk/src/executor.ts | 105 +++++-- packages/core/sdk/src/index.ts | 1 + packages/core/sdk/src/oauth-client.ts | 13 +- packages/core/sdk/src/oauth-service.ts | 25 +- packages/core/sdk/src/org-writes.test.ts | 258 ++++++++++++++++++ packages/core/sdk/src/plugin.ts | 35 ++- packages/core/sdk/src/shared.ts | 1 + packages/core/sdk/src/test-config.ts | 5 + .../src/mcp/agent-session-durable-object.ts | 8 + packages/hosts/mcp/src/seams.ts | 5 + packages/plugins/graphql/src/api/group.ts | 7 +- packages/plugins/graphql/src/sdk/plugin.ts | 3 +- packages/plugins/mcp/src/api/group.ts | 8 +- packages/plugins/mcp/src/sdk/plugin.ts | 16 +- packages/plugins/openapi/src/api/group.ts | 3 + packages/plugins/openapi/src/sdk/plugin.ts | 9 +- 36 files changed, 661 insertions(+), 81 deletions(-) create mode 100644 .changeset/workspace-writes-admin-only.md create mode 100644 packages/core/sdk/src/org-writes.test.ts diff --git a/.changeset/workspace-writes-admin-only.md b/.changeset/workspace-writes-admin-only.md new file mode 100644 index 000000000..2b58111a3 --- /dev/null +++ b/.changeset/workspace-writes-admin-only.md @@ -0,0 +1,21 @@ +--- +"@executor-js/sdk": minor +"@executor-js/plugin-graphql": minor +"@executor-js/plugin-mcp": minor +"@executor-js/plugin-openapi": minor +--- + +**Workspace-level settings are now admin-only** + +The executor binding gains `orgWrites: "allowed" | "denied"`. Hosts derive it +from the acting member's role (cloud: WorkOS membership role; self-host: +Better Auth org membership role), and a plain member's binding refuses every +user-intent workspace-level mutation with the new `OrgWriteDeniedError` +(HTTP 403): org-owned tool policies, workspace-shared connections, org OAuth +apps and org connect flows, and integration-catalog changes (add, update, +remove, health check). + +Using workspace resources is unchanged for members: reads, tool execution over +shared connections, and the operational writes those imply (token refresh, +tool-catalog re-sync, config-rewrite healing) keep working. Hosts with no role +model (local, the CLI, embedded SDK use) default to `"allowed"`. diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index 3ba01a61b..edf8cdc42 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -98,6 +98,9 @@ describe("protected API key auth", () => { name: null, avatarUrl: null, roles: [], + // The stub membership carries no role slug — normalization FAILS + // CLOSED to plain member, so the executor binds workspace writes off. + orgRole: "member", }); }), ); diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts index dbf35e1c2..afd67cd75 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -119,6 +119,9 @@ describe("protected JWT (device-login) auth", () => { name: null, avatarUrl: null, roles: [], + // The stub membership carries no role slug — normalization FAILS + // CLOSED to plain member, so the executor binds workspace writes off. + orgRole: "member", }); }), ); diff --git a/apps/cloud/src/auth/organization.ts b/apps/cloud/src/auth/organization.ts index 073dfacb3..5aceb2ab1 100644 --- a/apps/cloud/src/auth/organization.ts +++ b/apps/cloud/src/auth/organization.ts @@ -88,7 +88,14 @@ export const authorizeOrganization = (userId: string, organizationId: string) => ); if (!active) return null; - return yield* resolveOrganization(organizationId); + const org = yield* resolveOrganization(organizationId); + // The membership row already names the caller's role — surface it + // normalized so identity resolution can bind the executor's workspace + // write permission without a second WorkOS call. WorkOS issues + // `admin` / `member`; anything unrecognized stays a plain member. + const roleSlug = (active as { readonly role?: { readonly slug?: string } }).role?.slug; + const memberRole: "admin" | "member" = roleSlug === "admin" ? "admin" : "member"; + return { ...org, memberRole }; }); // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 95742038f..40d947f5c 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -154,6 +154,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => name: null, avatarUrl: null, roles: [], + orgRole: org.memberRole, } satisfies Principal; }); @@ -253,6 +254,7 @@ export const resolveBearerAuth = ( name: null, avatarUrl: null, roles: [], + orgRole: org.memberRole, } satisfies Principal; }); @@ -326,6 +328,7 @@ export const resolveSessionPrincipal = (request: Request) => name: sealedSessionDisplayName(session), avatarUrl: session.avatarUrl ?? null, roles: [], + orgRole: org.memberRole, } satisfies Principal; }); diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index c272e5026..aa4bdce3f 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -58,7 +58,7 @@ import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execu // `SessionAuthLive` instead.) import { CoreSharedServices } from "../auth/workos"; import { UserStoreService } from "../auth/context"; -import { resolveOrganization } from "../auth/organization"; +import { authorizeOrganization } from "../auth/organization"; import { DbService, combinedSchema, @@ -211,7 +211,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { const dbHandle = makeEphemeralDb(); return Effect.gen(function* () { - const org = yield* resolveOrganization(token.organizationId); + // Membership was already verified by the worker's per-request auth; this + // re-check is where the session learns the member's WORKSPACE ROLE, so + // the executor it builds can bind `orgWrites` (a member may use org + // connections but not configure workspace-level state). The role is + // baked into the persisted meta: a demotion applies from the next + // session init, not mid-session. + const org = yield* authorizeOrganization(token.userId, token.organizationId); if (!org) { return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); } @@ -220,6 +226,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase +export const isPrivileged = (role: string): boolean => role .split(",") .map((part) => part.trim()) diff --git a/apps/host-selfhost/src/auth/identity.ts b/apps/host-selfhost/src/auth/identity.ts index 932e90230..0a1abcb7d 100644 --- a/apps/host-selfhost/src/auth/identity.ts +++ b/apps/host-selfhost/src/auth/identity.ts @@ -2,6 +2,7 @@ import { Effect, Layer } from "effect"; import { IdentityProvider, Unauthorized } from "@executor-js/api/server"; +import { isPrivileged } from "../admin/require-admin"; import { BetterAuth } from "./better-auth"; // --------------------------------------------------------------------------- @@ -49,13 +50,18 @@ export const betterAuthIdentityLayer: Layer.Layer auth.api.getSession({ headers: request.headers }), ); + // The credential shape that resolved the session — the SAME headers + // are what the membership-role lookup below must present. + let sessionHeaders: Headers | Record = request.headers; if (!resolved) { const token = bearerToken(request.headers); if (token) { + const apiKeyHeaders = { "x-api-key": token }; resolved = yield* Effect.tryPromise({ - try: () => auth.api.getSession({ headers: { "x-api-key": token } }), + try: () => auth.api.getSession({ headers: apiKeyHeaders }), catch: () => "api-key session lookup failed", }).pipe(Effect.orElseSucceed(() => null)); + sessionHeaders = apiKeyHeaders; } } // No session resolved from any credential shape -> unauthenticated. @@ -66,6 +72,21 @@ export const betterAuthIdentityLayer: Layer.Layer + auth.api.getActiveMemberRole({ + headers: sessionHeaders, + query: { organizationId: resolvedOrganizationId }, + }), + ).pipe(Effect.orElseSucceed(() => null)); + const orgRole = + membership && isPrivileged(membership.role) + ? ("admin" as const) + : ("member" as const); return { kind: "member" as const, accountId: resolved.user.id, @@ -79,6 +100,7 @@ export const betterAuthIdentityLayer: Layer.Layer role.trim()) .filter((role) => role.length > 0), + orgRole, }; }), }); diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts index abd07ba38..ddfd24c49 100644 --- a/apps/host-selfhost/src/mcp/auth.ts +++ b/apps/host-selfhost/src/mcp/auth.ts @@ -11,6 +11,7 @@ import { type Principal, } from "@executor-js/host-mcp"; +import { isPrivileged } from "../admin/require-admin"; import { BetterAuth } from "../auth/better-auth"; import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org-path"; @@ -205,6 +206,24 @@ export const selfHostMcpAuth: Layer.Layer context.internalAdapter.findUserById(userId)); if (!user) return null; + // The workspace role, read from the INSTANCE org's membership row + // (an OAuth token carries no session, so the header-based + // `getActiveMemberRole` gate is out of reach — the adapter query + // answers the same question against the same table). FAIL CLOSED to + // "member": an infra fault demotes rather than escalates. + const membership = yield* Effect.promise(() => + context.adapter.findOne<{ readonly role?: string | null }>({ + model: "member", + where: [ + { field: "userId", value: userId }, + { field: "organizationId", value: organizationId }, + ], + }), + ).pipe(Effect.orElseSucceed(() => null)); + const orgRole = + membership?.role != null && isPrivileged(membership.role) + ? ("admin" as const) + : ("member" as const); return { accountId: user.id, // Single-org self-host: OAuth tokens carry no active org, so pin to @@ -216,6 +235,7 @@ export const selfHostMcpAuth: Layer.Layer => { - const inviteCode = await mintInviteCode(handler); +const signUp = async (email: string, role: "admin" | "member" = "member"): Promise => { + const inviteCode = await mintInviteCode(handler, role); const res = await handler( new Request(`${BASE}/api/auth/sign-up/email`, { method: "POST", @@ -136,7 +136,9 @@ const runCode = async (token: string, code: string) => { }; test("multiple accounts share one org but isolate per-user connections", async () => { - const alice = await signUp("alice@multi.test"); + // Workspace-level setup (the catalog, org-shared connections) is admin-only, + // so Alice joins as an admin; Bob stays a plain member. + const alice = await signUp("alice@multi.test", "admin"); const bob = await signUp("bob@multi.test"); // Same single org for both members. @@ -147,6 +149,21 @@ test("multiple accounts share one org but isolate per-user connections", async ( // The integration is tenant-scoped; register it once. expect((await addIntegration(alice, "tiny")).status).toBe(200); + // A plain member cannot register integrations or mint workspace-shared + // connections — 403 from the executor's workspace-write gate. + expect((await addIntegration(bob, "tiny2")).status).toBe(403); + expect( + ( + await createConnection(bob, { + owner: "org", + name: "bob-shared", + integration: "tiny", + template: "bearer", + value: "bob-token", + }) + ).status, + ).toBe(403); + // Alice attaches a USER-owned connection (private to her) and an ORG-owned // connection (shared across the tenant). expect( diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index c93e983cb..cd7bb7698 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -22,6 +22,7 @@ import { IntegrationSlug, InternalError, InvalidConnectionInputError, + OrgWriteDeniedError, OAuthClientSlug, Owner, ProviderItemId, @@ -195,6 +196,7 @@ export const ConnectionsApi = HttpApiGroup.make("connections") IntegrationNotFound, CredentialProviderNotRegistered, InvalidConnectionInput, + OrgWriteDeniedError, ], }), ) @@ -210,14 +212,14 @@ export const ConnectionsApi = HttpApiGroup.make("connections") params: ConnectionParams, payload: UpdateConnectionPayload, success: ConnectionResponse, - error: [InternalError, ConnectionNotFound], + error: [InternalError, ConnectionNotFound, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.delete("remove", "/connections/:owner/:integration/:name", { params: ConnectionParams, success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, ConnectionNotFound], + error: [InternalError, ConnectionNotFound, OrgWriteDeniedError], }), ) .add( diff --git a/packages/core/api/src/integrations/api.ts b/packages/core/api/src/integrations/api.ts index 6700c7a31..d5140d2dc 100644 --- a/packages/core/api/src/integrations/api.ts +++ b/packages/core/api/src/integrations/api.ts @@ -19,6 +19,7 @@ import { IntegrationRemovalNotAllowedError, IntegrationSlug, InternalError, + OrgWriteDeniedError, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- @@ -138,14 +139,14 @@ export const IntegrationsApi = HttpApiGroup.make("integrations") params: IntegrationParams, payload: UpdateIntegrationPayload, success: IntegrationResponse, - error: [InternalError, IntegrationNotFound], + error: [InternalError, IntegrationNotFound, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.delete("remove", "/integrations/:slug", { params: IntegrationParams, success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, IntegrationRemovalNotAllowed], + error: [InternalError, IntegrationRemovalNotAllowed, OrgWriteDeniedError], }), ) .add( @@ -178,6 +179,6 @@ export const IntegrationsApi = HttpApiGroup.make("integrations") params: IntegrationParams, payload: SetHealthCheckPayload, success: Schema.Struct({ ok: Schema.Boolean }), - error: [InternalError, IntegrationNotFound], + error: [InternalError, IntegrationNotFound, OrgWriteDeniedError], }), ); diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 96e76a26c..f00300811 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -28,6 +28,7 @@ import { OAuthSessionNotFoundError, OAuthStartError, OAuthState, + OrgWriteDeniedError, Owner, ProviderKey, } from "@executor-js/sdk/shared"; @@ -275,14 +276,14 @@ export const OAuthApi = HttpApiGroup.make("oauth") HttpApiEndpoint.post("createClient", "/oauth/clients", { payload: CreateClientPayload, success: CreateClientResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.post("registerDynamic", "/oauth/clients/register-dynamic", { payload: RegisterDynamicPayload, success: RegisterDynamicResponse, - error: [InternalError, OAuthRegisterDynamic], + error: [InternalError, OAuthRegisterDynamic, OrgWriteDeniedError], }), ) .add( @@ -296,14 +297,14 @@ export const OAuthApi = HttpApiGroup.make("oauth") params: RemoveClientParams, payload: RemoveClientPayload, success: RemoveClientResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( HttpApiEndpoint.post("start", "/oauth/start", { payload: StartPayload, success: StartResponse, - error: [InternalError, OAuthStart], + error: [InternalError, OAuthStart, OrgWriteDeniedError], }), ) .add( diff --git a/packages/core/api/src/policies/api.ts b/packages/core/api/src/policies/api.ts index a3b9f76de..5f7915265 100644 --- a/packages/core/api/src/policies/api.ts +++ b/packages/core/api/src/policies/api.ts @@ -8,7 +8,13 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { InternalError, Owner, PolicyId, ToolPolicyActionSchema } from "@executor-js/sdk/shared"; +import { + InternalError, + OrgWriteDeniedError, + Owner, + PolicyId, + ToolPolicyActionSchema, +} from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // Params @@ -63,7 +69,7 @@ export const PoliciesApi = HttpApiGroup.make("policies") HttpApiEndpoint.post("create", "/policies", { payload: CreateToolPolicyPayload, success: ToolPolicyResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( @@ -71,7 +77,7 @@ export const PoliciesApi = HttpApiGroup.make("policies") params: PolicyParams, payload: UpdateToolPolicyPayload, success: ToolPolicyResponse, - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ) .add( @@ -79,6 +85,6 @@ export const PoliciesApi = HttpApiGroup.make("policies") params: PolicyParams, payload: RemoveToolPolicyPayload, success: Schema.Struct({ removed: Schema.Boolean }), - error: InternalError, + error: [InternalError, OrgWriteDeniedError], }), ); diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts index b3e6749b8..db64b2d57 100644 --- a/packages/core/api/src/server/execution-stack-middleware.ts +++ b/packages/core/api/src/server/execution-stack-middleware.ts @@ -239,6 +239,9 @@ export const makeExecutionStackMiddleware = < resolved.accountId, resolved.organizationId, resolved.organizationName, + // A plain member binds with workspace writes denied; an admin — + // or a host with no role model (`orgRole` absent) — binds allowed. + { orgWrites: resolved.orgRole === "member" ? "denied" : "allowed" }, ).pipe( Effect.provide(options.stackLayer), Effect.provideService(RequestWebOrigin, { diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts index d951f3b81..dd1e2bdf3 100644 --- a/packages/core/api/src/server/execution-stack.ts +++ b/packages/core/api/src/server/execution-stack.ts @@ -112,7 +112,12 @@ export const makeExecutionStack = < accountId: string, organizationId: string, organizationName: string, - options?: { readonly mcpResource?: McpResource }, + options?: { + readonly mcpResource?: McpResource; + /** Workspace-settings permission for this binding (see + * `ExecutorConfig.orgWrites`), derived from the acting member's role. */ + readonly orgWrites?: "allowed" | "denied"; + }, ): Effect.Effect< { readonly executor: Executor; readonly engine: ExecutionEngine }, StorageFailure, @@ -123,10 +128,17 @@ export const makeExecutionStack = < accountId, organizationId, organizationName, - { plugins: { mcpResource: options?.mcpResource } }, + { + plugins: { mcpResource: options?.mcpResource }, + ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), + }, + ).pipe(Effect.withSpan("executor.stack.scoped_executor")); + const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe( + Effect.withSpan("executor.stack.code_executor"), + ); + const { decorate } = yield* EngineDecorator.asEffect().pipe( + Effect.withSpan("executor.stack.decorator"), ); - const codeExecutor = yield* CodeExecutorProvider.asEffect(); - const { decorate } = yield* EngineDecorator.asEffect(); const engine = yield* Effect.sync(() => decorate( createExecutionEngine({ executor, codeExecutor }), diff --git a/packages/core/api/src/server/identity.ts b/packages/core/api/src/server/identity.ts index 04e4f2310..ecc232d17 100644 --- a/packages/core/api/src/server/identity.ts +++ b/packages/core/api/src/server/identity.ts @@ -48,6 +48,17 @@ export interface Principal { readonly name: string | null; readonly avatarUrl: string | null; readonly roles: readonly string[]; + /** + * The member's NORMALIZED workspace role, when the host resolves one: + * `"admin"` may configure workspace-level state (org-owned rows, the + * integration catalog), `"member"` may only use it. Cloud maps its WorkOS + * membership role (`admin` / `member`); self-host maps Better Auth's org + * membership role (`owner` and `admin` → `"admin"`). ABSENT means the host + * has no role model (local's single user, test fakes) and the middleware + * binds the executor with workspace writes allowed — hosts that DO + * distinguish roles must always set it. + */ + readonly orgRole?: "admin" | "member"; } /** diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 3b9302fac..e9f4bed35 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -48,7 +48,12 @@ export const makeMcpBuildServer = principal.accountId, principal.organizationId, principal.organizationName, - { mcpResource: options?.resource }, + { + mcpResource: options?.resource, + // A plain member binds with workspace writes denied; an admin — or + // a host with no role model (`orgRole` absent) — binds allowed. + orgWrites: principal.orgRole === "member" ? "denied" : "allowed", + }, ).pipe(Effect.withSpan("mcp.execution_stack.build")); // Read inside the provided boundary: `webBaseUrl` is a host seam, and // hosts that can't know their public URL at boot leave it unset — in diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 9e6013d23..36344e9cc 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -236,7 +236,13 @@ export const makeScopedExecutor = < // `EngineStackIdentity` (the engine decorator still wants it); not part of the // v2 executor binding, which is `{ tenant, subject }` only. _organizationName: string, - options?: { readonly plugins?: PluginsProviderContext }, + options?: { + readonly plugins?: PluginsProviderContext; + /** Workspace-settings permission for this binding (see + * `ExecutorConfig.orgWrites`). Hosts derive it from the acting member's + * role; omitted -> allowed (hosts with no role model). */ + readonly orgWrites?: "allowed" | "denied"; + }, ): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => Effect.gen(function* () { const { db, blobs } = yield* DbProvider.asEffect(); @@ -297,6 +303,7 @@ export const makeScopedExecutor = < fetch: hostedFetch, onIntegrationChange: config.onIntegrationChange, onElicitation: "accept-all", + ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), redirectUri, oauthCallbackStateOrgSlug: orgSlug, firstPartyOAuthClients: config.firstPartyOAuthClients, diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index faf5e44a2..0b44f6ea6 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -142,6 +142,32 @@ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass()( + "OrgWriteDeniedError", + {}, + { httpApiStatus: 403 }, + ) + implements UserActionableError +{ + readonly __executorUserActionable = true; + readonly code = "org_write_denied"; + + override get message(): string { + return "Workspace-level changes require a workspace admin."; + } + + get userMessage(): string { + return this.message; + } +} + export class ConnectionNotFoundError extends Schema.TaggedErrorClass()( "ConnectionNotFoundError", { diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 2ce9a3922..6a47f4cb2 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -72,6 +72,7 @@ import { InvalidConnectionInputError, IntegrationRemovalNotAllowedError, NoHandlerError, + OrgWriteDeniedError, PluginNotLoadedError, ToolBlockedError, ToolInvocationError, @@ -287,10 +288,13 @@ export type Executor = { readonly update: ( slug: IntegrationSlug, patch: { readonly name?: string; readonly description?: string }, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( slug: IntegrationSlug, - ) => Effect.Effect; + ) => Effect.Effect< + void, + IntegrationRemovalNotAllowedError | OrgWriteDeniedError | StorageFailure + >; readonly detect: ( url: string, ) => Effect.Effect; @@ -315,7 +319,7 @@ export type Executor = { readonly set: ( slug: IntegrationSlug, spec: HealthCheckSpec | null, - ) => Effect.Effect; + ) => Effect.Effect; }; }; @@ -327,6 +331,7 @@ export type Executor = { | IntegrationNotFoundError | CredentialProviderNotRegisteredError | InvalidConnectionInputError + | OrgWriteDeniedError | StorageFailure >; readonly list: (filter?: { @@ -339,10 +344,10 @@ export type Executor = { readonly update: ( ref: ConnectionRef, input: UpdateConnectionInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( ref: ConnectionRef, - ) => Effect.Effect; + ) => Effect.Effect; readonly refresh: ( ref: ConnectionRef, ) => Effect.Effect< @@ -389,9 +394,15 @@ export type Executor = { readonly policies: { readonly list: () => Effect.Effect; - readonly create: (input: CreateToolPolicyInput) => Effect.Effect; - readonly update: (input: UpdateToolPolicyInput) => Effect.Effect; - readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect; + readonly create: ( + input: CreateToolPolicyInput, + ) => Effect.Effect; + readonly update: ( + input: UpdateToolPolicyInput, + ) => Effect.Effect; + readonly remove: ( + input: RemoveToolPolicyInput, + ) => Effect.Effect; readonly resolve: (address: ToolAddress) => Effect.Effect; }; @@ -707,6 +718,24 @@ export interface ExecutorConfig => + config.orgWrites === "denied" && (owner === undefined || owner === "org") + ? Effect.fail(new OrgWriteDeniedError()) + : Effect.void; + // Built-in core-tools plugin: agent-facing static tools over the v2 surface. const plugins: readonly AnyPlugin[] = config.coreTools ? ([ @@ -2559,7 +2599,7 @@ export const createExecutor = => + ): Effect.Effect => transaction( Effect.gen(function* () { const now = new Date(); @@ -2580,6 +2620,10 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(); const now = new Date(); const set: Record = { updated_at: now }; if (patch.name !== undefined) set.name = patch.name; @@ -2633,7 +2678,7 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { const existing = yield* findIntegrationRow(slug); if (!existing) return yield* new IntegrationNotFoundError({ slug }); @@ -2642,9 +2687,13 @@ export const createExecutor = => + ): Effect.Effect< + void, + IntegrationRemovalNotAllowedError | OrgWriteDeniedError | StorageFailure + > => transaction( Effect.gen(function* () { + yield* guardOrgWrite(); const existing = yield* findIntegrationRow(slug); if (!existing) return null; if (!existing.can_remove) { @@ -2732,8 +2781,9 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(); const row = yield* findIntegrationRow(slug); if (!row) return yield* new IntegrationNotFoundError({ slug }); yield* core.updateMany("integration", { @@ -2973,9 +3023,11 @@ export const createExecutor = => Effect.gen(function* () { + yield* guardOrgWrite(input.owner); const name = connectionIdentifier(String(input.name)); // Typed (not StorageError) so the HTTP edge can answer 400 with the // reason instead of an opaque 500 — callers can act on it. @@ -3339,8 +3391,9 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(ref.owner); const row = yield* findConnectionRow(ref); if (!row) { return yield* new ConnectionNotFoundError({ @@ -3367,9 +3420,10 @@ export const createExecutor = => + ): Effect.Effect => transaction( Effect.gen(function* () { + yield* guardOrgWrite(ref.owner); const row = yield* findConnectionRow(ref); if (!row) { return yield* new ConnectionNotFoundError({ @@ -4218,8 +4272,9 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(input.owner); if (!isValidPattern(input.pattern)) { return yield* new StorageError({ message: `Invalid tool policy pattern: ${input.pattern}`, @@ -4265,8 +4320,9 @@ export const createExecutor = => + ): Effect.Effect => Effect.gen(function* () { + yield* guardOrgWrite(input.owner); if (input.pattern !== undefined && !isValidPattern(input.pattern)) { return yield* new StorageError({ message: `Invalid tool policy pattern: ${input.pattern}`, @@ -4290,10 +4346,16 @@ export const createExecutor = => - core.deleteMany("tool_policy", { - where: (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)), - }); + const policiesRemove = ( + input: RemoveToolPolicyInput, + ): Effect.Effect => + guardOrgWrite(input.owner).pipe( + Effect.andThen( + core.deleteMany("tool_policy", { + where: (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)), + }), + ), + ); const policiesResolve = ( address: ToolAddress, @@ -4825,6 +4887,7 @@ export const createExecutor = ownedKeys(owner), + guardOrgWrite: (owner: Owner) => guardOrgWrite(owner), defaultWritableProvider, mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), connectionNameTaken: (ref) => findConnectionRow(ref).pipe(Effect.map((row) => row !== null)), diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index a9b5aeb1f..63f6cc462 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -69,6 +69,7 @@ export { IntegrationNotFoundError, IntegrationAlreadyExistsError, IntegrationRemovalNotAllowedError, + OrgWriteDeniedError, ConnectionNotFoundError, CredentialProviderNotRegisteredError, CredentialResolutionError, diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 2cbbbd911..955e20e01 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -2,7 +2,7 @@ import type { Effect } from "effect"; import { Schema } from "effect"; import type { Connection } from "./connection"; -import type { UserActionableError } from "./errors"; +import type { OrgWriteDeniedError, UserActionableError } from "./errors"; import type { StorageFailure } from "./fuma-runtime"; import { type AuthTemplateSlug, @@ -443,12 +443,15 @@ export class OAuthSessionNotFoundError extends Schema.TaggedErrorClass Effect.Effect; + ) => Effect.Effect; /** Mint a client via RFC 7591 Dynamic Client Registration (no pre-shared * client id/secret) and persist it as an owner-scoped `oauth_client`. */ readonly registerDynamicClient: ( input: RegisterDynamicClientInput, - ) => Effect.Effect; + ) => Effect.Effect< + OAuthClientSlug, + OAuthRegisterDynamicError | OrgWriteDeniedError | StorageFailure + >; /** All registered clients visible to the caller (their org's shared clients + * their own user clients), as metadata-only summaries — never the secret. */ readonly listClients: () => Effect.Effect; @@ -460,10 +463,10 @@ export interface OAuthService { readonly removeClient: ( owner: Owner, slug: OAuthClientSlug, - ) => Effect.Effect; + ) => Effect.Effect; readonly start: ( input: OAuthStartInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly complete: ( input: OAuthCompleteInput, ) => Effect.Effect; diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 751579979..7292d95eb 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -19,6 +19,7 @@ import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { connectionIdentifier } from "./connection-name-identifier"; import type { Connection } from "./connection"; +import type { OrgWriteDeniedError } from "./errors"; import type { IFumaClient, StorageFailure } from "./fuma-runtime"; import { StorageError } from "./fuma-runtime"; import { @@ -186,6 +187,10 @@ export interface OAuthServiceDeps { readonly owner: Owner; readonly subject: string; }; + /** Workspace-settings gate from the executor binding + * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the + * user-intent client/connect surfaces. */ + readonly guardOrgWrite: (owner: Owner) => Effect.Effect; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ readonly mintOAuthConnection: ( @@ -800,7 +805,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- const createClient = ( input: CreateOAuthClientInput, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { // The `first-party:` namespace is reserved for config-declared apps — a // stored row under it would be shadowed by (or worse, impersonate) the @@ -811,6 +816,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } + yield* deps.guardOrgWrite(input.owner); yield* validateClientEndpoints(input, deps.endpointUrlPolicy); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), @@ -894,7 +900,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // the next token refresh, prompting a reconnect (graceful degradation; this // op never cascades into connections). // ----------------------------------------------------------------------- - const removeClient = (owner: Owner, slug: OAuthClientSlug): Effect.Effect => + const removeClient = ( + owner: Owner, + slug: OAuthClientSlug, + ): Effect.Effect => Effect.gen(function* () { // Config-declared apps have no row to remove; removing one is an env // change on the host, not a storage operation. Fail loudly rather than @@ -905,6 +914,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } + yield* deps.guardOrgWrite(owner); yield* deps.fuma .use("oauth_client.delete", (db) => looseDb(db).deleteMany("oauth_client", { @@ -1092,7 +1102,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const registerDynamicClient = ( input: RegisterDynamicClientInput, - ): Effect.Effect => + ): Effect.Effect< + OAuthClientSlug, + OAuthRegisterDynamicError | OrgWriteDeniedError | StorageFailure + > => Effect.gen(function* () { const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint); // Resolved before the reuse decision: a persisted client registered with @@ -1297,8 +1310,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- const start = ( input: OAuthStartInput, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { + // Gate BEFORE any session row or upstream exchange: minting a Workspace + // connection (including a reconnect that would replace its credential) + // is a workspace-level change. + yield* deps.guardOrgWrite(input.owner); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), catch: (cause) => diff --git a/packages/core/sdk/src/org-writes.test.ts b/packages/core/sdk/src/org-writes.test.ts new file mode 100644 index 000000000..c549ea4ad --- /dev/null +++ b/packages/core/sdk/src/org-writes.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ProviderItemId, + ProviderKey, + ToolAddress, + ToolName, +} from "./ids"; +import { createExecutor } from "./executor"; +import { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestConfig } from "./testing"; + +// --------------------------------------------------------------------------- +// `ExecutorConfig.orgWrites` — the workspace-settings gate. +// +// A `"denied"` binding (a plain member) may USE workspace resources — read +// them, execute tools over org connections — but every user-intent +// workspace-level mutation refuses with `OrgWriteDeniedError`: org-owned +// policies / connections / OAuth clients, and the tenant-shared integration +// catalog. `"allowed"` (admins, and hosts with no role model) behaves exactly +// as before. +// +// The fixtures build TWO executors over ONE test database: an admin +// (default `orgWrites`) that seeds the workspace, and a member +// (`orgWrites: "denied"`) that the assertions run against. +// --------------------------------------------------------------------------- + +const memoryProvider = (): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + has: (id) => Effect.sync(() => store.has(String(id))), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((key) => ({ + id: ProviderItemId.make(key), + name: key, + })), + ), + }; +}; + +const INTEG = IntegrationSlug.make("vercel"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const demoPlugin = definePlugin(() => ({ + id: "demo" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [{ name: ToolName.make("deploy"), description: "deploy" }], + }), + invokeTool: ({ toolRow, credential }) => + Effect.succeed({ ran: toolRow.name, value: credential.value }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Vercel", + config: {}, + }), + seedFresh: () => + ctx.core.integrations.register({ + slug: IntegrationSlug.make("fresh"), + description: "Fresh", + config: {}, + }), + }), +}))(); + +const setup = () => + Effect.gen(function* () { + const config = makeTestConfig({ plugins: [demoPlugin] as const }); + const admin = yield* createExecutor(config); + const member = yield* createExecutor({ ...config, orgWrites: "denied" }); + yield* Effect.addFinalizer(() => + admin.close().pipe(Effect.andThen(member.close()), Effect.ignore), + ); + yield* admin.demo.seed(); + return { admin, member }; + }); + +const expectOrgWriteDenied = (effect: Effect.Effect) => + effect.pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toMatchObject({ _tag: "OrgWriteDeniedError" }); + }), + ); + +describe("orgWrites: denied", () => { + it.effect("refuses org tool policies but accepts user ones", () => + Effect.gen(function* () { + const { member } = yield* setup(); + yield* expectOrgWriteDenied( + member.policies.create({ owner: "org", pattern: "*", action: "block" }), + ); + const mine = yield* member.policies.create({ + owner: "user", + pattern: "*", + action: "require_approval", + }); + yield* expectOrgWriteDenied( + member.policies.update({ id: mine.id, owner: "org", action: "block" }), + ); + yield* expectOrgWriteDenied(member.policies.remove({ id: mine.id, owner: "org" })); + yield* member.policies.update({ id: mine.id, owner: "user", action: "approve" }); + yield* member.policies.remove({ id: mine.id, owner: "user" }); + }).pipe(Effect.scoped), + ); + + it.effect("refuses org connections but accepts personal ones", () => + Effect.gen(function* () { + const { admin, member } = yield* setup(); + yield* expectOrgWriteDenied( + member.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + value: "org-token", + }), + ); + const personal = yield* member.connections.create({ + owner: "user", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + value: "user-token", + }); + expect(personal.owner).toBe("user"); + + const shared = yield* admin.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + value: "org-token", + }); + const ref = { owner: shared.owner, integration: shared.integration, name: shared.name }; + yield* expectOrgWriteDenied(member.connections.update(ref, { description: "renamed" })); + yield* expectOrgWriteDenied(member.connections.remove(ref)); + }).pipe(Effect.scoped), + ); + + it.effect("still USES the workspace: reads org rows and executes org-connection tools", () => + Effect.gen(function* () { + const { admin, member } = yield* setup(); + yield* admin.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + value: "org-token", + }); + const visible = yield* member.connections.list({ owner: "org" }); + expect(visible.map((c) => String(c.name))).toContain("shared"); + const out = yield* member.execute(ToolAddress.make("tools.vercel.org.shared.deploy"), {}); + expect(out).toMatchObject({ ran: "deploy", value: "org-token" }); + }).pipe(Effect.scoped), + ); + + it.effect("refuses catalog mutations: new registration, update, health check, removal", () => + Effect.gen(function* () { + const { member } = yield* setup(); + // A NEW slug is refused through the plugin ctx register path (the seam + // every add-integration flow funnels through)… + yield* expectOrgWriteDenied(member.demo.seedFresh()); + const fresh = yield* member.integrations.get(IntegrationSlug.make("fresh")); + expect(fresh).toBeNull(); + // …and so are the public catalog mutations. + yield* expectOrgWriteDenied(member.integrations.update(INTEG, { name: "Renamed" })); + yield* expectOrgWriteDenied(member.integrations.healthCheck.set(INTEG, null)); + yield* expectOrgWriteDenied(member.integrations.remove(INTEG)); + }).pipe(Effect.scoped), + ); + + it.effect("keeps the register REPLACE arm open (config rewrites converge for members)", () => + Effect.gen(function* () { + const { member } = yield* setup(); + // The admin already registered `vercel`; re-registering the same slug on + // the denied binding is the replace arm and must succeed — this is the + // path catalog rebuilds and legacy healing converge through. + yield* member.demo.seed(); + const row = yield* member.integrations.get(INTEG); + expect(row?.slug).toBe(INTEG); + }).pipe(Effect.scoped), + ); + + it.effect("refuses org OAuth clients and org connect flows", () => + Effect.gen(function* () { + const { member } = yield* setup(); + yield* expectOrgWriteDenied( + member.oauth.createClient({ + owner: "org", + slug: OAuthClientSlug.make("shared-app"), + authorizationUrl: "https://example.com/authorize", + tokenUrl: "https://example.com/token", + grant: "authorization_code", + clientId: "client-id", + clientSecret: "", + }), + ); + yield* expectOrgWriteDenied( + member.oauth.removeClient("org", OAuthClientSlug.make("shared-app")), + ); + yield* expectOrgWriteDenied( + member.oauth.start({ + owner: "org", + clientOwner: "org", + client: OAuthClientSlug.make("shared-app"), + integration: INTEG, + template: TEMPLATE, + name: ConnectionName.make("shared"), + }), + ); + // Personal clients stay open. + const slug = yield* member.oauth.createClient({ + owner: "user", + slug: OAuthClientSlug.make("my-app"), + authorizationUrl: "https://example.com/authorize", + tokenUrl: "https://example.com/token", + grant: "authorization_code", + clientId: "client-id", + clientSecret: "", + }); + expect(String(slug)).toBe("my-app"); + }).pipe(Effect.scoped), + ); +}); + +describe("orgWrites: default (allowed)", () => { + it.effect("admin bindings mutate workspace-level state as before", () => + Effect.gen(function* () { + const { admin } = yield* setup(); + const policy = yield* admin.policies.create({ + owner: "org", + pattern: "*", + action: "require_approval", + }); + expect(policy.owner).toBe("org"); + yield* admin.policies.remove({ id: policy.id, owner: "org" }); + yield* admin.integrations.update(INTEG, { name: "Vercel (renamed)" }); + const row = yield* admin.integrations.get(INTEG); + expect(row?.name).toBe("Vercel (renamed)"); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index c30a3f43e..f8d462a91 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -47,6 +47,7 @@ import type { IntegrationNotFoundError, IntegrationRemovalNotAllowedError, InvalidConnectionInputError, + OrgWriteDeniedError, } from "./errors"; import type { OAuthService } from "./oauth-client"; import type { CredentialProvider, ProviderEntry } from "./provider"; @@ -162,8 +163,12 @@ export interface PluginCtx { readonly core: { readonly integrations: { - /** Register / replace this plugin's integration in the catalog. */ - readonly register: (input: RegisterIntegrationInput) => Effect.Effect; + /** Register / replace this plugin's integration in the catalog. A NEW + * row is a workspace-level change gated by the executor's `orgWrites` + * binding; replacing an existing row stays open at every role. */ + readonly register: ( + input: RegisterIntegrationInput, + ) => Effect.Effect; readonly update: ( slug: IntegrationSlug, patch: { @@ -171,21 +176,24 @@ export interface PluginCtx { readonly description?: string; readonly config?: IntegrationConfig; }, - ) => Effect.Effect; + ) => Effect.Effect; readonly list: () => Effect.Effect; readonly get: ( slug: IntegrationSlug, ) => Effect.Effect; readonly remove: ( slug: IntegrationSlug, - ) => Effect.Effect; + ) => Effect.Effect< + void, + IntegrationRemovalNotAllowedError | OrgWriteDeniedError | StorageFailure + >; /** Declare (or clear, with null) the integration's health check. Core * owns this storage; plugins call it e.g. to install a zero-config * default probe at registration time. */ readonly setHealthCheck: ( slug: IntegrationSlug, spec: HealthCheckSpec | null, - ) => Effect.Effect; + ) => Effect.Effect; readonly detect: ( url: string, ) => Effect.Effect; @@ -194,9 +202,15 @@ export interface PluginCtx { }; readonly policies: { readonly list: () => Effect.Effect; - readonly create: (input: CreateToolPolicyInput) => Effect.Effect; - readonly update: (input: UpdateToolPolicyInput) => Effect.Effect; - readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect; + readonly create: ( + input: CreateToolPolicyInput, + ) => Effect.Effect; + readonly update: ( + input: UpdateToolPolicyInput, + ) => Effect.Effect; + readonly remove: ( + input: RemoveToolPolicyInput, + ) => Effect.Effect; }; }; @@ -210,6 +224,7 @@ export interface PluginCtx { | IntegrationNotFoundError | CredentialProviderNotRegisteredError | InvalidConnectionInputError + | OrgWriteDeniedError | StorageFailure >; readonly list: (filter?: { @@ -221,10 +236,10 @@ export interface PluginCtx { readonly update: ( ref: ConnectionRef, input: UpdateConnectionInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( ref: ConnectionRef, - ) => Effect.Effect; + ) => Effect.Effect; readonly refresh: ( ref: ConnectionRef, ) => Effect.Effect< diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index c0dcfc5de..1f1a3be2e 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -59,6 +59,7 @@ export { IntegrationNotFoundError, IntegrationAlreadyExistsError, IntegrationRemovalNotAllowedError, + OrgWriteDeniedError, ConnectionNotFoundError, InvalidConnectionInputError, CredentialProviderNotRegisteredError, diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index f452b94db..c7ecf52ee 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -125,6 +125,10 @@ export type TestConfigOptions["onIntegrationChange"]; readonly firstPartyOAuthClients?: ExecutorConfig["firstPartyOAuthClients"]; readonly enterpriseManagedRollout?: ExecutorConfig["enterpriseManagedRollout"]; + /** Workspace-settings permission for the test binding (see + * `ExecutorConfig.orgWrites`). Defaults to allowed, like production hosts + * with no role model. */ + readonly orgWrites?: ExecutorConfig["orgWrites"]; }; export const makeTestConfig = ( @@ -164,6 +168,7 @@ export const makeTestConfig = ; diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 363038ef5..d5fced917 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -1,6 +1,10 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { InternalError, IntegrationAlreadyExistsError } from "@executor-js/sdk/shared"; +import { + InternalError, + IntegrationAlreadyExistsError, + OrgWriteDeniedError, +} from "@executor-js/sdk/shared"; import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; import { GraphqlAuthMethod, GraphqlAuthMethodInput } from "../sdk/types"; @@ -87,6 +91,7 @@ const GraphqlErrors = [ IntrospectionError, ExtractionError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, ] as const; export const GraphqlGroup = HttpApiGroup.make("graphql") diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 6e162d29d..6ffa353c7 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -19,6 +19,7 @@ import { type HealthCheckResult, type IntegrationConfig, type IntegrationRecord, + type OrgWriteDeniedError, type PluginCtx, type StorageFailure, type ToolAnnotations, @@ -1020,7 +1021,7 @@ const makeGraphqlExtension = (ctx: PluginCtx) => { const configureAuthMethods = ( slug: string, input: GraphqlConfigureAuthInput, - ): Effect.Effect => + ): Effect.Effect => ctx.transaction( Effect.gen(function* () { const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 2de2fb122..0e10956cb 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -4,6 +4,7 @@ import { IntegrationSlug, InternalError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, } from "@executor-js/sdk/shared"; import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; @@ -153,6 +154,7 @@ export const McpGroup = HttpApiGroup.make("mcp") McpConnectionError, McpToolDiscoveryError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, ], }), ) @@ -160,7 +162,7 @@ export const McpGroup = HttpApiGroup.make("mcp") HttpApiEndpoint.delete("removeServer", "/mcp/servers/:slug", { params: SlugParams, success: RemoveServerResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ) .add( @@ -175,7 +177,7 @@ export const McpGroup = HttpApiGroup.make("mcp") params: SlugParams, payload: ConfigureServerPayload, success: ConfigureServerResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ) .add( @@ -183,6 +185,6 @@ export const McpGroup = HttpApiGroup.make("mcp") params: SlugParams, payload: ConfigureAuthPayload, success: ConfigureAuthResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, OrgWriteDeniedError], }), ); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index d401f896c..b58f571a2 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -23,6 +23,7 @@ import { type IntegrationConfig, type IntegrationRecord, type OAuthClientSummary, + type OrgWriteDeniedError, type Owner, type PluginCtx, type StaticToolSchema, @@ -1703,12 +1704,17 @@ export interface McpPluginExtension { input: McpServerInput, ) => Effect.Effect< { readonly slug: string }, - McpExtensionFailure | IntegrationAlreadyExistsError + McpExtensionFailure | IntegrationAlreadyExistsError | OrgWriteDeniedError >; - readonly removeServer: (slug: string) => Effect.Effect; + readonly removeServer: ( + slug: string, + ) => Effect.Effect; /** Ensure every stdio integration has its default connection (migrating any * legacy inline env into the secret store). Idempotent; safe to run at boot. */ - readonly reconcileStdioConnections: () => Effect.Effect; + readonly reconcileStdioConnections: () => Effect.Effect< + void, + McpExtensionFailure | OrgWriteDeniedError + >; readonly getServer: ( slug: string, ) => Effect.Effect< @@ -1718,9 +1724,9 @@ export interface McpPluginExtension { readonly configureServer: ( slug: string, config: McpIntegrationConfigType, - ) => Effect.Effect; + ) => Effect.Effect; readonly configureAuth: ( slug: string, input: McpConfigureAuthInput, - ) => Effect.Effect; + ) => Effect.Effect; } diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 0b8077bc4..dcdf43427 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -7,6 +7,7 @@ import { IntegrationAlreadyExistsError, IntegrationNotFoundError, IntegrationSlug, + OrgWriteDeniedError, } from "@executor-js/sdk/shared"; import { @@ -33,6 +34,7 @@ const DomainErrors = [ OpenApiOAuthError, OpenApiSpecOverrideError, IntegrationAlreadyExistsError, + OrgWriteDeniedError, ] as const; const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); @@ -44,6 +46,7 @@ const UpdateSpecErrors = [ OpenApiOAuthError, OpenApiSpecOverrideError, IntegrationNotFound, + OrgWriteDeniedError, ] as const; const SlugParams = { diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index d92a2ae14..2f5d43dce 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -18,6 +18,7 @@ import { type IntegrationConfig, type IntegrationPreset, type IntegrationRecord, + type OrgWriteDeniedError, type PluginCtx, type StorageFailure, } from "@executor-js/sdk/core"; @@ -166,6 +167,7 @@ export interface OpenApiPluginExtension { | OpenApiOAuthError | OpenApiSpecOverrideError | IntegrationAlreadyExistsError + | OrgWriteDeniedError | StorageFailure >; /** Re-resolve the integration's spec (from its stored source URL, or the @@ -181,9 +183,10 @@ export interface OpenApiPluginExtension { | OpenApiOAuthError | OpenApiSpecOverrideError | IntegrationNotFoundError + | OrgWriteDeniedError | StorageFailure >; - readonly removeSpec: (slug: string) => Effect.Effect; + readonly removeSpec: (slug: string) => Effect.Effect; readonly getIntegration: (slug: string) => Effect.Effect; /** Read the integration's full opaque config, including its * `authenticationTemplate`. Returns null when the integration is absent. */ @@ -195,7 +198,7 @@ export interface OpenApiPluginExtension { readonly configure: ( slug: string, input: OpenApiConfigureInput, - ) => Effect.Effect; + ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -1165,7 +1168,7 @@ export const openApiPlugin = definePlugin< configure: ( slug: string, input: OpenApiConfigureInput, - ): Effect.Effect => + ): Effect.Effect => ctx.transaction( Effect.gen(function* () { const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); From 7e3eb8b572c87d866f3c4294c3dba7f8ff04c98a Mon Sep 17 00:00:00 2001 From: Max schwenk Date: Thu, 27 Aug 2026 16:15:23 -0400 Subject: [PATCH 2/9] Add workspace settings audit history --- .changeset/workspace-writes-admin-only.md | 8 + .../drizzle/0016_fantastic_colleen_wing.sql | 14 + apps/cloud/drizzle/meta/0016_snapshot.json | 1587 +++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 7 + apps/cloud/src/admin/admin-users-api.ts | 9 + apps/cloud/src/db/executor-schema.ts | 20 + apps/cloud/src/db/org-deletion.test.ts | 14 + apps/cloud/src/db/org-deletion.ts | 2 + .../src/admin/admin-escalation.node.test.ts | 1 + .../src/admin/admin-users-api.ts | 9 + .../src/admin/admin-users.node.test.ts | 9 + .../core/api/src/admin/admin-users.test.ts | 135 ++ packages/core/api/src/admin/api.ts | 42 + packages/core/api/src/admin/handlers.ts | 31 + packages/core/api/src/admin/reads.ts | 37 + packages/core/api/src/admin/service.ts | 7 + packages/core/api/src/client.ts | 2 + packages/core/api/src/index.ts | 2 + packages/core/api/src/server.ts | 1 + packages/core/sdk/src/audit-events.test.ts | 222 +++ packages/core/sdk/src/audit.ts | 40 + packages/core/sdk/src/core-schema.ts | 23 + packages/core/sdk/src/executor.ts | 204 ++- packages/core/sdk/src/index.ts | 8 + packages/core/sdk/src/oauth-service.ts | 120 +- packages/react/src/api/admin-atoms.tsx | 14 + .../react/src/lib/admin-users-display.test.ts | 38 + packages/react/src/lib/admin-users-display.ts | 32 + packages/react/src/pages/admin-users.tsx | 354 ++-- 29 files changed, 2805 insertions(+), 187 deletions(-) create mode 100644 apps/cloud/drizzle/0016_fantastic_colleen_wing.sql create mode 100644 apps/cloud/drizzle/meta/0016_snapshot.json create mode 100644 packages/core/sdk/src/audit-events.test.ts create mode 100644 packages/core/sdk/src/audit.ts diff --git a/.changeset/workspace-writes-admin-only.md b/.changeset/workspace-writes-admin-only.md index 2b58111a3..32730a130 100644 --- a/.changeset/workspace-writes-admin-only.md +++ b/.changeset/workspace-writes-admin-only.md @@ -1,5 +1,6 @@ --- "@executor-js/sdk": minor +"@executor-js/api": minor "@executor-js/plugin-graphql": minor "@executor-js/plugin-mcp": minor "@executor-js/plugin-openapi": minor @@ -19,3 +20,10 @@ Using workspace resources is unchanged for members: reads, tool execution over shared connections, and the operational writes those imply (token refresh, tool-catalog re-sync, config-rewrite healing) keep working. Hosts with no role model (local, the CLI, embedded SDK use) default to `"allowed"`. + +Successful connection, integration, and OAuth-client create/update/remove +operations now write a tenant-scoped audit event with the acting user, resource +scope, and safe identifiers. Admins can list the newest events through +the Users page's Activity tab or `GET /admin/audit-events`; actor email and +display name are joined from the host directory, while credentials and +free-form configuration are never stored in or returned by the audit surface. diff --git a/apps/cloud/drizzle/0016_fantastic_colleen_wing.sql b/apps/cloud/drizzle/0016_fantastic_colleen_wing.sql new file mode 100644 index 000000000..b3bfef57e --- /dev/null +++ b/apps/cloud/drizzle/0016_fantastic_colleen_wing.sql @@ -0,0 +1,14 @@ +CREATE TABLE "audit_event" ( + "id" varchar(255) NOT NULL, + "actor_id" varchar(255), + "action" varchar(255) NOT NULL, + "resource_type" varchar(255) NOT NULL, + "resource_owner" varchar(255), + "resource_parent" text, + "resource_id" text NOT NULL, + "created_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "audit_event_uidx" ON "audit_event" USING btree ("tenant","created_at","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0016_snapshot.json b/apps/cloud/drizzle/meta/0016_snapshot.json new file mode 100644 index 000000000..4294e1ea6 --- /dev/null +++ b/apps/cloud/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1587 @@ +{ + "id": "c84f378d-37c9-496e-aa01-3208aa59baa6", + "prevId": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_event": { + "name": "audit_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_owner": { + "name": "resource_owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resource_parent": { + "name": "resource_parent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_event_uidx": { + "name": "audit_event_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index fa9057083..eb2b007bb 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1785355354955, "tag": "0015_equal_the_leader", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1787860949290, + "tag": "0016_fantastic_colleen_wing", + "breakpoints": true } ] } diff --git a/apps/cloud/src/admin/admin-users-api.ts b/apps/cloud/src/admin/admin-users-api.ts index 351f8d46e..bb306d000 100644 --- a/apps/cloud/src/admin/admin-users-api.ts +++ b/apps/cloud/src/admin/admin-users-api.ts @@ -35,6 +35,7 @@ import { HostConfig, PluginsProvider, getAdminUser, + listAdminAuditEvents, listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, @@ -286,6 +287,14 @@ export const workosAdminUsersProvider: Layer.Layer< WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig >(); return AdminUsersProvider.of({ + listAuditEvents: (headers, options) => + withPlatformView(headers, (executor, organizationId) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + listAdminAuditEvents(admin, options, userDirectory(organizationId, context)), + ), + ), + ).pipe(Effect.provideContext(context)), listUsers: (headers, options) => withPlatformView(headers, (executor, organizationId) => platformViewOf(executor).pipe( diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index e23d30d07..34ed0db12 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -49,6 +49,26 @@ export const subject = pgTable( (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)], ); +export const audit_event = pgTable( + "audit_event", + { + id: varchar("id", { length: 255 }).notNull(), + actor_id: varchar("actor_id", { length: 255 }), + action: varchar("action", { length: 255 }).notNull(), + resource_type: varchar("resource_type", { length: 255 }).notNull(), + resource_owner: varchar("resource_owner", { length: 255 }), + resource_parent: text("resource_parent"), + resource_id: text("resource_id").notNull(), + created_at: timestamp("created_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("audit_event_uidx").on(table.tenant, table.created_at, table.id)], +); + export const connection = pgTable( "connection", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index 86faa45bb..ada5d4ed2 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -29,6 +29,7 @@ import * as executorSchema from "./executor-schema"; import { memberships, accounts } from "./schema"; import { artifact, + audit_event, blob, connection, definition, @@ -147,6 +148,18 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { tenant, }); + await db.insert(audit_event).values({ + id: `aud-${tag}`, + actor_id: `acct-${tag}`, + action: "created", + resource_type: "connection", + resource_owner: "org", + resource_parent: "int", + resource_id: `conn-${tag}`, + created_at: now, + tenant, + }); + await db.insert(artifact).values({ id: `art-${tag}`, title: "Dashboard", @@ -184,6 +197,7 @@ const TENANT_TABLES = [ tool_policy, plugin_storage, subject, + audit_event, artifact, ] as const; diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index b2a922a3f..97a7585ae 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -17,6 +17,7 @@ import type { DrizzleDb } from "./db"; import { organizations } from "./schema"; import { artifact, + audit_event, blob, connection, definition, @@ -50,6 +51,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); + await tx.delete(audit_event).where(eq(audit_event.tenant, organizationId)); await tx.delete(subject).where(eq(subject.tenant, organizationId)); await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); diff --git a/apps/host-selfhost/src/admin/admin-escalation.node.test.ts b/apps/host-selfhost/src/admin/admin-escalation.node.test.ts index 3e44d36dd..f7ed546b1 100644 --- a/apps/host-selfhost/src/admin/admin-escalation.node.test.ts +++ b/apps/host-selfhost/src/admin/admin-escalation.node.test.ts @@ -126,6 +126,7 @@ test("a member cannot escalate by owning an organization of their own", async () // Every admin users route, not just the list: a gate applied at four call // sites can be fixed at three. for (const path of [ + "/api/admin/audit-events", "/api/admin/users", "/api/admin/users/with-connections", "/api/admin/users/user_anyone/connections", diff --git a/apps/host-selfhost/src/admin/admin-users-api.ts b/apps/host-selfhost/src/admin/admin-users-api.ts index 38f767a16..4f11b8fbb 100644 --- a/apps/host-selfhost/src/admin/admin-users-api.ts +++ b/apps/host-selfhost/src/admin/admin-users-api.ts @@ -28,6 +28,7 @@ import { HostConfig, PluginsProvider, getAdminUser, + listAdminAuditEvents, listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, @@ -159,6 +160,14 @@ export const betterAuthAdminUsersProvider: Layer.Layer< const context = yield* Effect.context(); const { auth, organizationId } = yield* BetterAuth; return AdminUsersProvider.of({ + listAuditEvents: (headers, options) => + withPlatformView(headers, organizationId, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + listAdminAuditEvents(admin, options, userDirectory(auth, headers)), + ), + ), + ).pipe(Effect.provideContext(context)), listUsers: (headers, options) => withPlatformView(headers, organizationId, (executor) => platformViewOf(executor).pipe( diff --git a/apps/host-selfhost/src/admin/admin-users.node.test.ts b/apps/host-selfhost/src/admin/admin-users.node.test.ts index 149c0655e..781ea2dba 100644 --- a/apps/host-selfhost/src/admin/admin-users.node.test.ts +++ b/apps/host-selfhost/src/admin/admin-users.node.test.ts @@ -135,6 +135,13 @@ test("the owner sees who uses the instance; a plain member cannot look", async ( "the joined view reports the same identities, keyed the same way", ).toEqual(body.users.map((user) => [user.externalId, user.email]).sort()); + const audit = await adminUsers(adminToken, "/api/admin/audit-events"); + expect(audit.status).toBe(200); + expect( + Array.isArray(((await audit.json()) as { events: readonly unknown[] }).events), + "the owner receives the audit collection", + ).toBe(true); + // ------------------------------------------------------------------------- // The single-user read, by opaque id and by email. // ------------------------------------------------------------------------- @@ -221,10 +228,12 @@ test("the owner sees who uses the instance; a plain member cannot look", async ( // The gate: a plain member may not read the instance-wide view. const asMember = await adminUsers(memberToken); expect(asMember.status, "a member is refused").toBe(403); + expect((await adminUsers(memberToken, "/api/admin/audit-events")).status).toBe(403); // And an anonymous caller has no session at all. const anonymous = await adminUsers(); expect(anonymous.status, "no session → unauthorized").toBe(401); + expect((await adminUsers(undefined, "/api/admin/audit-events")).status).toBe(401); // The single-user read refuses on the same terms — and refuses BEFORE // looking, so a refused caller cannot probe which users exist. diff --git a/packages/core/api/src/admin/admin-users.test.ts b/packages/core/api/src/admin/admin-users.test.ts index 684c81ff6..0a1987612 100644 --- a/packages/core/api/src/admin/admin-users.test.ts +++ b/packages/core/api/src/admin/admin-users.test.ts @@ -20,6 +20,7 @@ import { AdminUsersHandlers } from "./handlers"; import { AdminUsersProvider, type AdminUsersHeaders } from "./service"; import { getUser, + listAuditEvents, listUserConnections, listUsers, listUsersWithConnections, @@ -158,6 +159,40 @@ const insertConnection = ( }); }); +const insertAuditEvent = ( + db: SqliteTestFumaDb, + row: { + readonly id: string; + readonly tenant: string; + readonly actorId: string | null; + readonly action: "created" | "updated" | "removed"; + readonly resourceType: "connection" | "integration" | "oauth_client"; + readonly resourceOwner: "org" | "user" | null; + readonly resourceId: string; + readonly createdAt: number; + }, +): Effect.Effect => + Effect.promise(async () => { + await db.client.execute({ + sql: `INSERT INTO audit_event ( + row_id, tenant, id, actor_id, action, resource_type, resource_owner, + resource_parent, resource_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + `row-${row.id}`, + row.tenant, + row.id, + row.actorId, + row.action, + row.resourceType, + row.resourceOwner, + row.resourceType === "connection" ? "github" : null, + row.resourceId, + row.createdAt, + ], + }); + }); + /** Two users with connections under tenant A, plus a whole separate tenant B * that A's admin plane must never see. */ const seed = (db: SqliteTestFumaDb): Effect.Effect => @@ -231,6 +266,15 @@ const stubProvider = ( directory?: AdminIdentityDirectory | AdminUserDirectory, ) => Layer.succeed(AdminUsersProvider)({ + listAuditEvents: (headers, options) => + authorize(headers).pipe( + Effect.flatMap(executorFor), + Effect.flatMap((executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => listAuditEvents(admin, options, directory)), + ), + ), + ), listUsers: (headers, options) => authorize(headers).pipe( Effect.flatMap(executorFor), @@ -359,6 +403,20 @@ type UsersWithConnectionsBody = { }>; }>; }; +type AuditEventsBody = { + readonly events: ReadonlyArray<{ + readonly id: string; + readonly actorId: string | null; + readonly actorEmail: string | null; + readonly actorDisplayName: string | null; + readonly action: string; + readonly resourceType: string; + readonly resourceOwner: string | null; + readonly resourceParent: string | null; + readonly resourceId: string; + readonly createdAt: number; + }>; +}; const ORG_A = "Bearer org_a_key"; @@ -420,6 +478,75 @@ const failingDirectory: AdminIdentityDirectory = () => Effect.fail(new DirectoryUnavailable({ message: "member directory unavailable" })); describe("admin users API", () => { + it.effect("lists filtered audit events with actor identity and tenant isolation", () => + withDb((db) => + Effect.gen(function* () { + yield* insertAuditEvent(db, { + id: "aud-a-old", + tenant: TENANT_A, + actorId: USER_A1, + action: "created", + resourceType: "connection", + resourceOwner: "org", + resourceId: "shared", + createdAt: 100, + }); + yield* insertAuditEvent(db, { + id: "aud-a-new", + tenant: TENANT_A, + actorId: USER_A1, + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceId: "personal", + createdAt: 200, + }); + yield* insertAuditEvent(db, { + id: "aud-b", + tenant: TENANT_B, + actorId: USER_B1, + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceId: "other-tenant-secret-name", + createdAt: 300, + }); + + const seen: string[][] = []; + const web = yield* webHandlerFor( + stubProvider( + (tenant) => platformExecutorFor(db, tenant), + headerAuthorize, + stubUserDirectory({ seen }), + ), + ); + const response = yield* get( + web, + "/admin/audit-events?action=removed&resourceOwner=user&limit=1", + ORG_A, + ); + expect(response.status).toBe(200); + const body = yield* jsonOf(response); + expect(body.events).toEqual([ + { + id: "aud-a-new", + actorId: USER_A1, + actorEmail: A1_EMAIL_STORED, + actorDisplayName: "User A1", + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceParent: "github", + resourceId: "personal", + createdAt: 200_000, + }, + ]); + expect(seen).toEqual([[USER_A1]]); + expect(JSON.stringify(body)).not.toContain("other-tenant-secret-name"); + }), + ), + ); + it.effect("lists every user of the tenant for an authorized org caller", () => withDb((db) => Effect.gen(function* () { @@ -499,6 +626,7 @@ describe("admin users API", () => { ); for (const path of [ + "/admin/audit-events", "/admin/users", "/admin/users/with-connections", `/admin/users/${USER_A1}/connections`, @@ -570,6 +698,9 @@ describe("admin users API", () => { const member = yield* get(web, "/admin/users", "Bearer user_scoped_key"); expect(member.status, "a non-admin caller → forbidden").toBe(403); + expect((yield* get(web, "/admin/audit-events")).status).toBe(401); + expect((yield* get(web, "/admin/audit-events", "Bearer user_scoped_key")).status).toBe(403); + const memberJoined = yield* get(web, "/admin/users/with-connections", "Bearer user_key"); expect(memberJoined.status).toBe(403); const memberConnections = yield* get( @@ -1097,6 +1228,10 @@ const A_SUBJECT: AdminSubject = { /** An `ExecutorAdmin` that answers everything and records the reads it was * asked for, so a test can assert the call the filter chose. */ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ + listAuditEvents: () => { + calls.push("listAuditEvents"); + return Effect.succeed([]); + }, listSubjects: () => { calls.push("listSubjects"); return Effect.succeed([A_SUBJECT]); diff --git a/packages/core/api/src/admin/api.ts b/packages/core/api/src/admin/api.ts index 360a61b60..299dd9884 100644 --- a/packages/core/api/src/admin/api.ts +++ b/packages/core/api/src/admin/api.ts @@ -37,6 +37,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect"; import { ConnectionName, HealthStatus, IntegrationSlug, Owner } from "@executor-js/sdk/shared"; +import { AUDIT_EVENT_ACTIONS, AUDIT_RESOURCE_TYPES } from "@executor-js/sdk"; // --------------------------------------------------------------------------- // Errors @@ -196,6 +197,24 @@ export const AdminUserResponse = Schema.Struct({ user: AdminUserWithConnections, }); +export const AdminAuditEvent = Schema.Struct({ + id: Schema.String, + actorId: Schema.NullOr(Schema.String), + actorEmail: Schema.NullOr(Schema.String), + actorDisplayName: Schema.NullOr(Schema.String), + action: Schema.Literals(AUDIT_EVENT_ACTIONS), + resourceType: Schema.Literals(AUDIT_RESOURCE_TYPES), + resourceOwner: Schema.NullOr(Owner), + resourceParent: Schema.NullOr(Schema.String), + resourceId: Schema.String, + /** Epoch milliseconds. */ + createdAt: Schema.Number, +}); + +export const AdminAuditEventsResponse = Schema.Struct({ + events: Schema.Array(AdminAuditEvent), +}); + // --------------------------------------------------------------------------- // Params / query // --------------------------------------------------------------------------- @@ -274,6 +293,22 @@ const AdminListQuery = Schema.Struct({ email: Schema.optional(Schema.String), }); +const AdminAuditListQuery = Schema.Struct({ + limit: Schema.optional( + Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })), + ), + offset: Schema.optional( + Schema.FiniteFromString.check( + Schema.isInt(), + Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), + ), + ), + actorId: Schema.optional(Schema.String), + action: Schema.optional(Schema.Literals(AUDIT_EVENT_ACTIONS)), + resourceType: Schema.optional(Schema.Literals(AUDIT_RESOURCE_TYPES)), + resourceOwner: Schema.optional(Owner), +}); + // --------------------------------------------------------------------------- // Group // --------------------------------------------------------------------------- @@ -304,6 +339,13 @@ const AdminListQuery = Schema.Struct({ * same position, and the tree matches on position, not on name. */ export const AdminUsersApi = HttpApiGroup.make("adminUsers") + .add( + HttpApiEndpoint.get("listAuditEvents", "/admin/audit-events", { + query: AdminAuditListQuery, + success: AdminAuditEventsResponse, + error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden], + }), + ) .add( HttpApiEndpoint.get("listUsers", "/admin/users", { query: AdminListQuery, diff --git a/packages/core/api/src/admin/handlers.ts b/packages/core/api/src/admin/handlers.ts index f5d6ccc8b..078983b90 100644 --- a/packages/core/api/src/admin/handlers.ts +++ b/packages/core/api/src/admin/handlers.ts @@ -1,6 +1,12 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerRequest } from "effect/unstable/http"; import { Effect } from "effect"; +import type { + AdminListAuditEventsOptions, + AuditEventAction, + AuditResourceType, + Owner, +} from "@executor-js/sdk"; import { AdminUsersHttpApi } from "./api"; import { normalizeEmail } from "./reads"; @@ -34,11 +40,36 @@ const listOptions = (query: { ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), }); +const auditListOptions = (query: { + readonly limit?: number | undefined; + readonly offset?: number | undefined; + readonly actorId?: string | undefined; + readonly action?: AuditEventAction | undefined; + readonly resourceType?: AuditResourceType | undefined; + readonly resourceOwner?: Owner | undefined; +}): AdminListAuditEventsOptions => ({ + ...(query.limit === undefined ? {} : { limit: query.limit }), + ...(query.offset === undefined ? {} : { offset: query.offset }), + ...(query.actorId === undefined ? {} : { actorId: query.actorId }), + ...(query.action === undefined ? {} : { action: query.action }), + ...(query.resourceType === undefined ? {} : { resourceType: query.resourceType }), + ...(query.resourceOwner === undefined ? {} : { resourceOwner: query.resourceOwner }), +}); + export const AdminUsersHandlers = HttpApiBuilder.group( AdminUsersHttpApi, "adminUsers", (handlers) => handlers + .handle("listAuditEvents", ({ query }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + return yield* (yield* AdminUsersProvider).listAuditEvents( + headers, + auditListOptions(query), + ); + }), + ) .handle("listUsers", ({ query }) => Effect.gen(function* () { const headers = yield* requestHeaders; diff --git a/packages/core/api/src/admin/reads.ts b/packages/core/api/src/admin/reads.ts index 96b6f2bbd..36ad52ff4 100644 --- a/packages/core/api/src/admin/reads.ts +++ b/packages/core/api/src/admin/reads.ts @@ -16,6 +16,7 @@ import { Effect } from "effect"; import type { AdminConnection, + AdminListAuditEventsOptions, AdminSubject, AdminSubjectWithConnections, Executor, @@ -26,6 +27,7 @@ import { AdminUserNotFound, AdminUsersError, type AdminUserConnectionsResponse, + type AdminAuditEventsResponse, type AdminUserResponse, type AdminUsersResponse, type AdminUsersWithConnectionsResponse, @@ -145,6 +147,41 @@ const resolveIdentities = ( const ABSENT_IDENTITY: AdminUserIdentity = { email: null, displayName: null }; +export const listAuditEvents = ( + admin: ExecutorAdmin, + options: AdminListAuditEventsOptions, + directory?: AdminIdentityDirectory | AdminUserDirectory, +): Effect.Effect => + Effect.gen(function* () { + const events = yield* admin + .listAuditEvents(options) + .pipe(Effect.mapError(readFailed("audit events"))); + const actorIds = [ + ...new Set(events.flatMap((event) => (event.actorId === null ? [] : [event.actorId]))), + ]; + const identities = yield* resolveIdentities(asDirectory(directory).identities, actorIds); + return { + events: events.map((event) => { + const identity = + event.actorId === null + ? ABSENT_IDENTITY + : (identities.get(event.actorId) ?? ABSENT_IDENTITY); + return { + id: event.id, + actorId: event.actorId, + actorEmail: identity.email, + actorDisplayName: identity.displayName, + action: event.action, + resourceType: event.resourceType, + resourceOwner: event.resourceOwner, + resourceParent: event.resourceParent, + resourceId: event.resourceId, + createdAt: event.createdAt.getTime(), + }; + }), + }; + }); + /** * `AdminSubject` → the public `AdminUser` shape. * diff --git a/packages/core/api/src/admin/service.ts b/packages/core/api/src/admin/service.ts index b1d9baba9..11ec8c37d 100644 --- a/packages/core/api/src/admin/service.ts +++ b/packages/core/api/src/admin/service.ts @@ -17,6 +17,7 @@ // --------------------------------------------------------------------------- import { Context, type Effect } from "effect"; +import type { AdminListAuditEventsOptions } from "@executor-js/sdk"; import { type AdminUserNotFound, @@ -24,6 +25,7 @@ import { type AdminUsersForbidden, type AdminUsersUnauthorized, AdminUserResponse, + AdminAuditEventsResponse, AdminUsersResponse, AdminUserConnectionsResponse, AdminUsersWithConnectionsResponse, @@ -41,6 +43,7 @@ export interface AdminUsersListOptions { } type User = typeof AdminUserResponse.Type; +type AuditEvents = typeof AdminAuditEventsResponse.Type; type Users = typeof AdminUsersResponse.Type; type UserConnections = typeof AdminUserConnectionsResponse.Type; type UsersWithConnections = typeof AdminUsersWithConnectionsResponse.Type; @@ -52,6 +55,10 @@ type Authorized = Effect.Effect< >; export interface AdminUsersProviderShape { + readonly listAuditEvents: ( + headers: AdminUsersHeaders, + options: AdminListAuditEventsOptions, + ) => Authorized; readonly listUsers: ( headers: AdminUsersHeaders, options: AdminUsersListOptions, diff --git a/packages/core/api/src/client.ts b/packages/core/api/src/client.ts index a46ec456f..4e5e51d96 100644 --- a/packages/core/api/src/client.ts +++ b/packages/core/api/src/client.ts @@ -20,6 +20,8 @@ export { AdminUsersError, AdminUsersForbidden, AdminUsersUnauthorized, + AdminAuditEvent, + AdminAuditEventsResponse, AdminUser, AdminUserConnection, AdminUserWithConnections, diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 6ba0b8b15..1f05965d5 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -69,6 +69,8 @@ export { AdminUsersForbidden, AdminUsersUnauthorized, AdminUserNotFound, + AdminAuditEvent, + AdminAuditEventsResponse, AdminUser, AdminUserConnection, AdminUserWithConnections, diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 104d841c3..0ee1a844b 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -33,6 +33,7 @@ export { export { AdminUsersHandlers } from "./admin/handlers"; export { platformViewOf, + listAuditEvents as listAdminAuditEvents, listUsers as listAdminUsers, listUsersWithConnections as listAdminUsersWithConnections, listUserConnections as listAdminUserConnections, diff --git a/packages/core/sdk/src/audit-events.test.ts b/packages/core/sdk/src/audit-events.test.ts new file mode 100644 index 000000000..3f39bfb72 --- /dev/null +++ b/packages/core/sdk/src/audit-events.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { createExecutor, type ExecutorAdmin } from "./executor"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ProviderItemId, + ProviderKey, + Tenant, +} from "./ids"; +import { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { makeTestConfig } from "./testing"; + +const INTEGRATION = IntegrationSlug.make("example"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const memoryProvider = (): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + delete: (id) => Effect.sync(() => void store.delete(String(id))), + has: (id) => Effect.sync(() => store.has(String(id))), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((key) => ({ + id: ProviderItemId.make(key), + name: key, + })), + ), + }; +}; + +const auditPlugin = definePlugin(() => ({ + id: "audit-test" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEGRATION, + description: "Example", + config: {}, + }), + }), +}))(); + +const requireAdmin = (admin: ExecutorAdmin | undefined) => + admin === undefined ? Effect.die("expected a platform admin view") : Effect.succeed(admin); + +const setup = () => + Effect.gen(function* () { + const config = makeTestConfig({ + tenant: "audit-tenant", + subject: "actor-123", + plugins: [auditPlugin] as const, + }); + const executor = yield* createExecutor(config); + const platformExecutor = yield* createExecutor({ + tenant: config.tenant, + db: config.testDb.db, + platformView: true, + onElicitation: "accept-all", + }); + const admin = yield* requireAdmin(platformExecutor.admin); + yield* Effect.addFinalizer(() => + executor + .close() + .pipe( + Effect.andThen(platformExecutor.close()), + Effect.andThen(Effect.promise(() => config.testDb.close())), + Effect.ignore, + ), + ); + return { executor, admin, db: config.testDb.db }; + }); + +describe("admin audit events", () => { + it.effect("records successful lifecycle changes with actor, scope, and safe identifiers", () => + Effect.gen(function* () { + const { executor, admin } = yield* setup(); + yield* executor["audit-test"].seed(); + + const shared = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEGRATION, + template: TEMPLATE, + value: "SECRET-workspace-token", + }); + const personal = yield* executor.connections.create({ + owner: "user", + name: ConnectionName.make("personal"), + integration: INTEGRATION, + template: TEMPLATE, + value: "SECRET-personal-token", + }); + yield* executor.connections.update( + { owner: shared.owner, integration: shared.integration, name: shared.name }, + { description: "renamed" }, + ); + yield* executor.connections.remove({ + owner: personal.owner, + integration: personal.integration, + name: personal.name, + }); + + const client = OAuthClientSlug.make("workspace-app"); + const clientInput = { + owner: "org" as const, + slug: client, + authorizationUrl: "https://example.test/authorize", + tokenUrl: "https://example.test/token", + grant: "authorization_code" as const, + clientId: "client-id", + clientSecret: "SECRET-client-secret", + }; + yield* executor.oauth.createClient(clientInput); + yield* executor.oauth.createClient({ ...clientInput, clientId: "updated-client-id" }); + yield* executor.oauth.removeClient("org", client); + + yield* executor.integrations.update(INTEGRATION, { name: "Renamed" }); + yield* executor.integrations.remove(INTEGRATION); + + const events = yield* admin.listAuditEvents(); + expect(events).toHaveLength(10); + expect(new Set(events.map((event) => event.actorId))).toEqual(new Set(["actor-123"])); + expect( + events.map(({ action, resourceType, resourceOwner, resourceParent, resourceId }) => ({ + action, + resourceType, + resourceOwner, + resourceParent, + resourceId, + })), + ).toEqual( + expect.arrayContaining([ + { + action: "created", + resourceType: "connection", + resourceOwner: "org", + resourceParent: "example", + resourceId: "shared", + }, + { + action: "removed", + resourceType: "connection", + resourceOwner: "user", + resourceParent: "example", + resourceId: "personal", + }, + { + action: "updated", + resourceType: "oauth_client", + resourceOwner: "org", + resourceParent: null, + resourceId: "workspace-app", + }, + { + action: "removed", + resourceType: "integration", + resourceOwner: null, + resourceParent: null, + resourceId: "example", + }, + ]), + ); + + const serialized = JSON.stringify(events); + expect(serialized).not.toContain("SECRET-"); + expect(serialized).not.toContain("client-id"); + expect(serialized).not.toContain("authorizationUrl"); + }).pipe(Effect.scoped), + ); + + it.effect("filters, pages, and isolates the tenant", () => + Effect.gen(function* () { + const { executor, admin, db } = yield* setup(); + yield* executor["audit-test"].seed(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: INTEGRATION, + template: TEMPLATE, + value: "workspace-token", + }); + yield* executor.connections.create({ + owner: "user", + name: ConnectionName.make("personal"), + integration: INTEGRATION, + template: TEMPLATE, + value: "personal-token", + }); + + const orgEvents = yield* admin.listAuditEvents({ resourceOwner: "org" }); + expect(orgEvents).toHaveLength(1); + expect(orgEvents[0]).toMatchObject({ resourceType: "connection", resourceId: "shared" }); + expect(yield* admin.listAuditEvents({ resourceType: "connection", limit: 1 })).toHaveLength( + 1, + ); + expect(yield* admin.listAuditEvents({ resourceType: "connection", offset: 1 })).toHaveLength( + 1, + ); + + const otherPlatform = yield* createExecutor({ + tenant: Tenant.make("other-tenant"), + db, + platformView: true, + onElicitation: "accept-all", + }); + yield* Effect.addFinalizer(() => otherPlatform.close().pipe(Effect.ignore)); + const otherAdmin = yield* requireAdmin(otherPlatform.admin); + expect(yield* otherAdmin.listAuditEvents()).toEqual([]); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/core/sdk/src/audit.ts b/packages/core/sdk/src/audit.ts new file mode 100644 index 000000000..20f1b48f8 --- /dev/null +++ b/packages/core/sdk/src/audit.ts @@ -0,0 +1,40 @@ +import type { Owner } from "./ids"; + +export const AUDIT_EVENT_ACTIONS = ["created", "updated", "removed"] as const; +export type AuditEventAction = (typeof AUDIT_EVENT_ACTIONS)[number]; + +export const AUDIT_RESOURCE_TYPES = ["connection", "integration", "oauth_client"] as const; +export type AuditResourceType = (typeof AUDIT_RESOURCE_TYPES)[number]; + +/** A durable, tenant-scoped record of a user-intent configuration mutation. + * Credential values and provider item ids are deliberately never recorded. */ +export interface AdminAuditEvent { + readonly id: string; + readonly actorId: string | null; + readonly action: AuditEventAction; + readonly resourceType: AuditResourceType; + readonly resourceOwner: Owner | null; + /** Parent namespace for a resource. Connections use their integration slug. */ + readonly resourceParent: string | null; + /** The resource's own stable identifier (connection name, integration slug, + * or OAuth-client slug). */ + readonly resourceId: string; + readonly createdAt: Date; +} + +export interface AdminListAuditEventsOptions { + readonly limit?: number; + readonly offset?: number; + readonly actorId?: string; + readonly action?: AuditEventAction; + readonly resourceType?: AuditResourceType; + readonly resourceOwner?: Owner; +} + +export interface AuditEventInput { + readonly action: AuditEventAction; + readonly resourceType: AuditResourceType; + readonly resourceOwner?: Owner | null; + readonly resourceParent?: string | null; + readonly resourceId: string; +} diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index b03adf5df..4ebeb206a 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -197,6 +197,28 @@ export const coreTables = defineTables({ ["tenant", "external_id"], ), + // Append-only configuration audit history. Tenant-scoped so the platform + // view can read every actor's events without widening any credential-bearing + // owner-scoped table. Rows contain identifiers only — never credential + // values, provider item ids, OAuth tokens, or free-form descriptions. + audit_event: tenantExecutorTable( + "audit_event", + { + id: keyColumn("id"), + actor_id: nullableKeyColumn("actor_id"), + action: keyColumn("action"), + resource_type: keyColumn("resource_type"), + resource_owner: nullableKeyColumn("resource_owner"), + resource_parent: nullableTextColumn("resource_parent"), + resource_id: textColumn("resource_id"), + created_at: dateColumn("created_at"), + }, + // The unique index doubles as the newest-first admin read index. `id` is + // globally unique in practice and remains the final tie-breaker for events + // written in the same millisecond. + ["tenant", "created_at", "id"], + ), + // THE saved credential, one per (owner, integration, name). Resolves each named // input via `provider` + the `item_ids` map (variable → provider item id). A // single-secret connection is `{ "token": }`; an apiKey method with two @@ -431,6 +453,7 @@ export type CoreSchema = typeof coreTables; export type IntegrationRow = FumaRow; export type SubjectRow = FumaRow; +export type AuditEventRow = FumaRow; export type ConnectionRow = FumaRow; export type OAuthClientRow = FumaRow; export type OAuthSessionRow = FumaRow; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 6a47f4cb2..22178adc9 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -37,11 +37,19 @@ import { type ConnectionRow, type CoreSchema, type IntegrationRow, + type AuditEventRow, type OAuthClientRow, type ToolInvocationRow, type ToolRow, type ToolPolicyRow, } from "./core-schema"; +import type { + AdminAuditEvent, + AdminListAuditEventsOptions, + AuditEventInput, + AuditEventAction, + AuditResourceType, +} from "./audit"; import { ElicitationDeclinedError, ElicitationResponse, @@ -552,6 +560,11 @@ const normalizeAdminPaging = ( }; export interface ExecutorAdmin { + /** Newest-first tenant audit history. Identifiers only: no credential + * material or free-form configuration is exposed. */ + readonly listAuditEvents: ( + options?: AdminListAuditEventsOptions, + ) => Effect.Effect; /** One page of subjects under the tenant, oldest first (stable: ties break on * `external_id`). ALWAYS bounded: no arguments means * {@link ADMIN_DEFAULT_PAGE_SIZE} rows from offset 0, and `limit` is clamped @@ -1705,6 +1718,24 @@ export const createExecutor = (effect: Effect.Effect) => fuma.transaction(effect); + const recordAuditEvent = (input: AuditEventInput): Effect.Effect => { + const createdAt = new Date(); + const id = `aud_${createdAt.getTime().toString(36)}_${Math.random().toString(36).slice(2, 12)}`; + return core + .create("audit_event", { + tenant, + id, + actor_id: subject, + action: input.action, + resource_type: input.resourceType, + resource_owner: input.resourceOwner ?? null, + resource_parent: input.resourceParent ?? null, + resource_id: input.resourceId, + created_at: createdAt, + }) + .pipe(Effect.asVoid); + }; + // Runtime-observed output shapes ("muscle memory"): learned on the // execute success path, served by tools.schema when a tool declares no // output schema. Backed by plugin_storage under a reserved system id. @@ -2636,6 +2667,11 @@ export const createExecutor = => - Effect.gen(function* () { - yield* guardOrgWrite(); - const now = new Date(); - const set: Record = { updated_at: now }; - if (patch.name !== undefined) set.name = patch.name; - if (patch.description !== undefined) set.description = patch.description; - if (patch.config !== undefined) { - set.config = patch.config; - // A config change can change the derived tools. The writer can only - // rebuild catalogs in its own partition (owner policy), so revise - // the integration: other subjects' connections compare this stamp - // against their `tools_synced_at` and lazily rebuild on next read. - set.config_revised_at = now.getTime(); - } - yield* core.updateMany("integration", { - where: (b: AnyCb) => b("slug", "=", String(slug)), - set, - }); - }); + transaction( + Effect.gen(function* () { + yield* guardOrgWrite(); + const now = new Date(); + const set: Record = { updated_at: now }; + if (patch.name !== undefined) set.name = patch.name; + if (patch.description !== undefined) set.description = patch.description; + if (patch.config !== undefined) { + set.config = patch.config; + // A config change can change the derived tools. The writer can only + // rebuild catalogs in its own partition (owner policy), so revise + // the integration: other subjects' connections compare this stamp + // against their `tools_synced_at` and lazily rebuild on next read. + set.config_revised_at = now.getTime(); + } + yield* core.updateMany("integration", { + where: (b: AnyCb) => b("slug", "=", String(slug)), + set, + }); + yield* recordAuditEvent({ + action: "updated", + resourceType: "integration", + resourceId: String(slug), + }); + }), + ); const integrationsUpdatePublic = ( slug: IntegrationSlug, @@ -2720,6 +2763,11 @@ export const createExecutor = b("slug", "=", String(slug)), }); + yield* recordAuditEvent({ + action: "removed", + resourceType: "integration", + resourceId: String(slug), + }); return existing.plugin_id; }), ).pipe( @@ -3165,6 +3213,13 @@ export const createExecutor = => - Effect.gen(function* () { - yield* guardOrgWrite(ref.owner); - const row = yield* findConnectionRow(ref); - if (!row) { - return yield* new ConnectionNotFoundError({ - owner: ref.owner, - integration: ref.integration, - name: ref.name, + transaction( + Effect.gen(function* () { + yield* guardOrgWrite(ref.owner); + const row = yield* findConnectionRow(ref); + if (!row) { + return yield* new ConnectionNotFoundError({ + owner: ref.owner, + integration: ref.integration, + name: ref.name, + }); + } + const set: Record = { updated_at: new Date() }; + if (input.description !== undefined) set.description = input.description; + if (input.identityLabel !== undefined) set.identity_label = input.identityLabel; + yield* core.updateMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(ref.owner)(b), + b("integration", "=", String(ref.integration)), + b("name", "=", String(ref.name)), + ), + set, }); - } - const set: Record = { updated_at: new Date() }; - if (input.description !== undefined) set.description = input.description; - if (input.identityLabel !== undefined) set.identity_label = input.identityLabel; - yield* core.updateMany("connection", { - where: (b: AnyCb) => - b.and( - byOwner(ref.owner)(b), - b("integration", "=", String(ref.integration)), - b("name", "=", String(ref.name)), - ), - set, - }); - const updated = yield* findConnectionRow(ref); - return rowToConnection(updated ?? row); - }); + yield* recordAuditEvent({ + action: "updated", + resourceType: "connection", + resourceOwner: ref.owner, + resourceParent: String(ref.integration), + resourceId: String(ref.name), + }); + const updated = yield* findConnectionRow(ref); + return rowToConnection(updated ?? row); + }), + ); const connectionsRemove = ( ref: ConnectionRef, @@ -3463,6 +3534,13 @@ export const createExecutor = ownedKeys(owner), guardOrgWrite: (owner: Owner) => guardOrgWrite(owner), + recordAuditEvent, defaultWritableProvider, mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), connectionNameTaken: (ref) => findConnectionRow(ref).pipe(Effect.map((row) => row !== null)), @@ -5151,6 +5230,44 @@ export const createExecutor = ({ + id: row.id, + actorId: row.actor_id == null ? null : String(row.actor_id), + action: row.action as AuditEventAction, + resourceType: row.resource_type as AuditResourceType, + resourceOwner: row.resource_owner == null ? null : (row.resource_owner as Owner), + resourceParent: row.resource_parent == null ? null : String(row.resource_parent), + resourceId: String(row.resource_id), + createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + }); + + const listAuditEvents = ( + options?: AdminListAuditEventsOptions, + ): Effect.Effect => { + const { limit, offset } = normalizeAdminPaging(options); + return platformCore + .findMany("audit_event", { + where: (b: AnyCb) => + b.and( + options?.actorId === undefined ? true : b("actor_id", "=", options.actorId), + options?.action === undefined ? true : b("action", "=", options.action), + options?.resourceType === undefined + ? true + : b("resource_type", "=", options.resourceType), + options?.resourceOwner === undefined + ? true + : b("resource_owner", "=", options.resourceOwner), + ), + orderBy: [ + ["created_at", "desc"], + ["id", "desc"], + ], + limit, + offset, + }) + .pipe(Effect.map((rows) => rows.map(rowToAdminAuditEvent))); + }; + const listSubjects = ( options?: AdminListSubjectsOptions, ): Effect.Effect => { @@ -5262,6 +5379,7 @@ export const createExecutor = Effect.Effect; + readonly recordAuditEvent: (input: AuditEventInput) => Effect.Effect; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ readonly mintOAuthConnection: ( @@ -845,42 +847,54 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { yield* provider.set(ProviderItemId.make(clientSecretItemIdValue), input.clientSecret); } - yield* deps.fuma - .use("oauth_client.deleteExisting", (db) => - looseDb(db).deleteMany("oauth_client", { - where: (b: any) => - b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), - }), - ) - .pipe(Effect.catch(() => Effect.void)); - yield* deps.fuma.use("oauth_client.create", (db) => - looseDb(db).create("oauth_client", { - tenant: keys.tenant, - owner: keys.owner, - subject: keys.subject, - slug: String(input.slug), - authorization_url: input.authorizationUrl, - token_url: input.tokenUrl, - grant: input.grant, - client_id: input.clientId, - client_secret_item_id: clientSecretItemIdValue, - resource: input.resource ?? null, - origin_kind: input.origin?.kind ?? "manual", - // Recorded intent, kept for BOTH origins: a manual app registered from - // an integration's dialog stamps its integration so the picker can - // match it exactly, the same way a DCR client records the integration - // that requested it. - origin_integration: - input.origin?.integration == null ? null : String(input.origin.integration), - origin_issuer: - input.origin?.kind === "dynamic_client_registration" - ? (canonicalIssuerUrl(input.originIssuer) ?? null) - : null, - origin_redirect_uri: - input.origin?.kind === "dynamic_client_registration" - ? (input.originRedirectUri ?? null) - : null, - created_at: now, + yield* deps.fuma.transaction( + Effect.gen(function* () { + const existing = yield* deps.fuma.use("oauth_client.findExisting", (db) => + looseDb(db).findFirst("oauth_client", { + where: (b: any) => + b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), + }), + ); + yield* deps.fuma + .use("oauth_client.deleteExisting", (db) => + looseDb(db).deleteMany("oauth_client", { + where: (b: any) => + b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), + }), + ) + .pipe(Effect.catch(() => Effect.void)); + yield* deps.fuma.use("oauth_client.create", (db) => + looseDb(db).create("oauth_client", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + slug: String(input.slug), + authorization_url: input.authorizationUrl, + token_url: input.tokenUrl, + grant: input.grant, + client_id: input.clientId, + client_secret_item_id: clientSecretItemIdValue, + resource: input.resource ?? null, + origin_kind: input.origin?.kind ?? "manual", + origin_integration: + input.origin?.integration == null ? null : String(input.origin.integration), + origin_issuer: + input.origin?.kind === "dynamic_client_registration" + ? (canonicalIssuerUrl(input.originIssuer) ?? null) + : null, + origin_redirect_uri: + input.origin?.kind === "dynamic_client_registration" + ? (input.originRedirectUri ?? null) + : null, + created_at: now, + }), + ); + yield* deps.recordAuditEvent({ + action: existing ? "updated" : "created", + resourceType: "oauth_client", + resourceOwner: input.owner, + resourceId: String(input.slug), + }); }), ); return input.slug; @@ -915,16 +929,34 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); } yield* deps.guardOrgWrite(owner); - yield* deps.fuma - .use("oauth_client.delete", (db) => - looseDb(db).deleteMany("oauth_client", { - where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), - }), - ) - .pipe(Effect.asVoid); + const removed = yield* deps.fuma.transaction( + Effect.gen(function* () { + const existing = yield* deps.fuma.use("oauth_client.findForRemoval", (db) => + looseDb(db).findFirst("oauth_client", { + where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), + }), + ); + yield* deps.fuma + .use("oauth_client.delete", (db) => + looseDb(db).deleteMany("oauth_client", { + where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), + }), + ) + .pipe(Effect.asVoid); + if (existing) { + yield* deps.recordAuditEvent({ + action: "removed", + resourceType: "oauth_client", + resourceOwner: owner, + resourceId: String(slug), + }); + } + return existing !== null; + }), + ); // Best-effort: drop the secret from the provider so it isn't orphaned. const provider = deps.defaultWritableProvider(); - if (provider?.delete) { + if (removed && provider?.delete) { yield* provider .delete(ProviderItemId.make(clientSecretItemId(owner, slug))) .pipe(Effect.catch(() => Effect.void)); diff --git a/packages/react/src/api/admin-atoms.tsx b/packages/react/src/api/admin-atoms.tsx index e72bac781..753090dc2 100644 --- a/packages/react/src/api/admin-atoms.tsx +++ b/packages/react/src/api/admin-atoms.tsx @@ -20,12 +20,26 @@ import { ReactivityKey } from "./reactivity-keys"; * 1..500 bound; the joined endpoint reads per-user connections, so a modest * page keeps that join cheap. */ export const ADMIN_USERS_PAGE_SIZE = 25; +export const ADMIN_AUDIT_EVENTS_PAGE_SIZE = 50; export interface AdminUsersPage { readonly limit: number; readonly offset: number; } +export interface AdminAuditEventsPage { + readonly limit: number; + readonly offset: number; +} + +export const adminAuditEventsAtom = Atom.family((page: AdminAuditEventsPage) => + AdminApiClient.query("adminUsers", "listAuditEvents", { + query: { limit: page.limit + 1, offset: page.offset }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.adminUsers], + }), +); + /** * One page of users joined with their connections — what the list renders. * diff --git a/packages/react/src/lib/admin-users-display.test.ts b/packages/react/src/lib/admin-users-display.test.ts index fad73a339..45f82feba 100644 --- a/packages/react/src/lib/admin-users-display.test.ts +++ b/packages/react/src/lib/admin-users-display.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "@effect/vitest"; import type { IntegrationSlug } from "@executor-js/sdk/shared"; import { + adminAuditActorLabel, + adminAuditResourceLabel, + adminAuditScopeLabel, adminUserCopyableEmail, adminUserTitle, connectionHealthStatus, @@ -18,6 +21,41 @@ import { type AdminConnectionRow, } from "./admin-users-display"; +describe("audit activity display", () => { + it("names actors without inventing an identity for system events", () => { + expect( + adminAuditActorLabel({ + actorId: "user_1", + actorEmail: "admin@example.test", + actorDisplayName: "Admin", + }), + ).toBe("admin@example.test"); + expect(adminAuditActorLabel({ actorId: null, actorEmail: null, actorDisplayName: null })).toBe( + "System", + ); + }); + + it("renders safe resource identifiers and personal versus workspace scope", () => { + expect( + adminAuditResourceLabel({ + resourceType: "connection", + resourceParent: "github", + resourceId: "main", + }), + ).toBe("Connection: github / main"); + expect( + adminAuditResourceLabel({ + resourceType: "oauth_client", + resourceParent: null, + resourceId: "workspace-app", + }), + ).toBe("OAuth app: workspace-app"); + expect(adminAuditScopeLabel("user")).toBe("Personal"); + expect(adminAuditScopeLabel("org")).toBe("Workspace"); + expect(adminAuditScopeLabel(null)).toBe("Workspace"); + }); +}); + const slug = (value: string): IntegrationSlug => value as IntegrationSlug; /** A catalog row for a normal, connectable integration. `kind` is the owning diff --git a/packages/react/src/lib/admin-users-display.ts b/packages/react/src/lib/admin-users-display.ts index 41ba53564..696f9bfe4 100644 --- a/packages/react/src/lib/admin-users-display.ts +++ b/packages/react/src/lib/admin-users-display.ts @@ -263,6 +263,38 @@ export const connectLinkUrl = ( return org ? `${base}/${org}/connect/${integration}` : `${base}/connect/${integration}`; }; +// ── Audit activity ───────────────────────────────────────────────────────── + +export interface AdminAuditActorRow { + readonly actorId: string | null; + readonly actorEmail: string | null; + readonly actorDisplayName: string | null; +} + +/** Human-readable actor, with the stable id retained as the final fallback. */ +export const adminAuditActorLabel = (event: AdminAuditActorRow): string => + event.actorEmail ?? event.actorDisplayName ?? event.actorId ?? "System"; + +export const adminAuditResourceLabel = (event: { + readonly resourceType: "connection" | "integration" | "oauth_client"; + readonly resourceParent: string | null; + readonly resourceId: string; +}): string => { + const kind = + event.resourceType === "oauth_client" + ? "OAuth app" + : event.resourceType === "integration" + ? "Integration" + : "Connection"; + const identifier = event.resourceParent + ? `${event.resourceParent} / ${event.resourceId}` + : event.resourceId; + return `${kind}: ${identifier}`; +}; + +export const adminAuditScopeLabel = (owner: Owner | null): string => + owner === "user" ? "Personal" : "Workspace"; + // ── Paging ────────────────────────────────────────────────────────────────── /** diff --git a/packages/react/src/pages/admin-users.tsx b/packages/react/src/pages/admin-users.tsx index 1fb2fdc40..39afa4b6d 100644 --- a/packages/react/src/pages/admin-users.tsx +++ b/packages/react/src/pages/admin-users.tsx @@ -9,7 +9,9 @@ import type { HealthStatus, Integration, IntegrationSlug } from "@executor-js/sd import { useIntegrationPlugins } from "@executor-js/sdk/client"; import { + ADMIN_AUDIT_EVENTS_PAGE_SIZE, ADMIN_USERS_PAGE_SIZE, + adminAuditEventsAtom, adminUserConnectionsAtom, adminUsersWithConnectionsAtom, } from "../api/admin-atoms"; @@ -18,6 +20,7 @@ import { ownerLabel } from "../api/owner-display"; import { Button } from "../components/button"; import { CopyButton } from "../components/copy-button"; import { ErrorState } from "../components/error-state"; +import { FilterTabs } from "../components/filter-tabs"; import { IntegrationFavicon, integrationInferredUrl, @@ -33,6 +36,9 @@ import { } from "../components/sheet"; import { Skeleton } from "../components/skeleton"; import { + adminAuditActorLabel, + adminAuditResourceLabel, + adminAuditScopeLabel, adminUserCopyableEmail, adminUserTitle, connectionHealthStatus, @@ -55,6 +61,7 @@ import { } from "../lib/health-display"; import { isAsyncResultLoading } from "../lib/async-result"; import { useExecutorDocumentTitle } from "../lib/document-title"; +import { formatRelativeTime } from "../lib/relative-time"; // --------------------------------------------------------------------------- // Admin · Users — the tenant-wide operator view. @@ -523,8 +530,142 @@ function UserDetail(props: { // ── Page ──────────────────────────────────────────────────────────────────── +type AdminAuditEventRow = { + readonly id: string; + readonly actorId: string | null; + readonly actorEmail: string | null; + readonly actorDisplayName: string | null; + readonly action: "created" | "updated" | "removed"; + readonly resourceType: "connection" | "integration" | "oauth_client"; + readonly resourceOwner: "org" | "user" | null; + readonly resourceParent: string | null; + readonly resourceId: string; + readonly createdAt: number; +}; + +const auditActionLabel = (action: AdminAuditEventRow["action"]): string => + `${action.slice(0, 1).toUpperCase()}${action.slice(1)}`; + +function AuditActivity() { + const [offset, setOffset] = useState(0); + const page = { limit: ADMIN_AUDIT_EVENTS_PAGE_SIZE, offset }; + const result = useAtomValue(adminAuditEventsAtom(page)); + const refresh = useAtomRefresh(adminAuditEventsAtom(page)); + const loading = ( +
+ {[0, 1, 2, 3].map((row) => ( + + ))} +
+ ); + + if (isAsyncResultLoading(result)) return loading; + return AsyncResult.match(result, { + onInitial: () => loading, + onFailure: (failure) => + isAccessDenied(failure.cause) ? ( + + ) : ( + + ), + onSuccess: ({ value }) => { + const { rows, hasNext } = splitPage(value.events, ADMIN_AUDIT_EVENTS_PAGE_SIZE); + if (rows.length === 0) { + return ( +
+

+ {offset === 0 ? "No activity yet" : "No activity on this page"} +

+

+ {offset === 0 + ? "Connection, integration, and OAuth app changes will appear here." + : "Go back a page to see earlier workspace activity."} +

+
+ ); + } + + return ( + <> +
+
+ When + Actor + Action + Resource + Scope +
+ {rows.map((event: AdminAuditEventRow) => ( +
+ + {formatRelativeTime(event.createdAt)} + + + {adminAuditActorLabel(event)} + + + {auditActionLabel(event.action)} + + + {adminAuditResourceLabel(event)} + + + {adminAuditScopeLabel(event.resourceOwner)} + +
+ ))} +
+ + {(hasNext || offset > 0) && ( +
+ + Page {pageNumber(offset, ADMIN_AUDIT_EVENTS_PAGE_SIZE)} + +
+ + +
+
+ )} + + ); + }, + }); +} + export function AdminUsersPage() { useExecutorDocumentTitle("Users"); + const [view, setView] = useState<"users" | "activity">("users"); const [offset, setOffset] = useState(0); const [selected, setSelected] = useState(null); @@ -559,115 +700,128 @@ export function AdminUsersPage() { {header} - {isAsyncResultLoading(result) - ? loading - : AsyncResult.match(result, { - onInitial: () => loading, - onFailure: (failure) => - isAccessDenied(failure.cause) ? ( - - ) : ( - - ), - onSuccess: ({ value }) => { - const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE); - - if (rows.length === 0) { - return ( -
-

- {offset === 0 ? "No users yet" : "No users on this page"} -

-

- {offset === 0 - ? "A user appears here the first time they reach this workspace or connect an account." - : "Go back a page to see this workspace's users."} -

- {offset > 0 && ( + + + {view === "activity" ? ( + + ) : isAsyncResultLoading(result) ? ( + loading + ) : ( + AsyncResult.match(result, { + onInitial: () => loading, + onFailure: (failure) => + isAccessDenied(failure.cause) ? ( + + ) : ( + + ), + onSuccess: ({ value }) => { + const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE); + + if (rows.length === 0) { + return ( +
+

+ {offset === 0 ? "No users yet" : "No users on this page"} +

+

+ {offset === 0 + ? "A user appears here the first time they reach this workspace or connect an account." + : "Go back a page to see this workspace's users."} +

+ {offset > 0 && ( + + )} +
+ ); + } + + return ( + <> +
+
+ User + Created + Last seen + Connections +
+ {rows.map((user: AdminUserRow) => ( + // oxlint-disable-next-line react/forbid-elements + + ))} +
+ + {(hasNext || offset > 0) && ( +
+ + Page {pageNumber(offset, ADMIN_USERS_PAGE_SIZE)} + +
- )} -
- ); - } - - return ( - <> -
-
- User - Created - Last seen - Connections -
- {rows.map((user: AdminUserRow) => ( - // oxlint-disable-next-line react/forbid-elements - - ))} -
- - {(hasNext || offset > 0) && ( -
- - Page {pageNumber(offset, ADMIN_USERS_PAGE_SIZE)} - -
- - -
+ Next +
- )} - - ); - }, - })} +
+ )} + + ); + }, + }) + )} !open && setSelected(null)}> From 5aa84106649db8aceac7c95791ccd827260cb464 Mon Sep 17 00:00:00 2001 From: Max schwenk Date: Thu, 27 Aug 2026 16:33:55 -0400 Subject: [PATCH 3/9] Use text columns for audit events --- .../drizzle/0016_fantastic_colleen_wing.sql | 16 +++++++------- apps/cloud/drizzle/meta/0016_snapshot.json | 14 ++++++------- apps/cloud/src/db/executor-schema.ts | 14 ++++++------- .../adapters/kysely/migration/introspect.ts | 10 +++++++-- packages/core/fumadb/src/schema/create.ts | 2 +- packages/core/fumadb/test/uuid.test.ts | 21 +++++++++++++++++++ packages/core/sdk/src/core-schema.ts | 18 +++++++++------- 7 files changed, 63 insertions(+), 32 deletions(-) diff --git a/apps/cloud/drizzle/0016_fantastic_colleen_wing.sql b/apps/cloud/drizzle/0016_fantastic_colleen_wing.sql index b3bfef57e..bb5b67360 100644 --- a/apps/cloud/drizzle/0016_fantastic_colleen_wing.sql +++ b/apps/cloud/drizzle/0016_fantastic_colleen_wing.sql @@ -1,14 +1,14 @@ CREATE TABLE "audit_event" ( - "id" varchar(255) NOT NULL, - "actor_id" varchar(255), - "action" varchar(255) NOT NULL, - "resource_type" varchar(255) NOT NULL, - "resource_owner" varchar(255), + "id" text NOT NULL, + "actor_id" text, + "action" text NOT NULL, + "resource_type" text NOT NULL, + "resource_owner" text, "resource_parent" text, "resource_id" text NOT NULL, "created_at" timestamp NOT NULL, - "row_id" varchar(255) PRIMARY KEY NOT NULL, - "tenant" varchar(255) NOT NULL + "row_id" text PRIMARY KEY NOT NULL, + "tenant" text NOT NULL ); --> statement-breakpoint -CREATE UNIQUE INDEX "audit_event_uidx" ON "audit_event" USING btree ("tenant","created_at","id"); \ No newline at end of file +CREATE UNIQUE INDEX "audit_event_uidx" ON "audit_event" USING btree ("tenant","created_at","id"); diff --git a/apps/cloud/drizzle/meta/0016_snapshot.json b/apps/cloud/drizzle/meta/0016_snapshot.json index 4294e1ea6..45b9d0f23 100644 --- a/apps/cloud/drizzle/meta/0016_snapshot.json +++ b/apps/cloud/drizzle/meta/0016_snapshot.json @@ -265,31 +265,31 @@ "columns": { "id": { "name": "id", - "type": "varchar(255)", + "type": "text", "primaryKey": false, "notNull": true }, "actor_id": { "name": "actor_id", - "type": "varchar(255)", + "type": "text", "primaryKey": false, "notNull": false }, "action": { "name": "action", - "type": "varchar(255)", + "type": "text", "primaryKey": false, "notNull": true }, "resource_type": { "name": "resource_type", - "type": "varchar(255)", + "type": "text", "primaryKey": false, "notNull": true }, "resource_owner": { "name": "resource_owner", - "type": "varchar(255)", + "type": "text", "primaryKey": false, "notNull": false }, @@ -313,13 +313,13 @@ }, "row_id": { "name": "row_id", - "type": "varchar(255)", + "type": "text", "primaryKey": true, "notNull": true }, "tenant": { "name": "tenant", - "type": "varchar(255)", + "type": "text", "primaryKey": false, "notNull": true } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 34ed0db12..fa141e9ce 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -52,19 +52,19 @@ export const subject = pgTable( export const audit_event = pgTable( "audit_event", { - id: varchar("id", { length: 255 }).notNull(), - actor_id: varchar("actor_id", { length: 255 }), - action: varchar("action", { length: 255 }).notNull(), - resource_type: varchar("resource_type", { length: 255 }).notNull(), - resource_owner: varchar("resource_owner", { length: 255 }), + id: text("id").notNull(), + actor_id: text("actor_id"), + action: text("action").notNull(), + resource_type: text("resource_type").notNull(), + resource_owner: text("resource_owner"), resource_parent: text("resource_parent"), resource_id: text("resource_id").notNull(), created_at: timestamp("created_at").notNull(), - row_id: varchar("row_id", { length: 255 }) + row_id: text("row_id") .primaryKey() .notNull() .$defaultFn(() => createId()), - tenant: varchar("tenant", { length: 255 }).notNull(), + tenant: text("tenant").notNull(), }, (table) => [uniqueIndex("audit_event_uidx").on(table.tenant, table.created_at, table.id)], ); diff --git a/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts b/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts index 212a7258f..185b18bb5 100644 --- a/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts +++ b/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts @@ -181,13 +181,19 @@ export async function introspectSchema( let col: AnyColumn; if (isPrimaryKey) { - if (!columnType.startsWith("varchar") && columnType !== "uuid") + if ( + !columnType.startsWith("varchar") && + columnType !== "string" && + columnType !== "uuid" + ) throw new Error( - `ID column only supports varchar and uuid at the moment, found ${columnType}.` + `ID column only supports string, varchar, and uuid at the moment, found ${columnType}.` ); if (columnType === "uuid") { col = idColumn(dbColumn.name, "uuid"); + } else if (columnType === "string") { + col = idColumn(dbColumn.name, "string"); } else { col = idColumn(dbColumn.name, columnType as `varchar(${number})`); } diff --git a/packages/core/fumadb/src/schema/create.ts b/packages/core/fumadb/src/schema/create.ts index 98dff5286..dd8597e12 100644 --- a/packages/core/fumadb/src/schema/create.ts +++ b/packages/core/fumadb/src/schema/create.ts @@ -344,7 +344,7 @@ type DefaultFunction = | (Type extends keyof DefaultFunctionMap ? DefaultFunctionMap[Type] : never) | (() => TypeMap[Type]); -type IdColumnType = `varchar(${number})` | "uuid"; +type IdColumnType = `varchar(${number})` | "string" | "uuid"; export type TypeMap = { string: string; diff --git a/packages/core/fumadb/test/uuid.test.ts b/packages/core/fumadb/test/uuid.test.ts index 48bce926e..2eb5fd905 100644 --- a/packages/core/fumadb/test/uuid.test.ts +++ b/packages/core/fumadb/test/uuid.test.ts @@ -10,6 +10,12 @@ test("idColumn accepts uuid type", () => { expect(col.id).toBe(true); }); +test("idColumn accepts unbounded string type", () => { + const col = idColumn("id", "string").defaultTo$("auto"); + expect(col.type).toBe("string"); + expect(col.id).toBe(true); +}); + test("column accepts uuid type", () => { const col = column("token", "uuid"); expect(col.type).toBe("uuid"); @@ -90,6 +96,21 @@ test("Drizzle SQLite generates UUID schema correctly", () => { expect(generated).toContain("primaryKey()"); }); +test("Drizzle PostgreSQL generates a text primary id correctly", () => { + const stringIdSchema = schema({ + version: "1.0.0", + tables: { + audit: table("audit", { + id: idColumn("id", "string").defaultTo$("auto"), + }), + }, + }); + + const generated = Drizzle.generateSchema(stringIdSchema, "postgresql"); + expect(generated).toContain('text("id")'); + expect(generated).toContain("primaryKey()"); +}); + test("TypeORM generates UUID schema correctly", () => { const generated = TypeORM.generateSchema(uuidSchema, "postgresql"); diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 4ebeb206a..a3e559df8 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -57,11 +57,12 @@ const tenantExecutorTable = ( name: string, columns: TColumns, uniqueKey: readonly string[], + keyStorage: "varchar(255)" | "string" = "varchar(255)", ) => { const out = table(name, { ...columns, - row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"), - tenant: keyColumn("tenant"), + row_id: idColumn("row_id", keyStorage).defaultTo$("auto"), + tenant: column("tenant", keyStorage), }); out.unique(`${name}_uidx`, [...uniqueKey]); return out.policy({ @@ -204,11 +205,11 @@ export const coreTables = defineTables({ audit_event: tenantExecutorTable( "audit_event", { - id: keyColumn("id"), - actor_id: nullableKeyColumn("actor_id"), - action: keyColumn("action"), - resource_type: keyColumn("resource_type"), - resource_owner: nullableKeyColumn("resource_owner"), + id: textColumn("id"), + actor_id: nullableTextColumn("actor_id"), + action: textColumn("action"), + resource_type: textColumn("resource_type"), + resource_owner: nullableTextColumn("resource_owner"), resource_parent: nullableTextColumn("resource_parent"), resource_id: textColumn("resource_id"), created_at: dateColumn("created_at"), @@ -217,6 +218,9 @@ export const coreTables = defineTables({ // globally unique in practice and remains the final tie-breaker for events // written in the same millisecond. ["tenant", "created_at", "id"], + // Audit identifiers are unbounded text by design. This table is currently + // backed by PostgreSQL/SQLite, both of which index text values directly. + "string", ), // THE saved credential, one per (owner, integration, name). Resolves each named From bd097ced4ddfb23734ef19c01a4bd00fce138377 Mon Sep 17 00:00:00 2001 From: Max schwenk Date: Thu, 27 Aug 2026 17:21:46 -0400 Subject: [PATCH 4/9] Require admins to add connections --- .changeset/workspace-writes-admin-only.md | 7 +-- apps/host-selfhost/src/multi-user.test.ts | 15 +++++- packages/core/sdk/src/errors.ts | 10 ++-- packages/core/sdk/src/executor.ts | 35 +++++++------ packages/core/sdk/src/oauth-service.ts | 13 +++-- packages/core/sdk/src/org-writes.test.ts | 40 +++++++++------ .../plugins/mcp/src/react/McpSignInButton.tsx | 4 +- .../react/src/components/accounts-section.tsx | 49 ++++++++++--------- packages/react/src/lib/admin-access.test.ts | 17 ++++++- packages/react/src/lib/admin-access.ts | 7 +++ .../react/src/multiplayer/use-admin-nav.tsx | 14 +++++- .../react/src/pages/connect-integration.tsx | 25 +++++++++- .../react/src/pages/integration-detail.tsx | 25 +++++----- 13 files changed, 174 insertions(+), 87 deletions(-) diff --git a/.changeset/workspace-writes-admin-only.md b/.changeset/workspace-writes-admin-only.md index 32730a130..e33647b17 100644 --- a/.changeset/workspace-writes-admin-only.md +++ b/.changeset/workspace-writes-admin-only.md @@ -12,9 +12,10 @@ The executor binding gains `orgWrites: "allowed" | "denied"`. Hosts derive it from the acting member's role (cloud: WorkOS membership role; self-host: Better Auth org membership role), and a plain member's binding refuses every user-intent workspace-level mutation with the new `OrgWriteDeniedError` -(HTTP 403): org-owned tool policies, workspace-shared connections, org OAuth -apps and org connect flows, and integration-catalog changes (add, update, -remove, health check). +(HTTP 403): all new connections (Personal and Workspace), org-owned tool +policies, org OAuth apps, and integration-catalog changes (add, update, remove, +health check). The console removes add/connect affordances for non-admins and +explains that a workspace admin is required. Using workspace resources is unchanged for members: reads, tool execution over shared connections, and the operational writes those imply (token refresh, diff --git a/apps/host-selfhost/src/multi-user.test.ts b/apps/host-selfhost/src/multi-user.test.ts index 78d222c57..178f56da4 100644 --- a/apps/host-selfhost/src/multi-user.test.ts +++ b/apps/host-selfhost/src/multi-user.test.ts @@ -149,8 +149,8 @@ test("multiple accounts share one org but isolate per-user connections", async ( // The integration is tenant-scoped; register it once. expect((await addIntegration(alice, "tiny")).status).toBe(200); - // A plain member cannot register integrations or mint workspace-shared - // connections — 403 from the executor's workspace-write gate. + // A plain member cannot register integrations or mint connections in either + // scope — 403 from the executor's workspace-write gate. expect((await addIntegration(bob, "tiny2")).status).toBe(403); expect( ( @@ -163,6 +163,17 @@ test("multiple accounts share one org but isolate per-user connections", async ( }) ).status, ).toBe(403); + expect( + ( + await createConnection(bob, { + owner: "user", + name: "bob-private", + integration: "tiny", + template: "bearer", + value: "bob-token", + }) + ).status, + ).toBe(403); // Alice attaches a USER-owned connection (private to her) and an ORG-owned // connection (shared across the tenant). diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index 0b44f6ea6..b9ee2bda7 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -142,10 +142,10 @@ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass => config.orgWrites === "denied" && (owner === undefined || owner === "org") ? Effect.fail(new OrgWriteDeniedError()) @@ -3075,7 +3076,9 @@ export const createExecutor = => Effect.gen(function* () { - yield* guardOrgWrite(input.owner); + // This API creates or replaces connection credentials. Both scopes are + // admin-only; metadata updates and removal use their own, narrower APIs. + yield* guardOrgWrite(); const name = connectionIdentifier(String(input.name)); // Typed (not StorageError) so the HTTP edge can answer 400 with the // reason instead of an opaque 500 — callers can act on it. @@ -4965,7 +4968,7 @@ export const createExecutor = ownedKeys(owner), - guardOrgWrite: (owner: Owner) => guardOrgWrite(owner), + guardOrgWrite: (owner?: Owner) => guardOrgWrite(owner), recordAuditEvent, defaultWritableProvider, mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 0620871fc..bf094b0f0 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -189,9 +189,9 @@ export interface OAuthServiceDeps { readonly subject: string; }; /** Workspace-settings gate from the executor binding - * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the - * user-intent client/connect surfaces. */ - readonly guardOrgWrite: (owner: Owner) => Effect.Effect; + * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets, or every + * connection create when called without an owner. */ + readonly guardOrgWrite: (owner?: Owner) => Effect.Effect; readonly recordAuditEvent: (input: AuditEventInput) => Effect.Effect; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ @@ -1344,10 +1344,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input: OAuthStartInput, ): Effect.Effect => Effect.gen(function* () { - // Gate BEFORE any session row or upstream exchange: minting a Workspace - // connection (including a reconnect that would replace its credential) - // is a workspace-level change. - yield* deps.guardOrgWrite(input.owner); + // Starting OAuth mints or re-mints connection credentials. Gate before + // reading client state, creating a session, or making an upstream call. + yield* deps.guardOrgWrite(); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), catch: (cause) => diff --git a/packages/core/sdk/src/org-writes.test.ts b/packages/core/sdk/src/org-writes.test.ts index c549ea4ad..1372f273b 100644 --- a/packages/core/sdk/src/org-writes.test.ts +++ b/packages/core/sdk/src/org-writes.test.ts @@ -21,10 +21,10 @@ import { makeTestConfig } from "./testing"; // // A `"denied"` binding (a plain member) may USE workspace resources — read // them, execute tools over org connections — but every user-intent -// workspace-level mutation refuses with `OrgWriteDeniedError`: org-owned -// policies / connections / OAuth clients, and the tenant-shared integration -// catalog. `"allowed"` (admins, and hosts with no role model) behaves exactly -// as before. +// workspace-level mutation refuses with `OrgWriteDeniedError`: all new +// connections, org-owned policies / OAuth clients, and the tenant-shared +// integration catalog. `"allowed"` (admins, and hosts with no role model) +// behaves exactly as before. // // The fixtures build TWO executors over ONE test database: an admin // (default `orgWrites`) that seeds the workspace, and a member @@ -119,7 +119,7 @@ describe("orgWrites: denied", () => { }).pipe(Effect.scoped), ); - it.effect("refuses org connections but accepts personal ones", () => + it.effect("refuses new workspace and personal connections", () => Effect.gen(function* () { const { admin, member } = yield* setup(); yield* expectOrgWriteDenied( @@ -131,14 +131,15 @@ describe("orgWrites: denied", () => { value: "org-token", }), ); - const personal = yield* member.connections.create({ - owner: "user", - name: ConnectionName.make("mine"), - integration: INTEG, - template: TEMPLATE, - value: "user-token", - }); - expect(personal.owner).toBe("user"); + yield* expectOrgWriteDenied( + member.connections.create({ + owner: "user", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + value: "user-token", + }), + ); const shared = yield* admin.connections.create({ owner: "org", @@ -197,7 +198,7 @@ describe("orgWrites: denied", () => { }).pipe(Effect.scoped), ); - it.effect("refuses org OAuth clients and org connect flows", () => + it.effect("refuses org OAuth clients and all new connect flows", () => Effect.gen(function* () { const { member } = yield* setup(); yield* expectOrgWriteDenied( @@ -235,6 +236,17 @@ describe("orgWrites: denied", () => { clientSecret: "", }); expect(String(slug)).toBe("my-app"); + yield* expectOrgWriteDenied( + member.oauth.start({ + owner: "user", + clientOwner: "user", + client: slug, + integration: INTEG, + template: TEMPLATE, + name: ConnectionName.make("mine"), + newConnection: true, + }), + ); }).pipe(Effect.scoped), ); }); diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx index 13c2252af..dcbda19ed 100644 --- a/packages/plugins/mcp/src/react/McpSignInButton.tsx +++ b/packages/plugins/mcp/src/react/McpSignInButton.tsx @@ -12,6 +12,7 @@ import { connectionsAllAtom } from "@executor-js/react/api/atoms"; import { AddAccountModal } from "@executor-js/react/components/add-account-modal"; import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; +import { useCanCreateConnections } from "@executor-js/react/multiplayer/use-admin-nav"; import { mcpServerAtom } from "./atoms"; import type { McpAuthMethod } from "../sdk/types"; @@ -34,6 +35,7 @@ export default function McpSignInButton(props: { integrationId: string; owner?: const serverResult = useAtomValue(mcpServerAtom(slug)); const connectionsResult = useAtomValue(connectionsAllAtom); const [modalOpen, setModalOpen] = useState(false); + const canCreateConnections = useCanCreateConnections(); const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null; const remote = server !== null && server.config.transport === "remote" ? server.config : null; @@ -77,7 +79,7 @@ export default function McpSignInButton(props: { integrationId: string; owner?: [modalOpen, oauthMethod, server, slug, targetOwner], ); - if (oauthMethod === null) return null; + if (oauthMethod === null || !canCreateConnections) return null; return ( <> diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index f0ec361da..bd14ba8d3 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -19,6 +19,7 @@ import { useConnectionHealth } from "../lib/use-connection-health"; import { messageFromExit } from "../api/error-reporting"; import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; import { trackEvent } from "../api/analytics"; +import { useCanCreateConnections } from "../multiplayer/use-admin-nav"; import type { AuthMethod } from "../lib/auth-placements"; import { connectionNeedsReconsent, @@ -86,6 +87,7 @@ function AccountRow(props: { * reconnect to grant the newly-needed access (e.g. after a service was added). */ readonly needsReconsent: boolean; readonly showOwnerLabel: boolean; + readonly canReconnect: boolean; readonly onEdit: () => void; readonly onReconnect: () => void; readonly onRemove: () => void; @@ -213,9 +215,11 @@ function AccountRow(props: { Edit - - Reconnect - + {props.canReconnect ? ( + + Reconnect + + ) : null} Remove @@ -230,6 +234,7 @@ function OwnerAccounts(props: { readonly integration: IntegrationSlug; readonly owner: Owner; readonly showOwnerLabels: boolean; + readonly canCreateConnections: boolean; readonly methods: readonly AuthMethod[]; readonly onEdit: (connection: Connection) => void; readonly onDcrReconnect: (connection: Connection) => void; @@ -392,6 +397,7 @@ function OwnerAccounts(props: { connection={connection} needsReconsent={connectionNeedsReconsent(connection, props.declaredScopes)} showOwnerLabel={props.showOwnerLabels} + canReconnect={props.canCreateConnections || reconnectMode(connection) !== "oauth"} onEdit={() => props.onEdit(connection)} onReconnect={() => void handleReconnect(connection)} onRemove={() => setRemovingConnection(connection)} @@ -453,13 +459,15 @@ export function AccountsSection(props: { const [editingConnection, setEditingConnection] = useState(null); const [reconnectHandoff, setReconnectHandoff] = useState(null); const ownerDisplay = useOwnerDisplay(); - const canAddConnection = methods.length > 0 || createCustomMethod !== undefined; + const canCreateConnections = useCanCreateConnections(); + const canAddConnection = + canCreateConnections && (methods.length > 0 || createCustomMethod !== undefined); useEffect(() => { - if (accountHandoff) { + if (accountHandoff && canAddConnection) { setAdding(true); } - }, [accountHandoff]); + }, [accountHandoff, canAddConnection]); // The integration's declared oauth scopes — what connections need granted. A // connection granted fewer is flagged to reconnect (e.g. after a service was @@ -531,14 +539,8 @@ export function AccountsSection(props: {

Connections

- {!showEmptyState ? ( - ) : null} @@ -553,17 +555,15 @@ export function AccountsSection(props: {

No connections yet

- Add a connection to make this integration's tools available. + {canCreateConnections + ? "Add a connection to make this integration's tools available." + : "Ask a workspace admin to add a connection for this integration."}

- + {canAddConnection ? ( + + ) : null}
) : (
@@ -573,6 +573,7 @@ export function AccountsSection(props: { integration={integration} owner={owner} showOwnerLabels={ownerDisplay.showOwnerLabels} + canCreateConnections={canCreateConnections} methods={methods} onEdit={setEditingConnection} onDcrReconnect={(connection: Connection) => { diff --git a/packages/react/src/lib/admin-access.test.ts b/packages/react/src/lib/admin-access.test.ts index 47a3cc7df..890710681 100644 --- a/packages/react/src/lib/admin-access.test.ts +++ b/packages/react/src/lib/admin-access.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { isTenantAdminMember, type TenantMemberRow } from "./admin-access"; +import { + canCreateConnectionsForHost, + isTenantAdminMember, + type TenantMemberRow, +} from "./admin-access"; const member = (overrides: Partial = {}): TenantMemberRow => ({ role: "member", @@ -50,3 +54,14 @@ describe("isTenantAdminMember", () => { expect(isTenantAdminMember([member({ role: "billing", isCurrentUser: true })])).toBe(false); }); }); + +describe("canCreateConnectionsForHost", () => { + it("allows connection creation on single-user hosts", () => { + expect(canCreateConnectionsForHost(null, false)).toBe(true); + }); + + it("allows organization admins and refuses organization members", () => { + expect(canCreateConnectionsForHost("org_123", true)).toBe(true); + expect(canCreateConnectionsForHost("org_123", false)).toBe(false); + }); +}); diff --git a/packages/react/src/lib/admin-access.ts b/packages/react/src/lib/admin-access.ts index 433b4aa68..57d5237f9 100644 --- a/packages/react/src/lib/admin-access.ts +++ b/packages/react/src/lib/admin-access.ts @@ -44,3 +44,10 @@ export const isTenantAdminMember = (members: readonly TenantMemberRow[]): boolea (member) => member.isCurrentUser && member.status === "active" && TENANT_ADMIN_ROLES.has(member.role), ); + +/** Connection creation is unrestricted on single-user hosts. Organization + * hosts require the active member to be an admin or owner. */ +export const canCreateConnectionsForHost = ( + organizationId: string | null, + isTenantAdmin: boolean, +): boolean => organizationId === null || isTenantAdmin; diff --git a/packages/react/src/multiplayer/use-admin-nav.tsx b/packages/react/src/multiplayer/use-admin-nav.tsx index c29dee29e..c44a2eb30 100644 --- a/packages/react/src/multiplayer/use-admin-nav.tsx +++ b/packages/react/src/multiplayer/use-admin-nav.tsx @@ -2,7 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { orgMembersAtom } from "../api/account-atoms"; -import { isTenantAdminMember, type TenantMemberRow } from "../lib/admin-access"; +import { useOrganizationId } from "../api/organization-context"; +import { + canCreateConnectionsForHost, + isTenantAdminMember, + type TenantMemberRow, +} from "../lib/admin-access"; import type { ShellNavItem } from "./shell"; // --------------------------------------------------------------------------- @@ -38,6 +43,13 @@ export const useIsTenantAdmin = (): boolean => { }); }; +/** Whether this host and active role allow adding connection credentials. */ +export const useCanCreateConnections = (): boolean => { + const organizationId = useOrganizationId(); + const isAdmin = useIsTenantAdmin(); + return canCreateConnectionsForHost(organizationId, isAdmin); +}; + /** * Append admin-only nav items to a host's nav, for admins only. * diff --git a/packages/react/src/pages/connect-integration.tsx b/packages/react/src/pages/connect-integration.tsx index e1a8c2bcb..55b7ef6f8 100644 --- a/packages/react/src/pages/connect-integration.tsx +++ b/packages/react/src/pages/connect-integration.tsx @@ -9,6 +9,7 @@ import { ErrorState } from "../components/error-state"; import { Skeleton } from "../components/skeleton"; import { isAsyncResultLoading } from "../lib/async-result"; import { useExecutorDocumentTitle } from "../lib/document-title"; +import { useCanCreateConnections } from "../multiplayer/use-admin-nav"; // --------------------------------------------------------------------------- // `/connect/` — the stable, shareable deep link that drops a @@ -34,6 +35,7 @@ export function ConnectIntegrationPage(props: { readonly integrationSlug: string const integration = useAtomValue(integrationAtom(slug)); const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); const navigate = useNavigate(); + const canCreateConnections = useCanCreateConnections(); useExecutorDocumentTitle("Connect"); @@ -46,7 +48,7 @@ export function ConnectIntegrationPage(props: { readonly integrationSlug: string const missing = !loading && !failed && resolved === null; useEffect(() => { - if (resolved === null) return; + if (resolved === null || !canCreateConnections) return; void navigate({ to: "/{-$orgSlug}/integrations/$namespace", params: { namespace: String(resolved.slug) }, @@ -56,7 +58,7 @@ export function ConnectIntegrationPage(props: { readonly integrationSlug: string search: { tab: "accounts", addAccount: 1 }, replace: true, }); - }, [resolved, navigate]); + }, [resolved, navigate, canCreateConnections]); if (failed) { return ( @@ -87,6 +89,25 @@ export function ConnectIntegrationPage(props: { readonly integrationSlug: string ); } + if (resolved !== null && !canCreateConnections) { + return ( + +
+

Workspace admin required

+

+ Ask a workspace admin to add a connection for {resolved.name}. +

+ + Back to integrations + +
+
+ ); + } + // Loading, or resolved and mid-redirect: the same quiet placeholder, so the // hand-off never flashes an empty frame between catalog load and navigation. return ( diff --git a/packages/react/src/pages/integration-detail.tsx b/packages/react/src/pages/integration-detail.tsx index 0f2c0d1bd..c4623788a 100644 --- a/packages/react/src/pages/integration-detail.tsx +++ b/packages/react/src/pages/integration-detail.tsx @@ -44,6 +44,7 @@ import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; import { useConnectionsHealth } from "../lib/use-connection-health"; +import { useCanCreateConnections } from "../multiplayer/use-admin-nav"; import { integrationDetailInternalTabFromSearch, type IntegrationDetailInternalTab, @@ -91,6 +92,7 @@ export function IntegrationDetailPage(props: { // Workspace (org) rules, preserving the prior default behavior. const policyActions = usePolicyActions("org"); const navigate = useNavigate(); + const canCreateConnections = useCanCreateConnections(); // HMR: refresh integration tools when the backend is hot-reloaded useEffect(() => { @@ -453,6 +455,7 @@ export function IntegrationDetailPage(props: { }; const handleOpenAddConnection = () => { + if (!canCreateConnections) return; setActiveTab("accounts"); setManualAccountHandoff({ key: `manual:${String(slug)}:${Date.now()}` }); }; @@ -628,7 +631,8 @@ export function IntegrationDetailPage(props: { ) : !isBuiltInIntegration && integrationConnections.length === 0 ? ( 0} + canCreateConnections={canCreateConnections} + canAddConnection={canCreateConnections && accountsMethods.length > 0} /> ) : hasToolSyncIssue ? ( void; + readonly canCreateConnections: boolean; readonly canAddConnection: boolean; }) { return ( @@ -679,17 +684,15 @@ function NoConnectionToolsEmptyState(props: {

No tools yet

- Add a connection to unlock this integration's tools. + {props.canCreateConnections + ? "Add a connection to unlock this integration's tools." + : "Ask a workspace admin to add a connection for this integration."}

- + {props.canAddConnection ? ( + + ) : null}
); From 3794932f2210ebbb8bc426849d4d2714acdd5e44 Mon Sep 17 00:00:00 2001 From: Max schwenk Date: Thu, 27 Aug 2026 17:26:17 -0400 Subject: [PATCH 5/9] Update member connection E2E --- e2e/selfhost/admin-users-console.test.ts | 60 +++++++++++++++++------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/e2e/selfhost/admin-users-console.test.ts b/e2e/selfhost/admin-users-console.test.ts index fccdef925..273c0102a 100644 --- a/e2e/selfhost/admin-users-console.test.ts +++ b/e2e/selfhost/admin-users-console.test.ts @@ -94,17 +94,20 @@ scenario( yield* Effect.ensuring( Effect.gen(function* () { - // The member stores their own credential, so the owner's view has - // something to report that the owner's product view cannot see. - yield* memberClient.connections.create({ - payload: { - owner: "user", - name: memberConnection, - integration, - template: TEMPLATE_API_KEY, - value: "member-personal-token", - }, - }); + // Connection creation is admin-only even for Personal scope. The + // refused request still sights this principal for the admin directory. + const refusal = yield* memberClient.connections + .create({ + payload: { + owner: "user", + name: memberConnection, + integration, + template: TEMPLATE_API_KEY, + value: "member-personal-token", + }, + }) + .pipe(Effect.flip); + expect(refusal).toMatchObject({ _tag: "OrgWriteDeniedError" }); yield* browser.session(owner, async ({ page, step }) => { await step("Open Users from the sidebar as the instance owner", async () => { @@ -119,14 +122,12 @@ scenario( .waitFor({ state: "visible", timeout: 30_000 }); }); - await step("The invited member's connection is attributed to them", async () => { + await step("The invited member is listed without a connection", async () => { // Selfhost shares one org across scenarios, so this asserts the // member's own row exists — never a count of the whole instance. const row = page .locator("[data-slot='admin-user-row']") - .filter({ - has: page.locator(`[data-integration='${integration}'][data-connected='true']`), - }) + .filter({ hasText: member.credentials?.email ?? "" }) .first(); await row.waitFor({ state: "visible", timeout: 30_000 }); @@ -144,9 +145,10 @@ scenario( const detail = page.getByRole("dialog"); await detail.waitFor({ state: "visible", timeout: 30_000 }); - await detail - .getByText(memberConnection, { exact: true }) - .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await detail.getByText(memberConnection, { exact: true }).count(), + "the refused credential was not stored", + ).toBe(0); }); await step("The detail header copies the member's email and their id", async () => { @@ -312,6 +314,28 @@ scenario( "the refusal replaces the table rather than rendering it empty", ).toBe(0); }); + + await step("A member has no add-connection affordance", async () => { + await visit(page, `/integrations/${availableIntegration}?tab=accounts`); + await page + .getByText("Ask a workspace admin to add a connection for this integration.") + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await page.getByRole("button", { name: "Add connection" }).count(), + "the accounts surface does not advertise an action the API refuses", + ).toBe(0); + }); + + await step("A member's connect deep link stops at the admin explanation", async () => { + await visit(page, `/connect/${availableIntegration}`); + await page + .getByText("Workspace admin required", { exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + new URL(page.url()).pathname.endsWith(`/connect/${availableIntegration}`), + "the deep link does not enter the add-account flow", + ).toBe(true); + }); }); }), Effect.all( From a7f7157c4f3ffd36e164b0187eee5e86ec8c214e Mon Sep 17 00:00:00 2001 From: Max schwenk Date: Thu, 27 Aug 2026 17:32:42 -0400 Subject: [PATCH 6/9] Update cloud connection E2Es --- e2e/cloud/admin-users-console.test.ts | 77 +++++++++++--------- e2e/cloud/connect-link-multi-org.test.ts | 88 ++++++----------------- e2e/cloud/spec-update-convergence.test.ts | 17 +++-- 3 files changed, 74 insertions(+), 108 deletions(-) diff --git a/e2e/cloud/admin-users-console.test.ts b/e2e/cloud/admin-users-console.test.ts index d5eaa0521..dae325047 100644 --- a/e2e/cloud/admin-users-console.test.ts +++ b/e2e/cloud/admin-users-console.test.ts @@ -9,9 +9,9 @@ // access rather than shown an empty workspace. // // Two members are built through the REAL flows (login → create-organization → -// invite → accept-invitation, in `./support/session`) and each connects their -// own credential, so the two rows differ in what they've connected and the -// summary has something to be right about. +// invite → accept-invitation, in `./support/session`). The admin connects their +// own credential; the plain member's attempt is refused, so the directory also +// proves the new admin-only connection rule is reflected honestly. import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; @@ -91,8 +91,7 @@ scenario( const adminId = yield* accountIdOf(target, admin); const memberId = yield* accountIdOf(target, member); - // Two integrations so the summary has a real available-vs-connected split: - // each member connects one, so each row shows one connected and one not. + // Two integrations so the member's summary has a real zero-of-two state. const connectedIntegration = yield* registerIntegration(adminClient, "admin-ui-conn"); const availableIntegration = yield* registerIntegration(adminClient, "admin-ui-avail"); const adminConnection = freshConnectionName(); @@ -100,9 +99,7 @@ scenario( yield* Effect.ensuring( Effect.gen(function* () { - // Each member stores their OWN credential. Neither can see the other's - // through the product plane — the admin page is the only surface that - // reports both. + // The admin may store a Personal credential; the plain member may not. yield* adminClient.connections.create({ payload: { owner: "user", @@ -112,15 +109,18 @@ scenario( value: "admin-personal-token", }, }); - yield* memberClient.connections.create({ - payload: { - owner: "user", - name: memberConnection, - integration: connectedIntegration, - template: TEMPLATE_API_KEY, - value: "member-personal-token", - }, - }); + const refusal = yield* memberClient.connections + .create({ + payload: { + owner: "user", + name: memberConnection, + integration: connectedIntegration, + template: TEMPLATE_API_KEY, + value: "member-personal-token", + }, + }) + .pipe(Effect.flip); + expect(refusal).toMatchObject({ _tag: "OrgWriteDeniedError" }); // ── The admin's view ──────────────────────────────────────────────── yield* browser.session(forBrowser(admin), async ({ page, step }) => { @@ -189,17 +189,17 @@ scenario( await summary.waitFor({ state: "visible", timeout: 30_000 }); expect( await summary.textContent(), - "one of the two connectable integrations, with the built-in out of both numbers", - ).toBe("1/2"); + "neither connectable integration is connected, with the built-in out of both numbers", + ).toBe("0/2"); expect( await memberRow.locator("[data-integration='executor']").count(), "the built-in integration has no connect flow, so it gets no slot", ).toBe(0); expect( await memberRow - .locator(`[data-integration='${connectedIntegration}'][data-connected='true']`) + .locator(`[data-integration='${connectedIntegration}'][data-connected='false']`) .count(), - "the integration this member connected is lit in their summary", + "the member's refused credential is not lit in their summary", ).toBe(1); expect( await memberRow @@ -209,7 +209,7 @@ scenario( ).toBe(1); }); - await step("Open the member's detail and read their connections", async () => { + await step("Open the member's detail and confirm no credential was stored", async () => { await page .locator("[data-slot='admin-user-row']") .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) }) @@ -217,15 +217,10 @@ scenario( const detail = page.getByRole("dialog"); await detail.waitFor({ state: "visible", timeout: 30_000 }); - // Their own connection, by name, with the shared health vocabulary. - await detail - .getByText(memberConnection, { exact: true }) - .waitFor({ state: "visible", timeout: 30_000 }); - // Never probed, so the honest verdict is Unchecked — not Healthy. expect( - await detail.getByLabel("Status: Unchecked").count(), - "a never-probed connection reads as unchecked, not healthy", - ).toBe(1); + await detail.getByText(memberConnection, { exact: true }).count(), + "the refused connection is absent", + ).toBe(0); // The other member's credential is not this member's business. expect( await detail.getByText(adminConnection, { exact: true }).count(), @@ -285,12 +280,12 @@ scenario( .count(), "the org-free form is never rendered on a host that has orgs", ).toBe(0); - // Exactly one: the member connected one of the two connectable - // integrations, and the built-in offers no link at all. + // Both connectable integrations are available; the built-in still + // offers no link at all. expect( await detail.getByRole("button", { name: "Copy link" }).count(), "one link per not-connected connectable integration, and none for the built-in", - ).toBe(1); + ).toBe(2); expect( await detail.getByText("/connect/executor", { exact: false }).count(), "the built-in integration is never offered as a connect link", @@ -362,6 +357,22 @@ scenario( ).toBe(0); }, ); + + await step("Connection creation is not offered to the member", async () => { + await visit(page, `/${slug}/integrations/${availableIntegration}?tab=accounts`); + await page + .getByText("Ask a workspace admin to add a connection for this integration.") + .waitFor({ state: "visible", timeout: 30_000 }); + expect(await page.getByRole("button", { name: "Add connection" }).count()).toBe(0); + }); + + await step("Their connect deep link stops at the admin explanation", async () => { + await visit(page, `/${slug}/connect/${availableIntegration}`); + await page + .getByText("Workspace admin required", { exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); + expect(new URL(page.url()).pathname).toBe(`/${slug}/connect/${availableIntegration}`); + }); }); }), Effect.all( diff --git a/e2e/cloud/connect-link-multi-org.test.ts b/e2e/cloud/connect-link-multi-org.test.ts index b268aec78..dc54cb3ea 100644 --- a/e2e/cloud/connect-link-multi-org.test.ts +++ b/e2e/cloud/connect-link-multi-org.test.ts @@ -16,8 +16,9 @@ // same place. `packages/react/src/routes/connect-deep-link.test.ts` only proves // the router parses the param. Neither has a second org to land in by mistake. // -// So: a recipient who is a member of TWO orgs, whose session defaults to org B, -// follows an org-A-scoped link, and the credential must end up in org A. +// So: a recipient who is a plain member of TWO orgs, whose session defaults to +// org B, follows an org-A-scoped link. The request must resolve in org A and +// stop at the admin-required state there — never enter a connection flow in B. // // Both orgs register an integration under the SAME slug — that is what makes // this a real test. With distinct slugs, org B's catalog would simply not @@ -41,8 +42,6 @@ import { activeOrg, forBrowser, joinOrg, organizationsOf } from "./support/sessi const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; -/** The spec's title, which the console uses to name a saved connection - * ("Personal Ping API"). */ const INTEGRATION_TITLE = "Ping API"; /** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ @@ -75,7 +74,7 @@ const registerIntegration = (client: Client, slug: IntegrationSlug) => }); scenario( - "Connect · an org-scoped connect link lands a multi-org recipient in the SENDING org", + "Connect · an org-scoped connect link resolves a multi-org recipient in the SENDING org", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; @@ -136,90 +135,43 @@ scenario( // connections open, so idle is not a state this page reaches. The // real wait is the redirect assertion below. await page.goto(connectLink, { waitUntil: "domcontentloaded" }); - // The deep link forwards into the integration detail route with the - // add-account handoff — and it must keep ORG A's prefix through the - // redirect. Landing on `/${orgB.slug}/...` here IS the bug. - await page.waitForURL((url) => url.pathname === `/${orgA.slug}/integrations/${slug}`, { - timeout: 30_000, - }); - expect( - new URL(page.url()).pathname.split("/").filter(Boolean)[0], - "the connect flow stayed in the SENDING org, not the session default", - ).toBe(orgA.slug); - expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1"); - }); - - await step("Complete the connection from that page", async () => { - const dialog = page.getByRole("dialog"); - await dialog.getByRole("heading", { name: /Add connection/ }).waitFor({ - timeout: 30_000, - }); - // The credential field is labelled by the method's PLACEMENT (the - // `authorization` header this spec declares), not by the variable. - // Waited for explicitly: the field renders only once the modal has - // loaded the integration's auth methods, which is a second fetch - // after the heading appears. - const credential = dialog.getByRole("textbox", { name: "authorization" }); - await credential.waitFor({ state: "visible", timeout: 90_000 }); - await credential.fill("recipient-personal-token"); - // The offered health check is opt-in (it runs only on "Check"), and - // this spec's base URL is never served — so the credential is saved - // unprobed, which is what this scenario is about: WHERE it lands, - // not whether it works. - await dialog.getByRole("button", { name: "Continue" }).click(); - await dialog.getByRole("button", { name: "Add connection" }).click(); - // The saved credential appears as a row in the accounts list of the - // page it was saved from — and that page is ORG A's (its URL was - // pinned to `orgA.slug` in the step above). So this row IS the - // "landed in the sending workspace" half of the guarantee, read - // from the surface the recipient is actually looking at. - // - // Waited on rather than the success toast (which auto-dismisses - // below the fold) or the dialog's disappearance (which races the - // close animation). + // A plain member cannot add a connection, so the deep link stays on + // its honest refusal state. The org-A prefix still proves the link + // resolved against the SENDING workspace rather than session org B. await page - .getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }) - .first() - .waitFor({ state: "visible", timeout: 90_000 }); + .getByText("Workspace admin required", { exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); expect( new URL(page.url()).pathname.split("/").filter(Boolean)[0], - "the credential was saved from a page scoped to the SENDING org", + "the refusal is scoped to the SENDING org, not the session default", ).toBe(orgA.slug); + expect( + new URL(page.url()).pathname, + "the member never enters the integration add-account route", + ).toBe(`/${orgA.slug}/connect/${slug}`); }); // ── The other half: it is NOT in the session's default org ───────── // // Same person, same session, same integration slug — the only thing // that differs is the org in the URL. Org B registered the SAME slug, - // so this page exists and renders; it simply must hold no connection. - // Had the link resolved against the session default, THIS is the page - // the credential would be on. - await step("Org B — the session's own default — has none", async () => { + // so this page exists and renders its own admin-required state too. + await step("Org B — the session's own default — also offers no add action", async () => { await page.goto(`${origin}/${orgB.slug}/integrations/${slug}?tab=accounts`, { waitUntil: "domcontentloaded", }); - // Wait for the accounts panel to finish loading before asserting an - // absence, so "not rendered yet" cannot pass as "not there". The - // empty state is the positive signal that the list resolved AND is - // empty — checking only for the missing row would also pass while - // the list was still loading. - await page - .getByRole("button", { name: "Add connection" }) - .first() - .waitFor({ state: "visible", timeout: 90_000 }); await page - .getByText("No connections", { exact: false }) + .getByText("Ask a workspace admin to add a connection for this integration.") .first() .waitFor({ state: "visible", timeout: 90_000 }); expect( - await page.getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }).count(), - "nothing landed in the org the recipient's session happened to default to", + await page.getByRole("button", { name: "Add connection" }).count(), + "the member cannot add in the session-default org either", ).toBe(0); }); }); }), - // Removing each org's spec takes its connections with it, so the - // UI-created credential (whose name the console chose) needs no lookup. + // Remove the same-slug fixtures from both organizations. Effect.all( [ ownerAClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), diff --git a/e2e/cloud/spec-update-convergence.test.ts b/e2e/cloud/spec-update-convergence.test.ts index 0a9ca4964..0ce761313 100644 --- a/e2e/cloud/spec-update-convergence.test.ts +++ b/e2e/cloud/spec-update-convergence.test.ts @@ -1,5 +1,5 @@ // Cloud-only (needs real multi-user organizations): when one member refreshes -// a shared integration's spec, a DIFFERENT member's OWN connection converges to +// a shared integration's spec, a DIFFERENT admin's OWN connection converges to // the new tool catalog on that member's next read — not just the editor's. // // This is the lazy-convergence design. Tools are stored per connection. The @@ -143,15 +143,16 @@ const withRefreshedSession = ( }; }; -/** Invite `member` into `admin`'s org and accept — the real invite flow. - * The member's requests inherit the admin's org selector (org-scoped reads +/** Invite a co-admin into `admin`'s org and accept — the real invite flow. + * Their requests inherit the admin's org selector (org-scoped reads * fail closed without the header). */ -const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => +const joinOrgAsAdmin = (target: TargetShape, admin: Identity, member: Identity) => Effect.gen(function* () { const adminSelector = admin.headers?.[ORG_SELECTOR_HEADER]; if (!adminSelector) throw new Error("admin identity carries no org selector header"); const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { email: member.credentials?.email, + roleSlug: "admin", }); const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { @@ -185,7 +186,7 @@ const ownToolNames = (client: Client, integration: IntegrationSlug) => ); scenario( - "Convergence · a spec refresh reaches a co-worker's own connection on their next read", + "Convergence · a spec refresh reaches a co-admin's own connection on their next read", {}, Effect.scoped( Effect.gen(function* () { @@ -194,7 +195,7 @@ scenario( const admin = yield* target.newIdentity(); const invitee = yield* target.newIdentity({ org: false }); - const colleague = yield* joinOrg(target, admin, invitee); + const colleague = yield* joinOrgAsAdmin(target, admin, invitee); const adminClient = yield* client(api, admin); const colleagueClient = yield* client(api, colleague); @@ -216,7 +217,9 @@ scenario( }, }); - // Each member binds their OWN personal connection to it. + // Both admins bind their OWN personal connection to it. Plain members + // cannot create connections; this scenario needs two subjects with + // credentials to exercise lazy cross-subject convergence. yield* personalConnection(adminClient, slug, adminConn); yield* personalConnection(colleagueClient, slug, colleagueConn); From 2f575b4d74a777035c4c8d85a53474f97e753918 Mon Sep 17 00:00:00 2001 From: Max schwenk Date: Thu, 27 Aug 2026 17:39:21 -0400 Subject: [PATCH 7/9] Update admin user inventory E2E --- e2e/cloud/admin-users.test.ts | 49 +++++++++++++---------------------- 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/e2e/cloud/admin-users.test.ts b/e2e/cloud/admin-users.test.ts index 70bf366b9..55b563c3b 100644 --- a/e2e/cloud/admin-users.test.ts +++ b/e2e/cloud/admin-users.test.ts @@ -4,12 +4,12 @@ // member of the tenant instead of binding to one. // // Two members are built through the REAL flows (login → create-organization → -// invite → accept-invitation), each connects their own credential, and the -// admin then reads the joined view — the exact shape a customer dashboard's -// icon grid consumes. The guarantees pinned here: +// invite → accept-invitation), the admin connects a credential, and then reads +// the joined view — the exact shape a customer dashboard's icon grid consumes. +// The guarantees pinned here: // -// 1. the joined view reports BOTH members and each one's own connections, -// even though no single product-view caller can see another member's; +// 1. the joined view reports BOTH members and their connection inventories, +// including the plain member's empty inventory; // 2. it never carries credential material; // 3. a plain member is refused (403) and an anonymous caller too (401); // 4. another tenant's admin sees none of it. @@ -75,7 +75,7 @@ const registerIntegration = (client: Client) => const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); scenario( - "Admin · the owner sees every member of the workspace and what each has connected", + "Admin · the owner sees every member and each member's connection inventory", {}, Effect.gen(function* () { const target = yield* Target; @@ -92,12 +92,13 @@ scenario( const integration = yield* registerIntegration(adminClient); const adminConnection = freshConnectionName(); - const memberConnection = freshConnectionName(); yield* Effect.ensuring( Effect.gen(function* () { - // Each member stores their OWN credential. Neither can see the other's - // through the product plane — that is the whole point of the admin one. + // Only admins can add credentials. The member still appears in the + // administrative inventory with an empty connection list after their + // first read registers their Executor-side subject row. + yield* memberClient.connections.list({ query: {} }); yield* adminClient.connections.create({ payload: { owner: "user", @@ -107,19 +108,9 @@ scenario( value: "admin-personal-token", }, }); - yield* memberClient.connections.create({ - payload: { - owner: "user", - name: memberConnection, - integration, - template: TEMPLATE_API_KEY, - value: "member-personal-token", - }, - }); - const client = yield* apiClient(AdminUsersHttpApi, admin); - // (1) The joined view: both members, each with their own connection. + // (1) The joined view: both members and their connection inventories. const joined = yield* client.adminUsers.listUsersWithConnections({ query: {} }); const byId = new Map(joined.users.map((user) => [user.externalId, user])); @@ -132,8 +123,8 @@ scenario( ).toContain(adminConnection); expect( byId.get(memberId)?.connections.map((connection) => connection.name), - "the member's connection is visible to the owner, though not to the admin's product view", - ).toContain(memberConnection); + "the member is represented even though they cannot add connections", + ).toEqual([]); // The host identity join: the WorkOS email lands on the right row. // @@ -166,8 +157,8 @@ scenario( }); expect( memberConnections.connections.map((connection) => connection.name), - "the member's connections are readable by external id", - ).toContain(memberConnection); + "the member's empty inventory is readable by external id", + ).toEqual([]); // The single-user read, addressed by EMAIL, against real WorkOS-shaped // identity: the reverse directory lookup (email → user id) has to agree @@ -181,8 +172,8 @@ scenario( expect(single.user.email, "and carries the identity the list reported").toBe(memberEmail); expect( single.user.connections.map((connection) => connection.name), - "with their connections joined in the same response", - ).toContain(memberConnection); + "with their empty inventory joined in the same response", + ).toEqual([]); // The same read by opaque id agrees, so neither identifier is a // different code path with a different answer. @@ -207,8 +198,7 @@ scenario( expect( JSON.stringify(joined), "the admin view never carries a stored credential", - ).not.toContain("member-personal-token"); - expect(JSON.stringify(joined)).not.toContain("admin-personal-token"); + ).not.toContain("admin-personal-token"); // (3) A plain member is refused: this plane reports on everyone. const asMember = yield* Effect.promise(() => @@ -229,9 +219,6 @@ scenario( adminClient.connections .remove({ params: { owner: "user", integration, name: adminConnection } }) .pipe(Effect.ignore), - memberClient.connections - .remove({ params: { owner: "user", integration, name: memberConnection } }) - .pipe(Effect.ignore), adminClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore), ], { discard: true }, From 84e45abf105edab6a0482f927e9c4dc4528b328e Mon Sep 17 00:00:00 2001 From: Max schwenk Date: Thu, 27 Aug 2026 18:02:27 -0400 Subject: [PATCH 8/9] Keep member connections personal --- .changeset/workspace-writes-admin-only.md | 8 +- apps/host-selfhost/src/multi-user.test.ts | 13 +-- e2e/cloud/admin-users-console.test.ts | 87 ++++++++++++------- e2e/cloud/admin-users.test.ts | 48 ++++++---- e2e/cloud/connect-link-multi-org.test.ts | 48 +++++----- e2e/cloud/spec-update-convergence.test.ts | 17 ++-- e2e/selfhost/admin-users-console.test.ts | 63 +++++++++----- packages/core/sdk/src/executor.ts | 32 ++++--- packages/core/sdk/src/oauth-service.ts | 13 +-- packages/core/sdk/src/org-writes.test.ts | 51 +++++------ .../plugins/mcp/src/react/McpSignInButton.tsx | 8 +- .../react/src/components/accounts-section.tsx | 12 +-- .../src/components/add-account-modal.tsx | 31 +++++-- .../src/components/oauth-client-form.tsx | 14 +-- packages/react/src/lib/admin-access.test.ts | 12 +-- packages/react/src/lib/admin-access.ts | 7 +- .../react/src/multiplayer/use-admin-nav.tsx | 9 +- .../react/src/pages/connect-integration.tsx | 25 +----- .../react/src/pages/integration-detail.tsx | 11 +-- .../src/plugins/connection-owner.test.ts | 21 +++++ .../react/src/plugins/connection-owner.tsx | 18 +++- 21 files changed, 322 insertions(+), 226 deletions(-) diff --git a/.changeset/workspace-writes-admin-only.md b/.changeset/workspace-writes-admin-only.md index e33647b17..7044e77a8 100644 --- a/.changeset/workspace-writes-admin-only.md +++ b/.changeset/workspace-writes-admin-only.md @@ -12,10 +12,10 @@ The executor binding gains `orgWrites: "allowed" | "denied"`. Hosts derive it from the acting member's role (cloud: WorkOS membership role; self-host: Better Auth org membership role), and a plain member's binding refuses every user-intent workspace-level mutation with the new `OrgWriteDeniedError` -(HTTP 403): all new connections (Personal and Workspace), org-owned tool -policies, org OAuth apps, and integration-catalog changes (add, update, remove, -health check). The console removes add/connect affordances for non-admins and -explains that a workspace admin is required. +(HTTP 403): Workspace connections, org-owned tool policies, org OAuth apps and +org connect flows, and integration-catalog changes (add, update, remove, health +check). Plain members can still add and manage Personal connections; the +console removes the Workspace choice while retaining the Personal flow. Using workspace resources is unchanged for members: reads, tool execution over shared connections, and the operational writes those imply (token refresh, diff --git a/apps/host-selfhost/src/multi-user.test.ts b/apps/host-selfhost/src/multi-user.test.ts index 178f56da4..c105e4ba3 100644 --- a/apps/host-selfhost/src/multi-user.test.ts +++ b/apps/host-selfhost/src/multi-user.test.ts @@ -149,8 +149,8 @@ test("multiple accounts share one org but isolate per-user connections", async ( // The integration is tenant-scoped; register it once. expect((await addIntegration(alice, "tiny")).status).toBe(200); - // A plain member cannot register integrations or mint connections in either - // scope — 403 from the executor's workspace-write gate. + // A plain member cannot register integrations or mint Workspace connections, + // but may still add a Personal credential. expect((await addIntegration(bob, "tiny2")).status).toBe(403); expect( ( @@ -173,7 +173,7 @@ test("multiple accounts share one org but isolate per-user connections", async ( value: "bob-token", }) ).status, - ).toBe(403); + ).toBe(200); // Alice attaches a USER-owned connection (private to her) and an ORG-owned // connection (shared across the tenant). @@ -209,13 +209,16 @@ test("multiple accounts share one org but isolate per-user connections", async ( aliceConns.some((a) => a.includes("org") && a.includes(connectionName("team-shared"))), ).toBe(true); - // Bob — a different user in the SAME org — sees the org connection but NOT - // Alice's user-owned one. + // Bob — a different user in the SAME org — sees the org connection and his + // own Personal connection, but NOT Alice's user-owned one. const bobConns = await connectionAddresses(bob); expect(bobConns.some((a) => a.includes("org") && a.includes(connectionName("team-shared")))).toBe( true, ); expect(bobConns.some((a) => a.includes(connectionName("alice-private")))).toBe(false); + expect( + bobConns.some((a) => a.includes("user") && a.includes(connectionName("bob-private"))), + ).toBe(true); }); test("each account can execute code in its own scoped sandbox", async () => { diff --git a/e2e/cloud/admin-users-console.test.ts b/e2e/cloud/admin-users-console.test.ts index dae325047..72cb25e24 100644 --- a/e2e/cloud/admin-users-console.test.ts +++ b/e2e/cloud/admin-users-console.test.ts @@ -9,9 +9,9 @@ // access rather than shown an empty workspace. // // Two members are built through the REAL flows (login → create-organization → -// invite → accept-invitation, in `./support/session`). The admin connects their -// own credential; the plain member's attempt is refused, so the directory also -// proves the new admin-only connection rule is reflected honestly. +// invite → accept-invitation, in `./support/session`). Each connects a Personal +// credential; the plain member's Workspace attempt is refused, so the directory +// and connection UI prove the owner-aware permission boundary together. import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; @@ -39,11 +39,12 @@ declare global { } const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); +const INTEGRATION_TITLE = "Ping API"; /** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ const pingSpec = JSON.stringify({ openapi: "3.0.3", - info: { title: "Ping API", version: "1.0.0" }, + info: { title: INTEGRATION_TITLE, version: "1.0.0" }, paths: { "/ping": { get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, @@ -99,7 +100,8 @@ scenario( yield* Effect.ensuring( Effect.gen(function* () { - // The admin may store a Personal credential; the plain member may not. + // Both roles may store Personal credentials. A member cannot promote + // theirs into a Workspace credential by bypassing the owner picker. yield* adminClient.connections.create({ payload: { owner: "user", @@ -109,10 +111,10 @@ scenario( value: "admin-personal-token", }, }); - const refusal = yield* memberClient.connections + const workspaceRefusal = yield* memberClient.connections .create({ payload: { - owner: "user", + owner: "org", name: memberConnection, integration: connectedIntegration, template: TEMPLATE_API_KEY, @@ -120,7 +122,16 @@ scenario( }, }) .pipe(Effect.flip); - expect(refusal).toMatchObject({ _tag: "OrgWriteDeniedError" }); + expect(workspaceRefusal).toMatchObject({ _tag: "OrgWriteDeniedError" }); + yield* memberClient.connections.create({ + payload: { + owner: "user", + name: memberConnection, + integration: connectedIntegration, + template: TEMPLATE_API_KEY, + value: "member-personal-token", + }, + }); // ── The admin's view ──────────────────────────────────────────────── yield* browser.session(forBrowser(admin), async ({ page, step }) => { @@ -189,17 +200,17 @@ scenario( await summary.waitFor({ state: "visible", timeout: 30_000 }); expect( await summary.textContent(), - "neither connectable integration is connected, with the built-in out of both numbers", - ).toBe("0/2"); + "one connectable integration is connected, with the built-in out of both numbers", + ).toBe("1/2"); expect( await memberRow.locator("[data-integration='executor']").count(), "the built-in integration has no connect flow, so it gets no slot", ).toBe(0); expect( await memberRow - .locator(`[data-integration='${connectedIntegration}'][data-connected='false']`) + .locator(`[data-integration='${connectedIntegration}'][data-connected='true']`) .count(), - "the member's refused credential is not lit in their summary", + "the member's Personal credential is lit in their summary", ).toBe(1); expect( await memberRow @@ -209,7 +220,7 @@ scenario( ).toBe(1); }); - await step("Open the member's detail and confirm no credential was stored", async () => { + await step("Open the member's detail and confirm their Personal connection", async () => { await page .locator("[data-slot='admin-user-row']") .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) }) @@ -217,10 +228,9 @@ scenario( const detail = page.getByRole("dialog"); await detail.waitFor({ state: "visible", timeout: 30_000 }); - expect( - await detail.getByText(memberConnection, { exact: true }).count(), - "the refused connection is absent", - ).toBe(0); + await detail + .getByText(memberConnection, { exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); // The other member's credential is not this member's business. expect( await detail.getByText(adminConnection, { exact: true }).count(), @@ -280,12 +290,13 @@ scenario( .count(), "the org-free form is never rendered on a host that has orgs", ).toBe(0); - // Both connectable integrations are available; the built-in still + // Only the unconnected integration is available; the member's + // Personal connection consumes the other slot. The built-in still // offers no link at all. expect( await detail.getByRole("button", { name: "Copy link" }).count(), - "one link per not-connected connectable integration, and none for the built-in", - ).toBe(2); + "one link for the not-connected integration, and none for the built-in", + ).toBe(1); expect( await detail.getByText("/connect/executor", { exact: false }).count(), "the built-in integration is never offered as a connect link", @@ -358,20 +369,36 @@ scenario( }, ); - await step("Connection creation is not offered to the member", async () => { - await visit(page, `/${slug}/integrations/${availableIntegration}?tab=accounts`); - await page - .getByText("Ask a workspace admin to add a connection for this integration.") - .waitFor({ state: "visible", timeout: 30_000 }); - expect(await page.getByRole("button", { name: "Add connection" }).count()).toBe(0); - }); + await step( + "The member can add Personal connections without a scope dropdown", + async () => { + await visit(page, `/${slug}/integrations/${availableIntegration}?tab=accounts`); + const add = page.getByRole("button", { name: "Add connection" }); + await add.waitFor({ state: "visible", timeout: 30_000 }); + await add.click(); + const dialog = page.getByRole("dialog"); + await dialog + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await dialog.getByText("Workspace", { exact: true }).count(), + "the member is forced to Personal rather than offered a scope picker", + ).toBe(0); + await page.keyboard.press("Escape"); + }, + ); - await step("Their connect deep link stops at the admin explanation", async () => { + await step("Their connect deep link opens the Personal add flow", async () => { await visit(page, `/${slug}/connect/${availableIntegration}`); + await page.waitForURL( + (url) => url.pathname === `/${slug}/integrations/${availableIntegration}`, + { timeout: 30_000 }, + ); + expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1"); await page - .getByText("Workspace admin required", { exact: true }) + .getByRole("dialog") + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) .waitFor({ state: "visible", timeout: 30_000 }); - expect(new URL(page.url()).pathname).toBe(`/${slug}/connect/${availableIntegration}`); }); }); }), diff --git a/e2e/cloud/admin-users.test.ts b/e2e/cloud/admin-users.test.ts index 55b563c3b..2be5a2930 100644 --- a/e2e/cloud/admin-users.test.ts +++ b/e2e/cloud/admin-users.test.ts @@ -4,12 +4,12 @@ // member of the tenant instead of binding to one. // // Two members are built through the REAL flows (login → create-organization → -// invite → accept-invitation), the admin connects a credential, and then reads -// the joined view — the exact shape a customer dashboard's icon grid consumes. -// The guarantees pinned here: +// invite → accept-invitation), each connects their own Personal credential, and +// the admin then reads the joined view — the exact shape a customer dashboard's +// icon grid consumes. The guarantees pinned here: // -// 1. the joined view reports BOTH members and their connection inventories, -// including the plain member's empty inventory; +// 1. the joined view reports BOTH members and each one's own connections, +// even though no single product-view caller can see another member's; // 2. it never carries credential material; // 3. a plain member is refused (403) and an anonymous caller too (401); // 4. another tenant's admin sees none of it. @@ -75,7 +75,7 @@ const registerIntegration = (client: Client) => const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); scenario( - "Admin · the owner sees every member and each member's connection inventory", + "Admin · the owner sees every member of the workspace and what each has connected", {}, Effect.gen(function* () { const target = yield* Target; @@ -92,13 +92,12 @@ scenario( const integration = yield* registerIntegration(adminClient); const adminConnection = freshConnectionName(); + const memberConnection = freshConnectionName(); yield* Effect.ensuring( Effect.gen(function* () { - // Only admins can add credentials. The member still appears in the - // administrative inventory with an empty connection list after their - // first read registers their Executor-side subject row. - yield* memberClient.connections.list({ query: {} }); + // Each member stores their OWN Personal credential. Neither can see the + // other's through the product plane — that is the admin view's job. yield* adminClient.connections.create({ payload: { owner: "user", @@ -108,9 +107,18 @@ scenario( value: "admin-personal-token", }, }); + yield* memberClient.connections.create({ + payload: { + owner: "user", + name: memberConnection, + integration, + template: TEMPLATE_API_KEY, + value: "member-personal-token", + }, + }); const client = yield* apiClient(AdminUsersHttpApi, admin); - // (1) The joined view: both members and their connection inventories. + // (1) The joined view: both members, each with their own connection. const joined = yield* client.adminUsers.listUsersWithConnections({ query: {} }); const byId = new Map(joined.users.map((user) => [user.externalId, user])); @@ -123,8 +131,8 @@ scenario( ).toContain(adminConnection); expect( byId.get(memberId)?.connections.map((connection) => connection.name), - "the member is represented even though they cannot add connections", - ).toEqual([]); + "the member's connection is visible to the owner, though not to the admin's product view", + ).toContain(memberConnection); // The host identity join: the WorkOS email lands on the right row. // @@ -157,8 +165,8 @@ scenario( }); expect( memberConnections.connections.map((connection) => connection.name), - "the member's empty inventory is readable by external id", - ).toEqual([]); + "the member's connections are readable by external id", + ).toContain(memberConnection); // The single-user read, addressed by EMAIL, against real WorkOS-shaped // identity: the reverse directory lookup (email → user id) has to agree @@ -172,8 +180,8 @@ scenario( expect(single.user.email, "and carries the identity the list reported").toBe(memberEmail); expect( single.user.connections.map((connection) => connection.name), - "with their empty inventory joined in the same response", - ).toEqual([]); + "with their connections joined in the same response", + ).toContain(memberConnection); // The same read by opaque id agrees, so neither identifier is a // different code path with a different answer. @@ -198,7 +206,8 @@ scenario( expect( JSON.stringify(joined), "the admin view never carries a stored credential", - ).not.toContain("admin-personal-token"); + ).not.toContain("member-personal-token"); + expect(JSON.stringify(joined)).not.toContain("admin-personal-token"); // (3) A plain member is refused: this plane reports on everyone. const asMember = yield* Effect.promise(() => @@ -219,6 +228,9 @@ scenario( adminClient.connections .remove({ params: { owner: "user", integration, name: adminConnection } }) .pipe(Effect.ignore), + memberClient.connections + .remove({ params: { owner: "user", integration, name: memberConnection } }) + .pipe(Effect.ignore), adminClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore), ], { discard: true }, diff --git a/e2e/cloud/connect-link-multi-org.test.ts b/e2e/cloud/connect-link-multi-org.test.ts index dc54cb3ea..e6b6d353c 100644 --- a/e2e/cloud/connect-link-multi-org.test.ts +++ b/e2e/cloud/connect-link-multi-org.test.ts @@ -17,8 +17,8 @@ // the router parses the param. Neither has a second org to land in by mistake. // // So: a recipient who is a plain member of TWO orgs, whose session defaults to -// org B, follows an org-A-scoped link. The request must resolve in org A and -// stop at the admin-required state there — never enter a connection flow in B. +// org B, follows an org-A-scoped link. The request must open the forced-Personal +// connection flow in org A — never enter a connection flow in B. // // Both orgs register an integration under the SAME slug — that is what makes // this a real test. With distinct slugs, org B's catalog would simply not @@ -135,39 +135,45 @@ scenario( // connections open, so idle is not a state this page reaches. The // real wait is the redirect assertion below. await page.goto(connectLink, { waitUntil: "domcontentloaded" }); - // A plain member cannot add a connection, so the deep link stays on - // its honest refusal state. The org-A prefix still proves the link - // resolved against the SENDING workspace rather than session org B. - await page - .getByText("Workspace admin required", { exact: true }) - .waitFor({ state: "visible", timeout: 30_000 }); + // A plain member can add a Personal connection. The org-A prefix + // still proves the link resolved against the SENDING workspace + // rather than session org B. + await page.waitForURL((url) => url.pathname === `/${orgA.slug}/integrations/${slug}`, { + timeout: 30_000, + }); expect( new URL(page.url()).pathname.split("/").filter(Boolean)[0], - "the refusal is scoped to the SENDING org, not the session default", + "the Personal add flow is scoped to the SENDING org, not the session default", ).toBe(orgA.slug); expect( - new URL(page.url()).pathname, - "the member never enters the integration add-account route", - ).toBe(`/${orgA.slug}/connect/${slug}`); + new URL(page.url()).searchParams.get("addAccount"), + "the member enters the add-account route", + ).toBe("1"); + const dialog = page.getByRole("dialog"); + await dialog + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await dialog.getByText("Workspace", { exact: true }).count(), + "the member is forced to Personal scope", + ).toBe(0); }); // ── The other half: it is NOT in the session's default org ───────── // // Same person, same session, same integration slug — the only thing // that differs is the org in the URL. Org B registered the SAME slug, - // so this page exists and renders its own admin-required state too. - await step("Org B — the session's own default — also offers no add action", async () => { + // so this page exists and has its own Personal add flow too. + await step("Org B has a separate Personal add action", async () => { await page.goto(`${origin}/${orgB.slug}/integrations/${slug}?tab=accounts`, { waitUntil: "domcontentloaded", }); - await page - .getByText("Ask a workspace admin to add a connection for this integration.") - .first() - .waitFor({ state: "visible", timeout: 90_000 }); + const add = page.getByRole("button", { name: "Add connection" }); + await add.waitFor({ state: "visible", timeout: 90_000 }); expect( - await page.getByRole("button", { name: "Add connection" }).count(), - "the member cannot add in the session-default org either", - ).toBe(0); + new URL(page.url()).pathname.split("/").filter(Boolean)[0], + "navigating explicitly to org B changes the active add-flow scope", + ).toBe(orgB.slug); }); }); }), diff --git a/e2e/cloud/spec-update-convergence.test.ts b/e2e/cloud/spec-update-convergence.test.ts index 0ce761313..0a9ca4964 100644 --- a/e2e/cloud/spec-update-convergence.test.ts +++ b/e2e/cloud/spec-update-convergence.test.ts @@ -1,5 +1,5 @@ // Cloud-only (needs real multi-user organizations): when one member refreshes -// a shared integration's spec, a DIFFERENT admin's OWN connection converges to +// a shared integration's spec, a DIFFERENT member's OWN connection converges to // the new tool catalog on that member's next read — not just the editor's. // // This is the lazy-convergence design. Tools are stored per connection. The @@ -143,16 +143,15 @@ const withRefreshedSession = ( }; }; -/** Invite a co-admin into `admin`'s org and accept — the real invite flow. - * Their requests inherit the admin's org selector (org-scoped reads +/** Invite `member` into `admin`'s org and accept — the real invite flow. + * The member's requests inherit the admin's org selector (org-scoped reads * fail closed without the header). */ -const joinOrgAsAdmin = (target: TargetShape, admin: Identity, member: Identity) => +const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => Effect.gen(function* () { const adminSelector = admin.headers?.[ORG_SELECTOR_HEADER]; if (!adminSelector) throw new Error("admin identity carries no org selector header"); const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { email: member.credentials?.email, - roleSlug: "admin", }); const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { @@ -186,7 +185,7 @@ const ownToolNames = (client: Client, integration: IntegrationSlug) => ); scenario( - "Convergence · a spec refresh reaches a co-admin's own connection on their next read", + "Convergence · a spec refresh reaches a co-worker's own connection on their next read", {}, Effect.scoped( Effect.gen(function* () { @@ -195,7 +194,7 @@ scenario( const admin = yield* target.newIdentity(); const invitee = yield* target.newIdentity({ org: false }); - const colleague = yield* joinOrgAsAdmin(target, admin, invitee); + const colleague = yield* joinOrg(target, admin, invitee); const adminClient = yield* client(api, admin); const colleagueClient = yield* client(api, colleague); @@ -217,9 +216,7 @@ scenario( }, }); - // Both admins bind their OWN personal connection to it. Plain members - // cannot create connections; this scenario needs two subjects with - // credentials to exercise lazy cross-subject convergence. + // Each member binds their OWN personal connection to it. yield* personalConnection(adminClient, slug, adminConn); yield* personalConnection(colleagueClient, slug, colleagueConn); diff --git a/e2e/selfhost/admin-users-console.test.ts b/e2e/selfhost/admin-users-console.test.ts index 273c0102a..ddada0bed 100644 --- a/e2e/selfhost/admin-users-console.test.ts +++ b/e2e/selfhost/admin-users-console.test.ts @@ -34,11 +34,12 @@ declare global { } const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); +const INTEGRATION_TITLE = "Ping API"; /** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ const pingSpec = JSON.stringify({ openapi: "3.0.3", - info: { title: "Ping API", version: "1.0.0" }, + info: { title: INTEGRATION_TITLE, version: "1.0.0" }, paths: { "/ping": { get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, @@ -94,12 +95,12 @@ scenario( yield* Effect.ensuring( Effect.gen(function* () { - // Connection creation is admin-only even for Personal scope. The - // refused request still sights this principal for the admin directory. + // Members may add Personal credentials, but the API refuses the same + // request in Workspace scope even if they bypass the owner picker. const refusal = yield* memberClient.connections .create({ payload: { - owner: "user", + owner: "org", name: memberConnection, integration, template: TEMPLATE_API_KEY, @@ -108,6 +109,15 @@ scenario( }) .pipe(Effect.flip); expect(refusal).toMatchObject({ _tag: "OrgWriteDeniedError" }); + yield* memberClient.connections.create({ + payload: { + owner: "user", + name: memberConnection, + integration, + template: TEMPLATE_API_KEY, + value: "member-personal-token", + }, + }); yield* browser.session(owner, async ({ page, step }) => { await step("Open Users from the sidebar as the instance owner", async () => { @@ -122,12 +132,14 @@ scenario( .waitFor({ state: "visible", timeout: 30_000 }); }); - await step("The invited member is listed without a connection", async () => { + await step("The invited member is listed with their Personal connection", async () => { // Selfhost shares one org across scenarios, so this asserts the // member's own row exists — never a count of the whole instance. const row = page .locator("[data-slot='admin-user-row']") - .filter({ hasText: member.credentials?.email ?? "" }) + .filter({ + has: page.locator(`[data-integration='${integration}'][data-connected='true']`), + }) .first(); await row.waitFor({ state: "visible", timeout: 30_000 }); @@ -145,10 +157,9 @@ scenario( const detail = page.getByRole("dialog"); await detail.waitFor({ state: "visible", timeout: 30_000 }); - expect( - await detail.getByText(memberConnection, { exact: true }).count(), - "the refused credential was not stored", - ).toBe(0); + await detail + .getByText(memberConnection, { exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); }); await step("The detail header copies the member's email and their id", async () => { @@ -315,26 +326,36 @@ scenario( ).toBe(0); }); - await step("A member has no add-connection affordance", async () => { + await step("A member can add Personal connections without a scope dropdown", async () => { await visit(page, `/integrations/${availableIntegration}?tab=accounts`); - await page - .getByText("Ask a workspace admin to add a connection for this integration.") + const add = page.getByRole("button", { name: "Add connection" }); + await add.waitFor({ state: "visible", timeout: 30_000 }); + await add.click(); + const dialog = page.getByRole("dialog"); + await dialog + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) .waitFor({ state: "visible", timeout: 30_000 }); expect( - await page.getByRole("button", { name: "Add connection" }).count(), - "the accounts surface does not advertise an action the API refuses", + await dialog.getByText("Workspace", { exact: true }).count(), + "the member is forced to Personal rather than offered a scope picker", ).toBe(0); + await page.keyboard.press("Escape"); }); - await step("A member's connect deep link stops at the admin explanation", async () => { + await step("A member's connect deep link opens the Personal add flow", async () => { await visit(page, `/connect/${availableIntegration}`); + await page.waitForURL( + (url) => url.pathname.endsWith(`/integrations/${availableIntegration}`), + { timeout: 30_000 }, + ); + expect( + new URL(page.url()).searchParams.get("addAccount"), + "the deep link enters the member's forced-Personal add flow", + ).toBe("1"); await page - .getByText("Workspace admin required", { exact: true }) + .getByRole("dialog") + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) .waitFor({ state: "visible", timeout: 30_000 }); - expect( - new URL(page.url()).pathname.endsWith(`/connect/${availableIntegration}`), - "the deep link does not enter the add-account flow", - ).toBe(true); }); }); }), diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 3a41ccf30..71ffd2f12 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -732,20 +732,20 @@ export interface ExecutorConfig => config.orgWrites === "denied" && (owner === undefined || owner === "org") ? Effect.fail(new OrgWriteDeniedError()) @@ -3076,9 +3076,7 @@ export const createExecutor = => Effect.gen(function* () { - // This API creates or replaces connection credentials. Both scopes are - // admin-only; metadata updates and removal use their own, narrower APIs. - yield* guardOrgWrite(); + yield* guardOrgWrite(input.owner); const name = connectionIdentifier(String(input.name)); // Typed (not StorageError) so the HTTP edge can answer 400 with the // reason instead of an opaque 500 — callers can act on it. @@ -4968,7 +4966,7 @@ export const createExecutor = ownedKeys(owner), - guardOrgWrite: (owner?: Owner) => guardOrgWrite(owner), + guardOrgWrite: (owner: Owner) => guardOrgWrite(owner), recordAuditEvent, defaultWritableProvider, mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index bf094b0f0..c306ab288 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -189,9 +189,9 @@ export interface OAuthServiceDeps { readonly subject: string; }; /** Workspace-settings gate from the executor binding - * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets, or every - * connection create when called without an owner. */ - readonly guardOrgWrite: (owner?: Owner) => Effect.Effect; + * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the + * user-intent client/connect surfaces. */ + readonly guardOrgWrite: (owner: Owner) => Effect.Effect; readonly recordAuditEvent: (input: AuditEventInput) => Effect.Effect; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ @@ -1344,9 +1344,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input: OAuthStartInput, ): Effect.Effect => Effect.gen(function* () { - // Starting OAuth mints or re-mints connection credentials. Gate before - // reading client state, creating a session, or making an upstream call. - yield* deps.guardOrgWrite(); + // Gate before any session row or upstream exchange: minting a Workspace + // connection (including a reconnect that would replace its credential) + // is a workspace-level change. Personal connections remain member-owned. + yield* deps.guardOrgWrite(input.owner); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), catch: (cause) => diff --git a/packages/core/sdk/src/org-writes.test.ts b/packages/core/sdk/src/org-writes.test.ts index 1372f273b..dc37a7156 100644 --- a/packages/core/sdk/src/org-writes.test.ts +++ b/packages/core/sdk/src/org-writes.test.ts @@ -21,10 +21,10 @@ import { makeTestConfig } from "./testing"; // // A `"denied"` binding (a plain member) may USE workspace resources — read // them, execute tools over org connections — but every user-intent -// workspace-level mutation refuses with `OrgWriteDeniedError`: all new +// workspace-level mutation refuses with `OrgWriteDeniedError`: Workspace // connections, org-owned policies / OAuth clients, and the tenant-shared -// integration catalog. `"allowed"` (admins, and hosts with no role model) -// behaves exactly as before. +// integration catalog. Personal connections and OAuth apps remain member-owned. +// `"allowed"` (admins, and hosts with no role model) behaves exactly as before. // // The fixtures build TWO executors over ONE test database: an admin // (default `orgWrites`) that seeds the workspace, and a member @@ -119,7 +119,7 @@ describe("orgWrites: denied", () => { }).pipe(Effect.scoped), ); - it.effect("refuses new workspace and personal connections", () => + it.effect("refuses Workspace connections but accepts Personal connections", () => Effect.gen(function* () { const { admin, member } = yield* setup(); yield* expectOrgWriteDenied( @@ -131,15 +131,16 @@ describe("orgWrites: denied", () => { value: "org-token", }), ); - yield* expectOrgWriteDenied( - member.connections.create({ - owner: "user", - name: ConnectionName.make("mine"), - integration: INTEG, - template: TEMPLATE, - value: "user-token", - }), - ); + const mine = yield* member.connections.create({ + owner: "user", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + value: "user-token", + }); + const mineRef = { owner: mine.owner, integration: mine.integration, name: mine.name }; + yield* member.connections.update(mineRef, { description: "my credential" }); + yield* member.connections.remove(mineRef); const shared = yield* admin.connections.create({ owner: "org", @@ -198,7 +199,7 @@ describe("orgWrites: denied", () => { }).pipe(Effect.scoped), ); - it.effect("refuses org OAuth clients and all new connect flows", () => + it.effect("refuses org OAuth clients/connect flows but accepts Personal ones", () => Effect.gen(function* () { const { member } = yield* setup(); yield* expectOrgWriteDenied( @@ -236,17 +237,17 @@ describe("orgWrites: denied", () => { clientSecret: "", }); expect(String(slug)).toBe("my-app"); - yield* expectOrgWriteDenied( - member.oauth.start({ - owner: "user", - clientOwner: "user", - client: slug, - integration: INTEG, - template: TEMPLATE, - name: ConnectionName.make("mine"), - newConnection: true, - }), - ); + const started = yield* member.oauth.start({ + owner: "user", + clientOwner: "user", + client: slug, + integration: INTEG, + template: TEMPLATE, + name: ConnectionName.make("mine"), + newConnection: true, + }); + expect(started.status).toBe("redirect"); + yield* member.oauth.removeClient("user", slug); }).pipe(Effect.scoped), ); }); diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx index dcbda19ed..ad52292b7 100644 --- a/packages/plugins/mcp/src/react/McpSignInButton.tsx +++ b/packages/plugins/mcp/src/react/McpSignInButton.tsx @@ -12,7 +12,7 @@ import { connectionsAllAtom } from "@executor-js/react/api/atoms"; import { AddAccountModal } from "@executor-js/react/components/add-account-modal"; import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; -import { useCanCreateConnections } from "@executor-js/react/multiplayer/use-admin-nav"; +import { useCanCreateWorkspaceConnections } from "@executor-js/react/multiplayer/use-admin-nav"; import { mcpServerAtom } from "./atoms"; import type { McpAuthMethod } from "../sdk/types"; @@ -35,7 +35,7 @@ export default function McpSignInButton(props: { integrationId: string; owner?: const serverResult = useAtomValue(mcpServerAtom(slug)); const connectionsResult = useAtomValue(connectionsAllAtom); const [modalOpen, setModalOpen] = useState(false); - const canCreateConnections = useCanCreateConnections(); + const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections(); const server = AsyncResult.isSuccess(serverResult) ? serverResult.value : null; const remote = server !== null && server.config.transport === "remote" ? server.config : null; @@ -79,7 +79,9 @@ export default function McpSignInButton(props: { integrationId: string; owner?: [modalOpen, oauthMethod, server, slug, targetOwner], ); - if (oauthMethod === null || !canCreateConnections) return null; + if (oauthMethod === null || (targetOwner === "org" && !canCreateWorkspaceConnections)) { + return null; + } return ( <> diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index bd14ba8d3..53ae72f78 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -19,7 +19,7 @@ import { useConnectionHealth } from "../lib/use-connection-health"; import { messageFromExit } from "../api/error-reporting"; import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; import { trackEvent } from "../api/analytics"; -import { useCanCreateConnections } from "../multiplayer/use-admin-nav"; +import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; import type { AuthMethod } from "../lib/auth-placements"; import { connectionNeedsReconsent, @@ -459,9 +459,9 @@ export function AccountsSection(props: { const [editingConnection, setEditingConnection] = useState(null); const [reconnectHandoff, setReconnectHandoff] = useState(null); const ownerDisplay = useOwnerDisplay(); - const canCreateConnections = useCanCreateConnections(); + const canCreateWorkspaceConnections = useCanCreateWorkspaceConnections(); const canAddConnection = - canCreateConnections && (methods.length > 0 || createCustomMethod !== undefined); + methods.length > 0 || (canCreateWorkspaceConnections && createCustomMethod !== undefined); useEffect(() => { if (accountHandoff && canAddConnection) { @@ -555,9 +555,9 @@ export function AccountsSection(props: {

No connections yet

- {canCreateConnections + {canAddConnection ? "Add a connection to make this integration's tools available." - : "Ask a workspace admin to add a connection for this integration."} + : "Ask a workspace admin to configure an authentication method for this integration."}

{canAddConnection ? (