Rhys Sullivanfmtc92c57d6 months agoHistory
187 lines · 7.8 KB · Markdown
RawDownload

Scopes

Tenant isolation + the path to layered scopes.

Today: flat, one scope per executor

Every multi-tenant row carries a scope_id column. Tables whose schema declares scope_id are "scoped"; tables without it are shared across scopes by construction.

  • SDK — createExecutor({ scope: Scope, adapter, blobs, plugins }). One Scope per executor instance. scopeAdapter(rootAdapter, {...}, schema) wraps the adapter before it reaches plugin storage or the core-table writers. Every read on a scoped table gets where scope_id = scope.id ANDed in; every write gets scope_id = scope.id stamped into the payload. Tx handles passed into transaction callbacks are also wrapped, so nested writes stay inside the same scope.

  • Plugins — see a plain DBAdapter. They do not know or care about scope. Every plugin schema that wants isolation declares scope_id: { type: "string", required: true, index: true }. Forgetting the column means the adapter passes the plugin's reads/writes through unscoped (documented failure mode, not silent) — tests catch the concrete cases we care about.

  • Cloud — the WorkOS organization is the outermost scope. createScopedExecutor(scopeId, scopeName) in apps/cloud/src/services/executor.ts is called per request with { org.id, org.name } pulled from the session. One scope per request.

  • Local — apps/local/src/server/executor.ts derives a single scope id from the working directory (${basename(cwd)}-${sha256(cwd)[0..8]}). One scope per executor process.

Coverage for the invariant lives in two places: packages/core/sdk/src/ executor.test.ts (SDK-level, in-memory adapter, two executors sharing one adapter) and apps/cloud/src/services/tenant-isolation.node.test.ts (HTTP-level, real ProtectedCloudApi, real PGlite). Both exercise sources, tools, secrets, and plugin-owned source detail lookups.

The primitive already accepts layering

scopeAdapter takes a ScopeContext:

interface ScopeContext {
  readonly read: readonly string[]; // precedence-ordered, innermost first
  readonly write: string; // exactly one scope for writes
}

Today the read list is always length 1 and equals the write target. For a single scope the wrapper emits where scope_id = <id>; for multiple scopes it emits where scope_id IN (...). The shape is a list on purpose so that extending to a stack later touches one call site (createExecutor's adapter wrap) — not every plugin or every storage backend.

Read-side dedup by id (shadowing on collision) is not implemented today; no code path sees rows from more than one scope yet. When layering lands the dedup step is a thin pass on top of findMany / list results.

Write target is always a single scope. Layered writes mean "I decided my write target is workspace, not org, not user" — that's a policy decision the caller makes, not something storage guesses.

Future: layered scopes

The goal is a design like:

org → workspace → workspace-of-workspace → user

As an ordered list, not a tree. Rows stay owned by exactly one scope. Reads walk the list; on id collision the innermost wins (shadowing). Writes land in exactly one scope chosen by the caller.

Scenarios we want to support

  • Org-provided API key, team inherits. Org admin adds an OpenAPI source with its auth at the org scope. Every user in every workspace in that org sees the source and the auth. Override at any inner scope (e.g., workspace overrides the URL, user overrides nothing) by writing a row with the same id at the inner scope — the outer row is shadowed on read.

  • Workspace Gmail, per-user auth. Workspace admin adds the Gmail source at workspace scope but declines to store an oauth token there. When a user invokes Gmail, secret resolution walks the scope stack (user → workspace → org) and only finds a token if that user personally oauthed. Policy on the source row — auth_scope_mandate: "user" — forces this: secret lookup refuses to return workspace-level tokens even if one existed.

  • Local global + per-folder. ~/.executor/global holds the outer scope; the current folder's executor.jsonc holds the inner. Run executor in folder A and you see folder A's sources layered on the global set. Run in folder B, same global base, different inner.

Data model implications

  • No new columns on existing tables. scope_id is enough — each row still belongs to exactly one scope. Layering is a read-time and resolution-time concern, not a storage concern.

  • One new column on source (or whatever the public config table becomes): auth_scope_mandate: string | null. If set, secret resolution for this source refuses scope levels at or above the mandate. null means "resolve normally, inner wins."

  • No parent pointer. Do not add parent_scope_id. Parent pointers imply a tree; we want an ordered list held in memory per request. Scope identity is flat (each scope is just a string id); hierarchy is assembled by the host app per request.

API / SDK surface changes when layering lands

  • Scope becomes ScopeStack { read: readonly Scope[]; write: Scope }, or createExecutor grows a new shape and the old Scope path becomes a 1-element convenience.
  • ctx.scope.id in plugin code is an implicit "the scope" today. The three callers in executor.ts that set SecretRef.scopeId from scope.id need to read the write target instead: ctx.scope.write.id or a rename.
  • scopeAdapter signature is already list-shaped. Change the caller, not the wrapper.
  • Secret resolution (executor.secrets.get) grows a shadowing pass: walk the stack, first non-null value wins. If the source has an auth_scope_mandate, skip scopes above it.

Permissions / RBAC

Out of scope for the isolation primitive. Who can write to which scope is a host-app concern (cloud: WorkOS role + workspace membership; local: filesystem ownership). The SDK just takes the caller's declared write target on faith — the host is responsible for enforcing that it's legal.

Cloud glue

createScopedExecutor takes (scopeId, scopeName) today with the cloud passing the org id/name. When workspaces arrive:

  • The request's AuthContext grows workspaceId? and userId.
  • Cloud builds a ScopeStack from those: [user, workspace, org] read, write defaulting to the innermost writable (usually user; admin ops override).
  • createScopedExecutor becomes createScopedExecutor(scopes: ScopeStack) or a similar shape.

The storage layer and every plugin are untouched by that change.

Local glue

apps/local derives a single scope id from cwd today. Layering is additive:

  • Outer scope: ~/.executor/global (or a named profile).
  • Inner scope: the current folder, same derivation as today.
  • executor.jsonc sync writes to the inner (folder) scope by default; a --global flag or explicit file location targets the outer.

What NOT to do

  • Do not collapse source and secret into one table to simplify layering. Their scope targets are independently chosen (see the Gmail scenario).
  • Do not add a parent_scope_id column.
  • Do not bake the concept of "organization" into SDK types or storage. It's a cloud concern today that maps onto the generic scope_id; the SDK must stay scope-generic so workspace + user scopes are cheap to add.
  • Do not introduce scope-level defaults in the wrapper. The wrapper stamps and filters; shadowing lives one layer up.
  • packages/core/sdk/src/scoped-adapter.ts — the wrapper.
  • packages/core/sdk/src/core-schema.ts — scope_id on source, tool, definition, secret.
  • packages/core/sdk/src/executor.test.ts — SDK-level tenancy tests.
  • apps/cloud/src/services/tenant-isolation.node.test.ts — HTTP-level tenancy tests.
  • Per-plugin schemas all carry scope_id today: openapi, mcp, graphql, google-discovery, workos-vault.