feat(tenancy): implement workspace isolation

This commit is contained in:
Junyan Qin
2026-07-19 09:58:59 +08:00
parent 9eb292992d
commit c6f826fe2d
271 changed files with 31162 additions and 6106 deletions
+31 -14
View File
@@ -8,13 +8,21 @@ API keys can be managed through the web interface:
1. Log in to the LangBot web interface
2. Click the "API Keys" button at the bottom of the sidebar
3. Create, view, copy, or delete API keys as needed
3. Create an API key and copy its secret immediately
4. Revoke keys that are no longer needed
Database-backed API-key secrets are returned exactly once. LangBot stores only
a SHA-256 lookup hash, so an existing secret cannot be displayed or recovered
later. Each key belongs to one Workspace, has explicit permission scopes, and
may have an expiry. The Workspace is derived from the authenticated key; an
`X-Workspace-Id` header cannot redirect it to another tenant.
## Global API Key (config.yaml)
In addition to web-UI-created keys (stored in the database, prefixed `lbk_`),
LangBot supports a **global API key** defined directly in `data/config.yaml`.
This is useful for automated deployments, infrastructure-as-code, and AI agents
This is a Community-edition bootstrap option for automated deployments,
infrastructure-as-code, and AI agents
that need API/MCP access **without a login session and without creating a
database record first**.
@@ -27,10 +35,12 @@ api:
Behavior:
- When `api.global_api_key` is a non-empty string, that exact value is accepted
anywhere a normal API key is accepted — the `X-API-Key` header or
`Authorization: Bearer <key>` — across the HTTP service API **and the MCP
server**.
- In Community edition's singleton Workspace, a non-empty
`api.global_api_key` is bound to that Workspace and accepted across the HTTP
service API and the MCP server.
- The global config key is rejected when multi-Workspace SaaS mode is enabled;
SaaS automation must use a database-backed Workspace key or a closed control
plane credential.
- The global key does **not** require the `lbk_` prefix; use any sufficiently
strong secret.
- Leave it empty (`''`, the default) to disable it entirely; only database-backed
@@ -38,9 +48,10 @@ Behavior:
- Existing installs are unaffected until you add the key — config completion only
backfills top-level keys, and the lookup is defensive when the field is absent.
> **Security:** the global key is stored in plaintext in `config.yaml`. Only
> enable it on trusted/internal deployments, keep the file permissions tight,
> always serve over HTTPS, and rotate the value if it may have leaked.
> **Security:** the global key is stored in plaintext in `config.yaml` and has
> the singleton Workspace's full fixed permission set. Only enable it on
> trusted/internal Community deployments, keep file permissions tight, always
> serve over HTTPS, and rotate it if it may have leaked.
## Using API Keys
@@ -60,7 +71,9 @@ Authorization: Bearer lbk_your_api_key_here
## Available APIs
All existing LangBot APIs now support **both user token and API key authentication**. This means you can use API keys to access:
Endpoints that declare API-key authentication accept either a user token or a
Workspace API key. The key must include the permission required by the route.
This includes:
- **Model Management** - `/api/v1/provider/models/llm` and `/api/v1/provider/models/embedding`
- **Bot Management** - `/api/v1/platform/bots`
@@ -227,6 +240,11 @@ or
}
```
### 403 Forbidden
The key is valid for its Workspace but does not include the fixed permission
required by the route.
### 500 Internal Server Error
```json
@@ -240,7 +258,7 @@ or
1. **Keep API keys secure**: Store them securely and never commit them to version control
2. **Use HTTPS**: Always use HTTPS in production to encrypt API key transmission
3. **Rotate keys regularly**: Create new API keys periodically and delete old ones
3. **Rotate keys regularly**: Create new API keys periodically and revoke old ones
4. **Use descriptive names**: Give your API keys meaningful names to track their usage
5. **Delete unused keys**: Remove API keys that are no longer needed
6. **Use X-API-Key header**: Prefer using the `X-API-Key` header for clarity
@@ -317,7 +335,6 @@ curl -X POST \
## Notes
- The same endpoints work for both the web UI (with user tokens) and external services (with API keys)
- API-key-enabled endpoints use the same resource shapes as the web UI
- No need to learn different API paths - use the existing API documentation with API key authentication
- All endpoints that previously required user authentication now also accept API keys
- API keys never select a Workspace from a request header; their persisted binding is authoritative
@@ -0,0 +1,246 @@
# Multi-tenant implementation checklist
This checklist turns the Workspace architecture into implementation and verification gates.
## Scope guard
- [x] LangBot uses branch feat/multi-tenants.
- [x] langbot-plugin-sdk uses branch feat/multi-tenants.
- [x] langbot-space has no changes made by this implementation; Cloud v2 does not extend the legacy Space deployment scheme.
- [x] Unrelated untracked files in either repository remain untouched.
- [x] Open-source startup cannot enable SaaS multi-workspace through edition flags or unsigned configuration.
## SaaS-only release gates
These items intentionally remain incomplete. The feature branch delivers the Core isolation kernel, not the closed SaaS product or a production Cloud v2 deployment.
- [ ] The closed Control Plane owns the global Account, Workspace, Membership, and Invitation directory.
- [ ] The closed placement service issues monotonic generations and leases for projected Workspaces.
- [ ] Core verifies a signed `InstanceManifest` before the closed bootstrap can inject `CloudWorkspacePolicy`.
- [ ] Tenant database writes hold a generation-aware shared transaction fence through commit, while placement cutovers take the exclusive fence.
- [ ] Business writes and non-transactional side effects use a generation-stamped outbox or equivalent publish fence.
- [ ] Durable object references survive a placement-generation change through stable published keys or an explicitly atomic key/reference migration.
- [ ] SaaS cells enforce tenant-safe egress and SSRF controls for Webhooks, providers, MCP servers, and every tenant-configurable outbound URL.
- [ ] Entitlement checks, usage aggregation, subscription lifecycle, and billing are implemented in the closed Control Plane.
- [ ] OAuth state and directory projection use an atomic shared store suitable for horizontally scaled SaaS services.
- [ ] A greenfield Cloud v2 deployment is designed and validated independently of the legacy Space deployment scheme.
- [ ] Multi-workspace is enabled in SaaS only after all closed Control Plane, deployment, and security gates pass.
## 1. Persistence foundation
### Account and directory
- [x] User has a stable, unique account UUID and explicit status.
- [x] Existing email and password behavior remains compatible during migration.
- [x] Workspace table represents the instance-local tenant.
- [x] WorkspaceMembership has a unique Workspace and Account pair.
- [x] WorkspaceInvitation stores only a token hash and supports expiry, revoke, and one-time accept.
- [x] WorkspaceExecutionState stores generation, state, source, and write fence.
- [x] OSS initialization creates exactly one Workspace and one owner membership atomically.
- [x] OSS refuses a second Workspace while allowing multiple members.
### Migration
- [x] Alembic migration upgrades SQLite.
- [x] Alembic migration upgrades PostgreSQL.
- [x] Existing first user becomes owner of the default Workspace.
- [x] Existing tenant resources are backfilled with the default Workspace UUID.
- [x] SQLite destructive boundaries create verified, revision-aware backups and atomically restore after failure.
- [x] Migration can resume safely after interruption.
- [x] New installs and upgraded installs produce the same tenancy-kernel schema.
## 2. Authentication and authorization
### Identity
- [x] JWT sub uses account UUID, with a bounded compatibility path for legacy email tokens.
- [x] Disabled or deleted accounts cannot authenticate.
- [x] Local password and Space-linked account flows support more than one local Account.
- [x] Public registration closes after initialization by default.
- [x] Invitation registration works without requiring SMTP.
- [x] An unknown Space OAuth subject cannot claim an existing Account by email; explicit account-bound binding is required.
### Request context
- [x] PrincipalContext identifies Account, API Key, or trusted runtime principal.
- [x] WorkspaceContext contains Workspace, Membership, role, permissions, and revision.
- [x] RequestContext contains instance UUID, Workspace context, auth type, request ID, and generation.
- [x] ExecutionContext propagates Workspace and generation to runtime work.
- [x] SaaS-style requests never fall back to the first or most recent Workspace.
- [x] OSS may resolve the single Workspace when the selector is omitted.
- [x] Account-token bootstrap can list only the authenticated Account's active memberships before a Workspace selector exists.
### Fixed RBAC
- [x] owner, admin, developer, operator, and viewer permissions match the architecture matrix.
- [x] Invitation cannot grant owner.
- [x] The last owner cannot be removed or demoted.
- [x] Cross-Workspace resources return 404.
- [x] Same-Workspace permission failures return 403.
## 3. Workspace and member APIs
- [x] GET /api/v1/workspaces returns the OSS singleton Workspace.
- [x] POST /api/v1/workspaces returns edition_limit in OSS.
- [x] Current Workspace endpoint returns the authenticated Membership.
- [x] Member list is permission scoped.
- [x] Invitation create, revoke, inspect, and accept are atomic.
- [x] Member role update and removal enforce owner rules.
- [x] Invitation tokens travel in a request body and are redacted from logs.
- [x] Relevant MCP tools and in-repo skills are updated with the same contract.
## 4. Tenant-scoped persistence and services
Each row type must have a non-null Workspace UUID, scoped indexes, scoped uniqueness, and scoped CRUD tests.
- [x] Bots and bot admins.
- [x] Legacy pipelines and pipeline run records.
- [x] Model providers.
- [x] LLM models.
- [x] Embedding models.
- [x] Rerank models.
- [x] Plugin installations, settings, and configuration.
- [x] MCP servers and resource preferences.
- [x] Knowledge bases, files, and chunks.
- [x] Vector collections and handles.
- [x] Monitoring messages, calls, sessions, errors, embeddings, and feedback.
- [x] API keys and scopes.
- [x] Webhooks and public route resolution.
- [x] Binary storage and Workspace storage.
- [x] Workspace metadata, separated from system metadata.
### Service and API rules
- [x] Every tenant Service receives RequestContext or an explicit Workspace UUID.
- [x] No tenant Service treats context None as global access.
- [x] Every applicable get, list, create, update, delete, copy, export, and bulk operation is scoped.
- [x] Parent-child references use the same Workspace.
- [x] API Key authentication derives Workspace from the key, not a header.
- [x] Webhook and Bot public routes derive Workspace from a trusted resource.
- [x] Background jobs carry Workspace and generation explicitly.
## 5. Runtime isolation
### Core runtime
- [x] RuntimeBot carries Workspace UUID and placement generation.
- [x] RuntimePipeline carries Workspace UUID and placement generation.
- [x] Query and Event carry Workspace UUID without making it an authorization source.
- [x] Session key includes Workspace UUID, Bot UUID, launcher type, and launcher ID.
- [x] QueryPool and manager indexes cannot collide across Workspaces.
- [x] Query and aggregation cache keys and locks include Workspace UUID.
- [x] Runtime transports, cached results, object operations, and long-lived tasks revalidate WorkspaceExecutionState generation at side-effect boundaries.
- [ ] Ordinary tenant database writes hold the generation fence in the same transaction until commit; this remains a SaaS activation gate.
### Plugin
- [x] Plugin installation and configuration are Workspace scoped.
- [x] Runtime control actions carry trusted Workspace binding and placement generation.
- [x] A plugin process or supervisor never serves multiple Workspaces in SaaS mode.
- [x] Host API derives Workspace from the connection, installation, and trusted action context, not plugin input.
- [x] Plugin get_bots, models, tools, vector, RAG, configuration, and messaging calls are scoped.
- [x] Plugin Workspace storage no longer uses owner default.
- [x] Plugin page APIs check Membership and installation ownership.
- [x] Local plugin launches use short-lived, one-use registration capabilities bound to manifest identity.
### MCP, RAG, and Box
- [x] MCP runtime key contains instance UUID, Workspace UUID, placement generation, and server UUID.
- [x] Same-named MCP servers in two Workspaces do not share sessions.
- [x] Pipeline cannot reference another Workspace's MCP resource.
- [x] RAG collection names and handles are server-derived and Workspace scoped.
- [x] Legacy global vector migration is available only to the local OSS singleton Workspace.
- [x] Object storage paths include instance, Workspace, and placement generation for the fixed-generation OSS runtime.
- [x] Object storage revalidates generation before touching a provider or resolving an opaque key.
- [ ] Cloud cutover uses generation-scoped staging plus stable published object references, rather than making the staging generation the durable identity.
- [x] Box persistent and ephemeral namespaces include the required instance, Workspace, and generation scope.
- [x] Same-named Box sessions and processes cannot collide across Workspaces or placement generations.
- [x] Box relay and process I/O reject or retire stale generations.
- [x] External paths and privileged mounts cannot be supplied by an untrusted plugin.
## 6. SDK and protocol
- [x] Public Query, Event, Session, and context entities carry backward-compatible Workspace data.
- [x] Action RPC request models carry trusted Workspace binding where required.
- [x] Action enums and callers remain consistent.
- [x] Old plugins continue to deserialize compatible events.
- [x] Plugins cannot select an arbitrary Workspace through a Host API argument.
- [x] Runtime storage uses the bound Workspace UUID.
- [x] SDK API tests pass.
- [x] Runtime tests pass.
- [x] Action consistency script passes.
## 7. Frontend
- [x] Every browser tenant API request carries the current Workspace selector after bootstrap.
- [x] OSS automatically selects the singleton Workspace.
- [x] OSS does not show Create Workspace or a misleading switcher.
- [x] Workspace settings show current Workspace information.
- [x] Members page lists roles and permissions.
- [x] Invitation creation shows a one-time link when SMTP is unavailable.
- [x] Invitation acceptance supports a signed-out user flow.
- [x] Role controls are hidden or disabled consistently with backend permissions.
- [x] Switching accounts clears stale Workspace query cache and local state.
- [x] User-facing strings support en_US, zh_Hans, and ja_JP.
## 8. Automated verification
### Persistence and authorization
- [x] SQLite fresh install.
- [x] SQLite upgrade from pre-tenant schema, including verified failure recovery.
- [x] PostgreSQL fresh install.
- [x] PostgreSQL upgrade from pre-tenant schema.
- [x] All fixed roles have positive and negative permission-matrix tests.
- [x] Concurrent invitation acceptance creates one Membership.
- [x] Concurrent owner changes never leave zero owners.
### Cross-tenant isolation
- [x] Two Workspaces are created through a test-only policy.
- [x] Applicable resource operations and parent-child references have cross-Workspace negative coverage.
- [x] Resource UUID guessing cannot cross Workspace.
- [x] API Key cannot cross Workspace.
- [x] Plugin cannot enumerate or invoke another Workspace's resources.
- [x] Sessions, caches, locks, MCP, RAG, Box, storage, and monitoring do not collide.
- [x] Background jobs cannot execute without an explicit Workspace and placement generation.
### Security and revocation
- [x] Space login and binding use purpose-bound, one-time opaque OAuth state; caller-supplied state is rejected.
- [x] OAuth redirects trust only server-configured WebUI or webhook origins, never request `Host` or `Origin` headers.
- [x] Dashboard WebSockets revalidate authentication, Membership, resource, permission, and generation per message.
- [x] Public embed WebSockets re-resolve Bot availability and execution binding per message.
- [x] Runtime, storage, Plugin Runtime, MCP, RAG, and Box reject a stale placement generation.
- [x] Unhandled API and webhook failures return a generic error plus request ID without exception text.
- [x] URL user information and sensitive query parameters are redacted before configuration is serialized or logged.
### Regression
- [x] LangBot unit tests pass.
- [x] LangBot integration tests pass.
- [x] Frontend lint completes without errors and the production build passes.
- [x] SDK focused and full relevant tests pass.
- [x] Local SDK is installed into LangBot from the exact pushed SDK commit and cross-repo tests pass with no sync.
## 9. Real browser E2E
- [x] Start from a clean local data directory.
- [x] First user initializes the singleton Workspace as owner.
- [x] Owner creates an invitation link.
- [x] A second signed-out browser identity accepts the invitation and registers.
- [x] owner, admin, developer, operator, and viewer UI permissions match backend enforcement.
- [x] Direct API calls cannot bypass hidden controls.
- [x] Account switch does not expose prior account or Workspace data.
- [x] Refresh and a new browser tab recover the correct Workspace safely.
- [x] OSS rejects a second Workspace with `edition_limit`; same-name and same-identifier isolation is covered by the test-only multi-Workspace policy because OSS deliberately has no multi-Workspace browser surface.
- [x] Explicit error states are visible for expired, revoked, reused, and email-mismatched invitations.
## 10. Completion evidence
- [x] LangBot and SDK branch refs are recorded in the verification report.
- [x] Space git diff is empty relative to the pre-work snapshot.
- [x] Migration output is captured for SQLite and PostgreSQL.
- [x] Test commands and results are recorded.
- [x] Browser E2E actions and observed results are recorded.
- [x] No remaining tenant table, global Service query, owner default, or unscoped runtime key is found by the final audit.
@@ -0,0 +1,175 @@
# Multi-tenant implementation decisions
This log records implementation choices made while delivering the Workspace architecture. It is intended to make trade-offs auditable without interrupting implementation for routine decisions.
## 2026-07-18
### OSS remains a singleton Workspace with multiple Accounts
- Decision: Community builds create exactly one Workspace per LangBot instance and allow multiple Accounts through invitations.
- Reason: This preserves a simple self-hosted deployment while making authorization and ownership explicit. Creating a second Workspace is an edition error, not a hidden fallback.
- SaaS boundary: Multi-Workspace directory, placement, entitlement, and billing are the responsibility of a separate closed SaaS Control Plane. Core consumes a validated projection and remains the final isolation and authorization enforcement point; it does not become the SaaS system of record or billing engine.
- Deployment boundary: Cloud v2 is a greenfield deployment design. The previous Space instance/pod deployment scheme is not migrated, preserved for compatibility, or extended by this implementation. Existing Space OAuth, marketplace, and payment concepts may be reused only through explicit adapters where they still fit; `langbot-space` itself is not changed.
### Workspace selection is trusted only after authentication
- Decision: Browser requests carry `X-Workspace-Id`, but the server resolves it against the authenticated Account membership. API keys, public Bot routes, webhooks, jobs, and plugin calls derive Workspace from their trusted owning resource or binding instead of trusting the header.
- Reason: A selector is routing input, not authorization evidence.
- Compatibility: Community builds may select the singleton Workspace when the header is omitted. A multi-Workspace-capable build must reject an omitted selector.
### Stable Account UUID is the token subject
- Decision: New JWTs use the stable Account UUID as `sub`; a bounded compatibility path accepts legacy email-subject tokens and rotates them when checked.
- Reason: Email can change and therefore cannot be a durable authorization identity.
### Fixed roles are authoritative in Core
- Decision: `owner`, `admin`, `developer`, `operator`, and `viewer` map to a fixed permission matrix in LangBot Core. The last owner cannot be removed or demoted, and invitations cannot create an owner directly.
- Reason: Core must remain the final authorization boundary in both OSS and SaaS deployments.
### Cross-Workspace access is indistinguishable from absence
- Decision: Resource lookups always include Workspace UUID. A guessed UUID belonging to another Workspace returns 404; a visible resource with insufficient same-Workspace permission returns 403.
- Reason: This avoids leaking resource existence across tenants while preserving actionable same-tenant errors.
### Plugin Runtime binds to exactly one Workspace
- Decision: A trusted LangBot control connection binds a Plugin Runtime to one `instance_uuid`, `workspace_uuid`, `placement_generation`, and optional installation. Repeating the same binding is idempotent; rebinding is rejected. Plugin-supplied context fields are stripped.
- Reason: One untrusted plugin process must never become a cross-Workspace router.
- Compatibility: Older SDK payloads without context still deserialize, but Workspace storage and tenant Host APIs fail explicitly until a trusted binding is established.
- Startup fence: The Runtime does not launch or register plugins until `SET_RUNTIME_CONFIG` establishes the trusted Workspace binding. A cloud or multi-Workspace connector must be constructed for one explicit projected Workspace and generation; it never falls back to a migration-created local compatibility Workspace.
### Invitation delivery does not require SMTP
- Decision: Core returns an invitation secret once for copy-and-share, persists only its hash, and supports expiry, revocation, and one-time acceptance.
- Reason: Self-hosted OSS must support adding users without an email service while avoiding recoverable invitation secrets at rest.
- Browser handling: The copyable invitation URL carries the secret in its fragment, which browsers do not send in HTTP requests or Referer headers. The acceptance page immediately removes the fragment and keeps the secret only in `sessionStorage` until login or acceptance completes; it is never placed in a path, query string, analytics event, or persistent local storage.
### Schema rollout is additive before enforcement
- Decision: Add Account/Workspace directory tables first, then add non-null Workspace ownership to every tenant resource with a deterministic default-Workspace backfill. Runtime and service enforcement is enabled only with matching migration and isolation tests.
- Reason: A Workspace column alone is not isolation, and enforcing queries before data backfill would break upgraded installations.
### Login capability discovery is instance-scoped, not account-scoped
- Decision: The unauthenticated login bootstrap endpoint reports only which login mechanisms the instance supports. It does not inspect the first Account or expose whether that Account has a password. Both password and Space OAuth entry points are available on a multi-user instance; the submitted identity determines which mechanism is valid.
- Reason: This avoids projecting the original owner's authentication type onto invited users and removes a public Account-state disclosure.
### Space OAuth identity does not choose a SaaS Workspace
- Decision: Space OAuth tokens remain Account credentials. In OSS singleton mode, an OAuth refresh may update the singleton Workspace's Space provider only when that Account's role can manage provider secrets. In SaaS multi-Workspace mode an OAuth callback without an authenticated Workspace selector never guesses which Workspace to mutate; explicit Workspace configuration or the closed control plane owns that linkage.
- Reason: An Account may belong to several Workspaces, and authentication must not silently mutate a shared tenant secret.
### SaaS execution state is a validated Core projection
- Decision: Core can resolve both local and `cloud_projection` Workspaces, but only from an explicit Workspace UUID and an active, unfenced `WorkspaceExecutionState` for the current instance and matching source. OSS-only bootstrap paths additionally require `source=local`.
- Reason: The closed control plane owns placement decisions, while Core remains the enforcement point for instance binding, generation, and write fences.
### API-key secrets are one-time and Workspace-bound
- Decision: Database API keys persist only a globally unique SHA-256 hash, an opaque UUID, one Workspace UUID, explicit fixed-permission scopes, status, expiry, creator, and last-used time. The raw secret is returned once. Authentication derives Workspace and generation from the key record and ignores Workspace selectors. Legacy plaintext keys are hashed during migration and receive a compatibility `*` scope. The plaintext config key works only for the OSS singleton Workspace and is disabled in multi-Workspace mode.
- Reason: A bearer key is an identity and routing credential, not merely a password layered on top of caller-controlled tenant selection.
### MCP tools inherit the authenticated API-key context
- Decision: The MCP ASGI mount authenticates the API key once, binds an immutable per-request `RequestContext`, and every tool checks a fixed permission before calling tenant services with that same context.
- Reason: Authenticating the transport without propagating Workspace identity into tool calls would leave the direct service path globally scoped.
### Unreleased SDK protocol is pinned reproducibly without publishing
- Decision: The SDK tenancy protocol is versioned as 0.4.15. This task does not create a GitHub release or publish PyPI because the user authorized pushing code, not a package release. After the SDK feature branch is final, LangBot's feature branch temporarily pins the exact pushed SDK Git commit. Before merging to master, the release gate is to publish `langbot-plugin==0.4.15` and replace the Git pin with the registry pin.
- Reason: PyPI 0.4.14 does not contain `ActionContext`; pinning it makes clean installs fail, while pinning an unpublished 0.4.15 makes dependency sync impossible. An exact Git commit is reproducible and keeps the feature branch testable without expanding release authority.
### Cloud directory writes stay outside Core
- Decision: The open-source Core startup always installs `SingleWorkspacePolicy`, creates or repairs one local Workspace, and permits local membership/invitation workflows. Changing mutable configuration such as `system.edition` cannot activate multi-Workspace routing. The future closed Cloud bootstrap will install `CloudWorkspacePolicy` only after verifying a signed `InstanceManifest`; that policy requires an explicit projected Workspace selector, does not create Workspaces, and rejects invitation or membership mutations with `control_plane_required`; member reads use the versioned local projection.
- Ownership split: The closed Control Plane owns the global Account/Workspace/Membership/Invitation directory, placement and lease generations, entitlements, subscription state, usage aggregation, and billing decisions. Core owns request authorization, resource scoping, execution-generation validation, and fail-closed enforcement. Provisioning and invoice computation do not belong in open-source Core.
- Reason: The closed control plane is authoritative for SaaS Account, Workspace, Membership, and Invitation state. Allowing Core to mutate the same directory would create split-brain ownership and would make an ownerless compatibility Workspace a dangerous fallback.
- Release gate: Multi-Workspace activation is deliberately unavailable in the open-source bootstrap. Production Cloud v2 must implement the signed `InstanceManifest` verifier and closed bootstrap described in the architecture document before it can inject `CloudWorkspacePolicy`; `edition=cloud`, an environment variable, or any unsigned local configuration is never a valid activation credential.
### Workspace bootstrap is reactive and ordered before browser resource calls
- Decision: The web application blocks Workspace-owned pages until Account and current Workspace bootstrap completes. A `useSyncExternalStore` Workspace store publishes permission changes to React consumers; direct mutation-only routes and controls are hidden or disabled when the fixed role lacks the required permission.
- Reason: Mutating a module-level variable after the initial React render did not reliably re-render permission controls, and mounting resource pages before the selector was established could issue tenant requests without `X-Workspace-Id`.
### JWTs are bound to one LangBot instance
- Decision: New Core JWTs require `iss=langbot-core`, an audience derived from the immutable instance UUID, and an expiry. Legacy community tokens are accepted only when they have the historical issuer, carry no audience, and the active policy is the OSS singleton policy.
- Reason: A token issued by one instance must not authenticate against another instance that happens to share a secret, and a compatibility decoder must not become an alternate path around the SaaS trust boundary.
### Runtime control transports authenticate before protocol dispatch
- Decision: External Plugin Runtime and Box WebSocket control channels require independent strong shared secrets in handshake headers. Locally managed child processes receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally binds the first authenticated control channel to one trusted instance. Plugin Runtime debug and control credentials remain separate.
- Reason: Workspace context inside an RPC payload is not trustworthy until the transport peer itself is authenticated. Separating control and debug credentials also limits accidental privilege reuse.
- Deployment consequence: Docker Compose and Kubernetes wire one shared secret to each host/runtime pair. An empty external-runtime secret fails startup instead of silently exposing an unauthenticated socket.
### Dashboard WebSocket sessions are tenant runtime objects
- Decision: A dashboard WebSocket sends an authentication frame immediately after upgrade. The server validates Account, Membership, permission, Pipeline ownership, instance, Workspace, and placement generation before registering the connection. Connection indexes, sessions, broadcasts, attachments, and resets include the complete execution scope.
- Reason: Browser WebSocket APIs cannot attach the normal authorization headers, and a process-global `pipeline_uuid` or `session_type` index can collide across Workspaces.
### Read permissions never imply secret permissions
- Decision: `resource.view` responses recursively redact Bot, Plugin, MCP, and provider credentials. Provider secrets require `provider_secret.manage`; Bot and Plugin configuration writes require `resource.manage`. Masked Plugin values can be round-tripped by a manager without overwriting the stored secret. Plugin Runtime debug credentials require `resource.manage`, not the operator-only `runtime.operate` permission.
- Reason: A multi-user Workspace needs useful viewer access without turning every visible configuration endpoint into credential export. Plugin debug attachment can register executable code and is therefore a resource-management operation.
### Temporary credential exchanges are bound to their initiator
- Decision: Lark, Weixin, DingTalk, WeComBot, and QQOfficial one-click registration sessions require `resource.manage` and store the initiating instance, Workspace, placement generation, and principal. Status and cancellation by any other scope return the same 404 as an unknown session.
- Reason: Random session IDs reduce guessing probability but do not authorize access to credentials returned by a completed exchange.
### Uploaded images and documents use different storage capabilities
- Decision: Browser images use the scoped `upload_image` owner type and may be resolved only through the opaque public-image route. RAG documents use `upload_document` and can be read, sized, or deleted only by an exact instance, Workspace, generation, and owner-type match. Legacy `upload` objects are cleanup-only.
- Reason: Treating every upload as a public image made a leaked document key sufficient to bypass authenticated RAG access.
## 2026-07-19
### Space OAuth state is server-issued and single-use
- Decision: Core issues an opaque, cryptographically random OAuth state for each Space login or Account-binding attempt, stores only its digest, and consumes it exactly once within a short expiry. Login and binding states are different capabilities; a binding state is additionally bound to the authenticated Account. Caller-supplied state, including a LangBot JWT, is rejected.
- Redirect boundary: Callback redirects are accepted only for the known callback path and an origin declared by the server-side `api.webui_url` or `api.webhook_prefix`. Request `Host` and `Origin` headers never expand this allowlist.
- Current deployment: The OSS state store is bounded and process-local, so a Core restart safely invalidates outstanding attempts. A horizontally scaled SaaS deployment must move this exchange to an atomic, shared Control Plane store before enabling the closed Cloud bootstrap.
- Reason: OAuth state is a narrow, one-time CSRF and flow-binding capability. Reusing a bearer JWT or trusting caller-controlled Host or Origin data would turn an authorization redirect into an Account-token theft or open-redirect primitive.
### OAuth provider subjects, not email addresses, bind Accounts
- Decision: A known Space `account_uuid` may refresh the credentials of its already-bound local Account. An unknown provider subject that presents an email belonging to an existing Account is rejected, even when the normalized emails match. The Account owner must authenticate locally and use the one-time, account-bound binding flow.
- Reason: Email is contact and display data, not a stable federated identity key. Email-only auto-linking would let provider verification drift or identity reassignment become a local Account takeover.
### Workspace discovery is an account-only bootstrap capability
- Decision: `ACCOUNT_TOKEN` validates the active Account JWT but intentionally cannot resolve a Workspace, receive `RequestContext`, or declare Workspace permissions. Its narrow bootstrap endpoint returns only active Workspace memberships belonging to that Account and never chooses the first Workspace when several exist. All tenant resource routes still require the explicit selector in multi-Workspace mode.
- Reason: Requiring a Workspace header to discover the Account's Workspaces creates an authentication deadlock; allowing the bootstrap route to perform tenant actions would create an authorization bypass. Separating the two capabilities resolves the cycle without weakening tenant routes.
### SQLite tenancy migrations have a verified recovery boundary
- Decision: Before each destructive tenant-schema boundary, a file-backed SQLite installation creates an online-consistent backup with its source and target revisions, runs `PRAGMA quick_check`, writes a durable manifest, and fsyncs restrictive-permission files and directories. A failed boundary disposes the engine, removes stale journal sidecars, atomically restores the verified source revision, and verifies the restored database before startup continues.
- Compatibility: In-memory SQLite cannot provide this recovery guarantee and is rejected for destructive production migration boundaries; it remains usable in tests that create the final schema directly.
- Reason: SQLite batch table rebuilds can leave an installation between schemas if a process or migration fails. A verified pre-boundary image makes retry behavior recoverable instead of merely idempotent in the happy path.
### Placement generation is an execution revocation capability
- Decision: RuntimeBot, RuntimePipeline, background tasks, object storage, Plugin Runtime, MCP, RAG, and Box operations carry the complete instance, Workspace, and placement-generation scope. They revalidate the active execution binding before accessing a provider or transport; long-running calls validate again before accepting results. A stale generation is fenced before it can read, write, or reuse a cached object.
- Plugin boundary: Each locally launched plugin receives a short-lived, one-use registration capability bound to the expected manifest identity and execution scope. The production child environment does not inherit the reusable debug credential, and Host APIs derive scope from the trusted connection and action context.
- Box boundary: Persistent skill content remains Workspace-scoped, while session/process state and relay requests also include placement generation. A generation change retires matching live sessions and closes a stale relay before further stdin, stdout, or file operations.
- Transaction boundary: Request admission and runtime side effects are fenced in this branch, but ordinary tenant database mutations do not yet hold a generation-aware lock through commit. The closed Cloud bootstrap must remain disabled until Core provides the shared-write/exclusive-cutover transaction primitive and a generation-stamped outbox (or an equivalent atomic publish fence). The OSS singleton policy has a fixed local generation and cannot trigger a placement cutover.
- Durable-object boundary: Current opaque storage keys include generation and therefore fail closed after a generation change. That is safe for OSS's fixed generation, but a Cloud cutover must not strand durable KB files, images, or plugin references. Cloud v2 must publish stable final object identities from generation-scoped staging, or perform an atomic object-and-reference migration before activating the new generation.
- Reason: Workspace UUID prevents cross-tenant collisions, but it cannot revoke work after a Workspace is moved or fenced. Placement generation is the monotonic lease that makes old runtimes unusable.
### Long-lived WebSockets continuously revalidate authority
- Decision: Dashboard WebSockets re-authenticate the Account, Membership, permission, resource ownership, instance, Workspace, and placement generation for every inbound message, not only during the initial frame. A changed role, removed Membership, or fenced placement takes effect without waiting for reconnect.
- Public embed boundary: The embed connection re-resolves its Bot before every message and rejects a Bot that was disabled, deleted, moved, or rebound. The public connection may identify a Bot, but it cannot make the initial Bot object an indefinite authorization capability.
- Reason: Authorization and resource state can change while a socket remains open. Connection-time validation alone leaves a revocation gap.
### Legacy vector migration is an OSS-local compatibility path
- Decision: Status, backup, execute, dismiss, and background entry points for legacy global vector collections require an active local Workspace binding under `SingleWorkspacePolicy`. A `cloud_projection` Workspace cannot observe or migrate the old global collection, even when it carries a legacy marker.
- Reason: The legacy collection predates tenant ownership. Treating it as a SaaS fallback would expose one installation's historical vectors to an arbitrary projected Workspace.
### External errors and persisted URLs are redacted centrally
- Decision: Unhandled HTTP and webhook failures return a stable `internal_error` response and request ID, and expose that ID in `X-Request-Id`; the detailed exception is retained only in server logs correlated by the same ID. Explicit domain and validation errors keep their documented status and code.
- Secret boundary: Shared sanitization removes URL user information and masks sensitive query parameters before provider or MCP configuration is serialized, logged, or shown to a reader. Masked placeholders can be round-tripped by an authorized manager without replacing the stored secret.
- Reason: Tenant isolation is incomplete if framework exceptions, connection URLs, or configuration reads can export credentials across otherwise authorized interfaces.