Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/quiet-admin-unlock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@executor-js/sdk": patch
"@executor-js/api": patch
---

Check workspace write permission before redeeming an OAuth code. Preserve denied callbacks for retry after verification and expose an authorization marker to host callback pages.
3 changes: 2 additions & 1 deletion apps/cloud/src/account/account-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { UserStoreService } from "../auth/context";
import { WorkOsMirror } from "../auth/workos-mirror";
import { sessionFromSealed, type Session } from "../auth/middleware";
import { WorkOSClient } from "../auth/workos";
import { ADMIN_MFA_COOKIE } from "../auth/admin-mfa-proof";
import { AutumnService } from "../extensions/billing/service";
import { DbService } from "../db/db";
import { AccountCaller, workosAccountProvider } from "./workos-account-service";
Expand Down Expand Up @@ -69,7 +70,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{
const request = yield* HttpServerRequest.HttpServerRequest;
const cookieValue = request.cookies["wos-session"] ?? "";
const resolved = yield* workos
.authenticateSealedSession(cookieValue)
.authenticateSealedSession(cookieValue, request.cookies[ADMIN_MFA_COOKIE])
.pipe(Effect.orElseSucceed(() => null));
// The account API never re-sets the cookie, so the fallback sealed
// session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie).
Expand Down
10 changes: 9 additions & 1 deletion apps/cloud/src/account/workos-account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,15 @@
// moments ago is denied as soon as the write-through or the Events
// reconciler has landed the change.
const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) =>
org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden());
Effect.gen(function* () {
if (org.memberRole !== "admin") return yield* new AccountForbidden();
const session = yield* requireSession();
if (session.adminVerified !== true) {
return yield* new AccountForbidden({

Check failure on line 151 in apps/cloud/src/account/workos-account-service.ts

View workflow job for this annotation

GitHub Actions / Test

src/auth/mirror-feeders.node.test.ts > account service writes through to the mirror > updateMemberRole writes the role WorkOS returned

AccountForbidden: Verify your identity to use organization admin settings. ❯ Array.<anonymous> src/account/workos-account-service.ts:151:25 ❯ next ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1276:25 ❯ Object.~effect/Effect/evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1289:23 ❯ FiberImpl.runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:633:39 ❯ runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:593:22 ❯ evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1030:14 ❯ resume ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:991:15 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { _tag: 'AccountForbidden' }

Check failure on line 151 in apps/cloud/src/account/workos-account-service.ts

View workflow job for this annotation

GitHub Actions / Test

src/auth/mirror-feeders.node.test.ts > account service writes through to the mirror > removeMember tombstones the mirror row after the WorkOS delete

AccountForbidden: Verify your identity to use organization admin settings. ❯ Array.<anonymous> src/account/workos-account-service.ts:151:25 ❯ next ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1276:25 ❯ Object.~effect/Effect/evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1289:23 ❯ FiberImpl.runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:633:39 ❯ runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:593:22 ❯ evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1030:14 ❯ resume ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:991:15 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { _tag: 'AccountForbidden' }

Check failure on line 151 in apps/cloud/src/account/workos-account-service.ts

View workflow job for this annotation

GitHub Actions / Test

src/auth/mirror-feeders.node.test.ts > account service writes through to the mirror > inviteMember mirrors the pending membership WorkOS created for the invitee

AccountForbidden: Verify your identity to use organization admin settings. ❯ Array.<anonymous> src/account/workos-account-service.ts:151:25 ❯ next ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1276:25 ❯ Object.~effect/Effect/evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1289:23 ❯ FiberImpl.runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:633:39 ❯ runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:593:22 ❯ evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1030:14 ❯ resume ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:991:15 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { _tag: 'AccountForbidden' }

Check failure on line 151 in apps/cloud/src/account/workos-account-service.ts

View workflow job for this annotation

GitHub Actions / Test

src/account/org-api-key-revoke.node.test.ts > revokeOrgApiKey · provider boundary > an org admin revokes an org-owned key

AccountForbidden: Verify your identity to use organization admin settings. ❯ Array.<anonymous> src/account/workos-account-service.ts:151:25 ❯ next ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1276:25 ❯ Object.~effect/Effect/evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1289:23 ❯ FiberImpl.runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:633:39 ❯ runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:593:22 ❯ evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1030:14 ❯ resume ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:972:13 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { _tag: 'AccountForbidden' }
message: "Verify your identity to use organization admin settings.",
});
}
});

