Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/workspace-writes-admin-only.md
Original file line number Diff line number Diff line change
@@ -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"`.
3 changes: 3 additions & 0 deletions apps/cloud/src/api/protected-api-key-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
}),
);
Expand Down
3 changes: 3 additions & 0 deletions apps/cloud/src/api/protected-jwt-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
}),
);
Expand Down
9 changes: 8 additions & 1 deletion apps/cloud/src/auth/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,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 };
});

// ---------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions apps/cloud/src/auth/workos-auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) =>
name: null,
avatarUrl: null,
roles: [],
orgRole: org.memberRole,
} satisfies Principal;
});

Expand Down Expand Up @@ -253,6 +254,7 @@ export const resolveBearerAuth = (
name: null,
avatarUrl: null,
roles: [],
orgRole: org.memberRole,
} satisfies Principal;
});

Expand Down Expand Up @@ -326,6 +328,7 @@ export const resolveSessionPrincipal = (request: Request) =>
name: sealedSessionDisplayName(session),
avatarUrl: session.avatarUrl ?? null,
roles: [],
orgRole: org.memberRole,
} satisfies Principal;
});

Expand Down
16 changes: 13 additions & 3 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -214,7 +214,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {
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 });
}
Expand All @@ -223,6 +229,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
organizationName: org.name,
organizationSlug: org.slug,
userId: token.userId,
orgRole: org.memberRole,
resource: token.resource,
elicitationMode: token.elicitationMode,
artifactsEnabled: token.artifactsEnabled,
Expand Down Expand Up @@ -253,7 +260,10 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
sessionMeta.userId,
sessionMeta.organizationId,
sessionMeta.organizationName,
{ mcpResource: sessionMeta.resource },
{
mcpResource: sessionMeta.resource,
orgWrites: sessionMeta.orgRole === "member" ? "denied" : "allowed",
},
).pipe(
// The metered stack tracks each execution to Autumn. It requires
// `AutumnService | DbService`; `AutumnService.Default` is provided here
Expand Down
6 changes: 5 additions & 1 deletion apps/host-selfhost/src/admin/require-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,12 @@ export interface InstanceAdmin {
* string is the common case rather than the contract. Membership in the
* privileged set is therefore tested per role, not by equality on the whole
* field — an `"owner,admin"` value must not read as neither.
*
* Exported for the identity seam: the same "who counts as an admin" answer
* decides the executor's workspace-write binding (`Principal.orgRole`), and
* there is only one place to be right about it.
*/
const isPrivileged = (role: string): boolean =>
export const isPrivileged = (role: string): boolean =>
role
.split(",")
.map((part) => part.trim())
Expand Down
24 changes: 23 additions & 1 deletion apps/host-selfhost/src/auth/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -49,13 +50,18 @@ export const betterAuthIdentityLayer: Layer.Layer<IdentityProvider, never, Bette
let resolved = yield* Effect.promise(() =>
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<string, string> = 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.
Expand All @@ -66,6 +72,21 @@ export const betterAuthIdentityLayer: Layer.Layer<IdentityProvider, never, Bette
// session hook; API-key-minted sessions carry no active org, so we
// default to the seeded org rather than rejecting with NoOrganization.
const resolvedOrganizationId = resolved.session.activeOrganizationId ?? organizationId;
// The workspace role, resolved against the INSTANCE org exactly as
// the admin gate does (require-admin.ts): the explicit
// `organizationId` query keeps a caller-controlled active org from
// answering for an org they own elsewhere. FAIL CLOSED to "member"
// — an infra fault demotes rather than escalates.
const membership = yield* Effect.tryPromise(() =>
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,
Expand All @@ -79,6 +100,7 @@ export const betterAuthIdentityLayer: Layer.Layer<IdentityProvider, never, Bette
.split(",")
.map((role) => role.trim())
.filter((role) => role.length > 0),
orgRole,
};
}),
});
Expand Down
20 changes: 20 additions & 0 deletions apps/host-selfhost/src/mcp/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -205,6 +206,24 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I
Effect.gen(function* () {
const user = yield* Effect.promise(() => 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
Expand All @@ -216,6 +235,7 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I
name: user.name ?? null,
avatarUrl: user.image ?? null,
roles: parseRoles(userRole(user)),
orgRole,
} satisfies Principal;
});

Expand Down
23 changes: 20 additions & 3 deletions apps/host-selfhost/src/multi-user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ const TINY_SPEC = JSON.stringify({
},
});

const signUp = async (email: string): Promise<string> => {
const inviteCode = await mintInviteCode(handler);
const signUp = async (email: string, role: "admin" | "member" = "member"): Promise<string> => {
const inviteCode = await mintInviteCode(handler, role);
const res = await handler(
new Request(`${BASE}/api/auth/sign-up/email`, {
method: "POST",
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions packages/core/api/src/connections/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
IntegrationSlug,
InternalError,
InvalidConnectionInputError,
OrgWriteDeniedError,
OAuthClientSlug,
Owner,
ProviderItemId,
Expand Down Expand Up @@ -195,6 +196,7 @@ export const ConnectionsApi = HttpApiGroup.make("connections")
IntegrationNotFound,
CredentialProviderNotRegistered,
InvalidConnectionInput,
OrgWriteDeniedError,
],
}),
)
Expand All @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions packages/core/api/src/integrations/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
IntegrationRemovalNotAllowedError,
IntegrationSlug,
InternalError,
OrgWriteDeniedError,
} from "@executor-js/sdk/shared";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -132,14 +133,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(
Expand Down Expand Up @@ -172,6 +173,6 @@ export const IntegrationsApi = HttpApiGroup.make("integrations")
params: IntegrationParams,
payload: SetHealthCheckPayload,
success: Schema.Struct({ ok: Schema.Boolean }),
error: [InternalError, IntegrationNotFound],
error: [InternalError, IntegrationNotFound, OrgWriteDeniedError],
}),
);
9 changes: 5 additions & 4 deletions packages/core/api/src/oauth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
OAuthSessionNotFoundError,
OAuthStartError,
OAuthState,
OrgWriteDeniedError,
Owner,
ProviderKey,
} from "@executor-js/sdk/shared";
Expand Down Expand Up @@ -259,14 +260,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(
Expand All @@ -280,14 +281,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(
Expand Down
Loading
Loading