Workspace fence
Every persistent surface in Bigwave is workspace-scoped. Cross-tenant reads return 404 — never 403, never a count-oracle leak. This is enforced at the repository layer, not the route.
Why it matters
When two business units share a Bigwave deploy, the fence is what keeps team-A's training data, fine-tunes, eval reports, and chat history invisible to team-B. Even if team-B knows the exact adapter_id of one of team-A's adapters and crafts a request for it, the response is 404.
For regulated deployments this is a compliance requirement, not a nice-to-have.
What "404 not 403" gets you
Three reasons the response shape matters:
- No information leak through error codes. A 403 says "this resource exists, you just can't access it" — that's an oracle: an attacker can enumerate adapter IDs by which ones return 403 vs which return 404. A 404 is indistinguishable from "no such resource."
- No count oracle. List endpoints don't return "you have 5 adapters but 3 are hidden from you." They return only the rows the workspace owns. The hidden ones don't appear in totals.
- Reduced attack surface. Routes that exposed cross-tenant identifiers anywhere — error messages, log lines, metrics labels — would leak inventory. Bigwave's design pushes the fence into the repository so route handlers can't accidentally bypass it.
Where the fence lives
The fence is enforced at the repository layer:
# packages/eval/runner.py — sketch
class EvalReportRepository:
async def get_report(self, *, workspace: str, report_id: str):
row = await session.scalar(
select(EvalReportORM).where(
EvalReportORM.report_id == report_id,
EvalReportORM.workspace_id == workspace, # the fence
)
)
if row is None:
raise EvalReportNotFoundError(report_id)
return rowEvery get / list / update / delete method takes a workspace_id parameter. The SQL query filters on it. There is no cross-tenant code path; an admin route attempting to read a row without scoping fails the type check.
Surfaces that are fenced
DatasetRepository— datasetsAdapterRegistry— fine-tuned adaptersEvalReportRepository— eval runs + reportsFeedbackStore— feedback eventsInferenceLogRepository— chat completionsAuditLogRepository— audit entriesChannelCredentialStore— channel bindings + their secretsWorkspaceTokenBucketLimiter— per-workspace rate-limit bucketsCompletionProvenanceCache— completion → context lookupsKnowledgeBase— vector-store collections (named{workspace}:{kb_id})
Surfaces that intentionally do not enforce the fence
- The webhook receiver (
/webhooks/telegram/{binding_id}) does an ID-only lookup — providers can't carry our admin tokens. The workspace is carried by the binding row (ChannelBindingORM.workspace_id), not by an Authorization header. An attacker who guesses the binding UUID still has to forge the provider's signature to dispatch (see the secret-token verifier).
That's the only deliberate exception. Everything else is fenced.
What the fence does not enforce
- RBAC within a workspace. All members of a workspace see all rows. There's no row-level RBAC for
principal-A vs principal-Binside the same workspace. That's a roadmap capability (oauthAuthAdapter + role definitions); for now, treat workspaces as the unit of isolation. - Encryption at rest. The fence prevents programmatic access across workspaces; it doesn't encrypt data in the underlying storage. Disk-level encryption is the operator's responsibility.
- Network isolation. All workspaces share the same API process and the same database. A bug in the fence (or a vulnerability in the storage backend) could in principle leak across them. Defense- in-depth means deploying isolated Bigwave instances per high- sensitivity tenant.
Testing the fence
Every workspace-scoped repository has a "tenant fence" test:
async def test_workspace_fence_isolates_tenants(repo):
await repo.record(workspace_id="ws-A", ...)
await repo.record(workspace_id="ws-B", ...)
a = await repo.list(workspace_id="ws-A")
b = await repo.list(workspace_id="ws-B")
assert all(r.workspace_id == "ws-A" for r in a)
assert all(r.workspace_id == "ws-B" for r in b)The lifecycle E2E suite (tests/e2e/test_multi_tenant_fence.py) adds HTTP-level confirmation: ws-A's eval runs are invisible in ws-B's listing, and ws-B's GET on ws-A's run id returns 404.
Related concepts
- The loop — workspace-scoping is what lets the loop close cleanly per-tenant without cross-talk.
- Eval-as-a-gate — gate decisions are workspace-scoped; ws-A's policy never affects ws-B's activations.
- Compliance use case — the fence is one of the audit-defensibility properties.