// Ownership check so an admin can't mutate a membership id from another
// org: the id must name a row the mirror holds for THIS org (any status —
Expand Down
50 changes: 9 additions & 41 deletions apps/cloud/src/admin/admin-users-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,9 @@
// Cloud admin users API — the shared, provider-neutral `AdminUsersHandlers`
// backed by a WorkOS-authorized platform view, mounted at `/api/admin/users*`.
//
// TWO credentials reach this plane, and they are the two an operator actually
// has:
// 1. an ORG-SCOPED api key -> `PlatformAuth`. The key IS the authority: WorkOS
// validated it and reported which org owns it, and there is no member
// behind it to check membership for. This is the machine credential
// (a customer's backend calling us).
// 2. an admin SESSION member -> the console. Requires the caller's mirrored
// membership (the shared `MemberDirectory` over the local membership
// mirror) to carry the `admin` role AND `active` status, matching the
// strictest existing cloud guard (`auth/handlers.ts`'s org-delete check) —
// a pending admin invite is not an admin.
// A plain member session, or a USER-scoped api key, is refused: both name one
// acting member, and this plane deliberately serves the whole tenant.
// Only an active admin browser session with a completed second factor reaches
// this plane. Bearer credentials retain ordinary product access, never cross-user
// access, even when accompanied by a verified browser cookie.
//
// The executor is built by `makePlatformExecutor` — `{ tenant, subject:
// undefined, platformView: true }` — so the reads are tenant-wide and read-only
Expand Down Expand Up @@ -54,10 +44,8 @@
} from "@executor-js/api";
import type { Executor } from "@executor-js/sdk";

