Tenant isolation is structural, not conventional.
There is no mechanism for a session to address another tenant. We removed it. RLS on the platform DB, a dedicated DB per tenant, and a tenant-locked agent context.
The isolation model.
Three concentric guarantees.
Application-level checks are necessary but not sufficient. We back every check with a structural guarantee — one that holds even when application code has a bug.
Row-Level Security
Every cross-tenant table on the platform DB has an RLS policy on tenant_id. The session sets it. Postgres enforces it.
Dedicated tenant DBs
Module data lives in a per-tenant PostgreSQL database. Cross-tenant queries are physically impossible.
Agent context lock
Sessions inherit tenant_id at boot. The executor passes it on every tool call. The tool decorator refuses unset contexts.
Why dedicated tenant DBs.
“Conventional” multi-tenancy gets you to about 80%.
Conventional / single DB
- Tenant ID is a column. App code adds WHERE tenant_id = ….
- A missed WHERE clause is a cross-tenant leak.
- Migrations affect every tenant in one shot.
- Noisy-neighbor performance bleeds across customers.
- Per-tenant backup / restore is a custom script.
DataVisions.ai / dedicated DB per tenant
- A missed WHERE clause queries the wrong table — in your own DB. Caught in dev.
- Migrations roll out tenant-by-tenant, monitored.
- Noisy neighbors don't exist — they're in another DB.
- Backup, restore, export — per tenant, by default.
- Compliance asks ("show me only this customer's data") have a trivial answer.
Role-Based Access Control.
Roles declared by modules, enforced by the platform.
Each module's module.json declares its roles. Tenants grant roles to users. The platform evaluates every API request, tool call, and UI render against the same RBAC tree.
Module-declared roles
operator, supervisor, admin, viewer — defined where they make sense.
Tenant-scoped grants
A user can be 'admin' in tenant A and 'operator' in tenant B without leakage.
UI + API parity
The console asks the same RBAC engine as the FastAPI guard. The button doesn't render unless the call would succeed.
# every @platform_tool call
if not ctx.user.has_role(tool.roles, tenant=ctx.tenant_id):
raise Forbidden(tool.name)
# every FastAPI route
@router.get("/machines", dependencies=[Depends(require_role("operator"))])
async def list_machines(ctx: TenantCtx = Depends()):
return await repo.all(ctx.tenant_db)
# every UI button
<Guarded role="supervisor"><RestartBtn /></Guarded>
Every action, in one searchable place.
The audit log is platform-level: any tool call, any role change, any tenant action, any login. Search by tenant, user, agent session, or tool name. Exportable.
| When | Tenant | Actor | Action | Detail |
|---|---|---|---|---|
| 2026-05-16 09:14:02 | acme-ops | u_07a · operator | tool.invoke | pm.machines.list / shift=west |
| 2026-05-16 09:14:05 | acme-ops | u_07a · operator (via agent) | tool.invoke | pm.alerts.recent / window=8h |
| 2026-05-16 09:11:48 | acme-ops | u_03b · admin | role.grant | u_07a → supervisor |
| 2026-05-16 09:08:22 | metro-mill | u_22c · admin | module.subscribe | scada_ops v2.1.0 |
| 2026-05-16 09:02:17 | acme-ops | system | scheduler.run | pm.nightly_health → ok |