Security
Tenant Isolation by Default
How Basalt makes tenant boundaries part of every data request instead of relying on each application query to apply the right filter.
Multi-tenancy is not a UI feature. It is a security boundary. When a platform stores virtual machines, networks, licenses, API keys, image references, audit events, and support records for many organizations in the same control plane, every request that touches tenant-owned data is part of that boundary.
Basalt enforces the boundary in the shared data layer. Tables that contain tenant data carry a tenant_id, and
the data layer applies policies that compare it with current_setting('app.current_tenant'). Before a
tenant-scoped request runs, the control plane sets app.current_tenant on the connection. The data layer then
limits which records are visible, regardless of how the application query was written.
That changes the failure mode. When filtering exists only in application code, the security model depends on every query including the right condition:
WHERE tenant_id = $1 One missed condition can become a cross-tenant data leak. A new report, an administrative helper, a background task, or a join added during a refactor can accidentally return records for every tenant. Code review and tests reduce that risk, but neither can cover every future query shape.
With data-layer enforcement, even a careless SELECT * FROM virtual_machines is evaluated inside the current
tenant context. The application can still have bugs, but records outside that context are not returned. Basalt treats
this as a core boundary, not an optional hardening step.
What data-layer isolation prevents
The most direct risk is Tenant A seeing Tenant B's virtual machines, networks, licenses, or tickets because an
endpoint forgot WHERE tenant_id = ?. A policy attached to the protected table prevents that record from
being visible to Tenant A's request.
Less obvious leaks can be just as important. Aggregate reports may reveal counts or operational hints, such as how many licenses another tenant has or whether a support ticket ID is valid. Joins are another common failure path: a query can filter its primary table correctly while exposing metadata from an unfiltered related table. Policies on each protected table preserve the boundary regardless of how the query is assembled.
This enforcement also reduces the impact of injection and authorization bugs. Injection must still be prevented; parameterized requests and safe query construction remain mandatory. But if an attacker changes the shape of a tenant-scoped query, the data policy still constrains which tenant records are visible. If an application handler incorrectly permits an action, the resulting query still runs inside the tenant context established for the request.
This is defense in depth with a concrete enforcement point. The control plane authenticates the caller, resolves
the tenant, sets app.current_tenant, and uses a connection configured for that tenant. The shared data layer
enforces visibility instead of leaving isolation as a choice made separately by every query.
Tenant boundaries and permissions solve different problems
The data boundary answers one question: which tenant's records can this session see? It does not decide whether the current user may reboot a virtual machine, issue a license, rotate an API key, or read an audit log. Basalt uses role-based access control for those decisions.
The platform defines fine-grained permissions for actions and resources, such as reading a project, creating a virtual machine, managing a license, viewing support tickets, operating registry credentials, administering tenant users, or inspecting audit events. Those permissions are checked before an action is performed, while the tenant boundary remains active in the data layer underneath it.
The combination matters. Permissions prevent a user inside Tenant A from performing actions they are not allowed to perform. Data-layer policies prevent any path, whether authorized or buggy, from crossing into Tenant B's records. Because the controls fail differently, a mistake in one does not automatically remove the other.
Token and secret handling follows the same model of explicit boundaries. Basalt uses a centrally maintained set of cryptographic operations for authentication material and signed transfer flows rather than creating new security logic for each feature. This supports the tenant boundary and permission checks without replacing either one.
Why not one database per tenant?
A separate database for every tenant can provide a strong isolation model, but it also creates an operations model that grows with tenant count. Each tenant adds another database to provision, update, back up, monitor, restore, and test during upgrades. Schema changes become fleet operations, and small tenants cost nearly as much to operate as large ones.
For some environments, that trade-off is correct. Customer-managed keys tied to isolated database clusters, strict data residency per account, or contractual single-tenant deployment may call for separate databases. Basalt's default model is designed for a shared infrastructure control plane where strong logical isolation, consistent updates, and operational simplicity all matter.
A shared database with enforced tenant policies gives Basalt one schema, one update path, one backup strategy, and one place to maintain relationships across control-plane data. Tenant-owned records still carry tenant identity, and policies still restrict visibility. Operators do not have to coordinate hundreds of database upgrades just to add a field to a task record or an audit event.
The trade-offs are real
Policy checks have a cost. Good indexes on tenant_id are part of both the security design and the performance
design. Query plans must account for the tenant condition applied by the data layer, even when that condition does
not appear directly in application code.
This model also requires disciplined schema design. Tenant-owned tables need clear ownership. Shared reference tables need explicit decisions about whether they are global, tenant-scoped, or both. Cross-tenant administrative work cannot casually run under a tenant context; broader access requires a dedicated, controlled role and an audit record explaining why it was needed.
Testing changes as well. The suite must verify that policies remain correct when tables are added or relationships change. It is not enough to prove that a request returns a virtual machine for the right tenant. Tests must also prove that the same request cannot see another tenant's machine and that joins do not bypass the boundary. The data policy is production security code and must be tested that way.
Those costs are acceptable because the alternative is worse. Application-only filtering turns tenant isolation
into a coding habit. Basalt makes it an enforced data rule. When the control plane sets
app.current_tenant, the shared data layer becomes an active participant in the security model rather than a
passive store waiting for every query to remember the right condition.
For an infrastructure platform, that distinction is fundamental. Tenant isolation has to survive new endpoints, refactors, administrative tools, background jobs, and tired humans debugging incidents at 2 a.m. Basalt keeps the boundary in every protected data request, exactly where the records live.