Skip to content

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:

  1. 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."
  2. 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.
  3. 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:

python
# 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 row

Every 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 — datasets
  • AdapterRegistry — fine-tuned adapters
  • EvalReportRepository — eval runs + reports
  • FeedbackStore — feedback events
  • InferenceLogRepository — chat completions
  • AuditLogRepository — audit entries
  • ChannelCredentialStore — channel bindings + their secrets
  • WorkspaceTokenBucketLimiter — per-workspace rate-limit buckets
  • CompletionProvenanceCache — completion → context lookups
  • KnowledgeBase — 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-B inside the same workspace. That's a roadmap capability (oauth AuthAdapter + 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:

python
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.

  • 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.

Dual-licensed: MIT OR Apache-2.0