import { ApiKeyService } from "../auth/api-keys";
import { UserStoreService } from "../auth/context";
import { WorkOsMirror } from "../auth/workos-mirror";
import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider";
import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization";
import { WorkOSClient } from "../auth/workos";
import { DbService } from "../db/db";
Expand All @@ -66,37 +54,19 @@
/**
* Resolve the tenant this request may read, or fail with the neutral 401/403.
*
* Returns only the organization id: nothing downstream needs to know WHICH of
* the two credentials got the caller here, and keeping the acting member out of
* the return value means no admin read can accidentally become subject-scoped.
* Returns only the authorized organization id so admin reads remain tenant-scoped.
* Exported for its test only.
*/
export const authorizeTenant = (
request: Request,
): Effect.Effect<
string,
AdminUsersUnauthorized | AdminUsersForbidden,
WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror
WorkOSClient | UserStoreService | MemberDirectory | WorkOsMirror
> =>
Effect.gen(function* () {
// (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`,
// which rejects org keys for the product plane) is what distinguishes an
// org key from a user key.
const bearer = yield* resolveBearerAuth(request).pipe(
// Every rejected-credential and infra failure collapses to one refusal:
// this plane must not report whether a key exists, belongs to another
// org, or merely lacks privilege.
Effect.catchCause(() => Effect.succeed(null)),
);
if (bearer !== null) {
if (isPlatformAuth(bearer)) return bearer.organizationId;
// A user-scoped key authenticated fine but names one member; the platform
// plane has no honest way to serve it.
return yield* new AdminUsersForbidden();
}
if (request.headers.has("authorization")) return yield* new AdminUsersForbidden();

// (2) The session path: an active admin membership in the selected org,
// read from the mirror.
const workos = yield* WorkOSClient;
const session = yield* workos
.authenticateRequest(request)
Expand All @@ -115,6 +85,7 @@
);
if (!org) return yield* new AdminUsersForbidden();
if (org.memberRole !== "admin") return yield* new AdminUsersForbidden();
if (session.adminVerified !== true) return yield* new AdminUsersForbidden();

Check failure on line 88 in apps/cloud/src/admin/admin-users-api.ts

View workflow job for this annotation

GitHub Actions / Test

src/admin/admin-users-api.node.test.ts > authorizeTenant · admin session > an active admin resolves the selected org as the tenant

{ _tag: 'AdminUsersForbidden', message: '', stack: 'AdminUsersForbidden: \n' + ' at Array.<anonymous> (/home/runner/_work/executor/executor/apps/cloud/src/admin/admin-users-api.ts:88:55)\n' + ' at Generator.next (<anonymous>)\n' + ' at Object.~effect/Effect/successCont (file:///home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/dist/internal/effect.js:868:26)\n' + ' at Object.~effect/Effect/evaluate (file:///home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/dist/internal/core.js:312:30)\n' + ' at FiberImpl.runLoop (file:///home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/dist/internal/effect.js:444:107)\n' + ' at FiberImpl.evaluate (file:///home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/dist/internal/effect.js:412:23)\n' + ' at file:///home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/dist/internal/effect.js:719:15\n' + ' at file:///home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/dist/internal/effect.js:687:50\n' + ' at processTicksAndRejections (node:internal/process/task_queues:103:5)', name: 'AdminUsersForbidden', stacks: [ { method: 'Array.<anonymous>', file: '/home/runner/_work/executor/executor/apps/cloud/src/admin/admin-users-api.ts', line: 88, column: 55 }, { line: 1276, column: 25, file: '/home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts', method: 'next' }, { line: 531, column: 30, file: '/home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/core.ts', method: 'Object.~effect/Effect/evaluate' }, { line: 633, column: 39, file: '/home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts', method: 'FiberImpl.runLoop' }, { line: 593, column: 22, file: '/home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts', method: 'runLoop' }, { line: 1030, column: 14, file: '/home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts', method: 'evaluate' }, { line: 972, column: 13, file: '/home/runner/_work/executor/executor/node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts', method: 'resume' } ] } ❯ Array.<anonymous> src/admin/admin-users-api.ts:88:55 ❯ next ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1276:25 ❯ Object.~effect/Effect/evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/core.ts:531:30 ❯ FiberImpl.runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:633:39 ❯ runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:593:22 ❯ evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1030:14 ❯ resume ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:972:13 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { _tag: 'AdminUsersForbidden' }
return org.id;
});

Expand All @@ -134,7 +105,6 @@
// way regardless of what `body` itself raises.
E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden,
| WorkOSClient
| ApiKeyService
| UserStoreService
| MemberDirectory
| WorkOsMirror
Expand Down Expand Up @@ -173,7 +143,6 @@
AdminUsersProvider,
never,
| WorkOSClient
| ApiKeyService
| UserStoreService
| MemberDirectory
| WorkOsMirror
Expand All @@ -184,7 +153,6 @@
Effect.gen(function* () {
const context = yield* Effect.context<
| WorkOSClient
| ApiKeyService
| UserStoreService
| MemberDirectory
| WorkOsMirror
Expand Down Expand Up @@ -233,15 +201,15 @@
);

// Builds the provider per request, providing it to the handlers. Long-lived
// `WorkOSClient | ApiKeyService` come from the surrounding boot context; the
// `WorkOSClient` come from the surrounding boot context; the
// per-request `DbService`/`UserStoreService`/`MemberDirectory` (and the
// execution seams built over them) are supplied by the combined
// `requestScopedMiddleware`.
const AdminUsersProviderMiddleware = HttpRouter.middleware<{
provides: AdminUsersProvider;
}>()(
Effect.gen(function* () {
const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>();
const longLived = yield* Effect.context<WorkOSClient>();
return (httpEffect) =>
Effect.gen(function* () {
// Built inside the request body so the execution seams close over the
Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { UserStoreService } from "../auth/context";
import { WorkOsMirror } from "../auth/workos-mirror";
import { DbService } from "../db/db";
import { makeAccountApiLive } from "../account/account-api";
import { AdminMfaRoutes } from "../auth/admin-mfa-routes";

import { AutumnRoutesLive } from "../extensions/billing/route";
import { CloudDocsLive } from "../extensions/docs";
Expand Down Expand Up @@ -41,6 +42,7 @@ export const makeApiLive = (
Layer.provide(requestScopedMiddleware(requestScopedLive).layer),
);
return Layer.mergeAll(
AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(requestScopedLive).layer)),
makeNonProtectedApiLive(requestScopedLive),
makeOrgApiLive(requestScopedLive),
makeAccountApiLive(requestScopedLive),
Expand Down
89 changes: 89 additions & 0 deletions apps/cloud/src/auth/admin-mfa-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Data, Effect, Option, Schema } from "effect";
import { SignJWT, jwtVerify } from "jose";

/** HttpOnly cookies used only for the administrative verification flow. */
export const ADMIN_MFA_COOKIE = "__Host-executor-admin-mfa";
/** The pending challenge is bound to the same user and WorkOS session. */
export const ADMIN_MFA_CHALLENGE_COOKIE = "__Host-executor-admin-challenge";
/** Administrative verification expires after fifteen minutes. */
export const ADMIN_MFA_TTL_SECONDS = 15 * 60;

/** A verified WorkOS session, supplied by the authentication adapter. */
export interface AdminMfaIdentity {
readonly userId: string;
readonly sessionId: string;
}

const Proof = Schema.Struct({
factorId: Schema.String,
challengeId: Schema.String,
mode: Schema.Literals(["enroll", "challenge"]),
exp: Schema.Number,
});
const decodeProof = Schema.decodeUnknownOption(Proof);

/** Signing failures are server failures; invalid input cookies are simply refused. */
export class AdminMfaProofError extends Data.TaggedError("AdminMfaProofError")<{
readonly cause: unknown;
}> {}

type Purpose = "challenge" | "verified";
const issuer = (purpose: Purpose) => `executor:admin-mfa:${purpose}`;
const key = (secret: string) => new TextEncoder().encode(secret);

/** Sign a purpose-specific, session-bound proof with an explicit expiration. */
export const signAdminMfaProof = (
secret: string,
identity: AdminMfaIdentity,
purpose: Purpose,
proof: typeof Proof.Type,
now: number,
) =>
Effect.tryPromise({
try: () =>
new SignJWT({ factorId: proof.factorId, challengeId: proof.challengeId, mode: proof.mode })
.setProtectedHeader({ alg: "HS256" })
.setIssuer(issuer(purpose))
.setSubject(identity.userId)
.setAudience(identity.sessionId)
.setIssuedAt(Math.floor(now / 1000))
.setExpirationTime(proof.exp)
.sign(key(secret)),
catch: (cause) => new AdminMfaProofError({ cause }),
});

/** Reject expired, tampered, cross-user, cross-session, and wrong-purpose proofs. */
export const readAdminMfaProof = (
secret: string,
identity: AdminMfaIdentity,
purpose: Purpose,
token: string | undefined,
now: number,
) => {
if (!token) return Effect.succeed(null);
return Effect.tryPromise({
try: () =>
jwtVerify(token, key(secret), {
algorithms: ["HS256"],
issuer: issuer(purpose),
subject: identity.userId,
audience: identity.sessionId,
requiredClaims: ["exp", "iat", "sub", "aud"],
maxTokenAge: purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS,
currentDate: new Date(now),
}),
catch: (cause) => new AdminMfaProofError({ cause }),
}).pipe(
Effect.map(({ payload }) => {
const maxAge = purpose === "challenge" ? 300 : ADMIN_MFA_TTL_SECONDS;
if (
typeof payload.iat !== "number" ||
typeof payload.exp !== "number" ||
payload.exp > payload.iat + maxAge
)
return null;
return Option.getOrNull(decodeProof(payload));
}),
Effect.catchTag("AdminMfaProofError", () => Effect.succeed(null)),
);
};
Loading
Loading