Tenant Isolation & Authorization
This is the standard every API endpoint must meet before it ships. It exists so
that future endpoints do not reintroduce the cross-tenant and privilege-escalation
gaps closed in the RBAC pre-launch audit
(docs/audits/RBAC-AUDIT-2026-06-01.md).
Proxima Console is multi-tenant. A single backend serves many clients, and the external Client role is a real, hostile-by-assumption principal. "It requires a UUID the attacker shouldn't know" is not an authorization control — UUIDs leak, get logged, and are enumerable. Every route must prove who may act and whose data they may touch.
The dual authorization model
Every authenticated route is protected by one or both of:
- (A) Middleware gate — registered with
.With(RequirePermission(auth.PermX)). Proves the caller holds permissionPermXon some client. - (B) In-handler self-check — the handler resolves the resource's owning client
and verifies access (
ac.HasAccessToClient(clientID),ac.Can(perm, clientID), or anac.IsSuperAdmingate).
Defense in depth wants both. A RequirePermission route is not tenant-safe on
its own: the middleware proves the caller MAY perform the action somewhere, never
WHOSE data they may touch. The handler must still scope by the owning client.
Conversely, a bare route (no RequirePermission) is only safe if the handler
self-authorizes — many of the audit's Criticals were bare routes with no self-check
at all.
// Both gates: middleware proves the permission, handler proves the tenant.
r.With(RequirePermission(auth.PermHostsDelete)).Delete("/{hostID}", h.Delete)
func (h *HostHandler) Delete(w http.ResponseWriter, r *http.Request) {
ac, _ := auth.FromContext(r.Context())
clientID, ok := requireHostAccess(w, r, ac, h.hosts, h.envs, hostID) // owning client
if !ok {
return // 403/404 already written
}
if !ac.Can(auth.PermHostsDelete, clientID) { // per-client permission re-check
apiutil.WriteError(w, r, http.StatusForbidden, "forbidden", "access denied")
return
}
// ... proceed
}
The four enforcement primitives
The helpers live in internal/auth/context.go (the AuthContext methods) and
internal/api/scope.go (the HTTP ownership resolvers).
1. Tenant scoping — ac.AllowedClientIDs()
Returns the clients the caller can access. Semantics are load-bearing:
nil→ super-admin (or wildcard service account) → all clients.- non-nil, including empty → exactly those clients (empty = none).
Pass the result into the store, which builds WHERE client_id = ANY($1). The store
MUST branch on if clientIDs != nil, never if len(clientIDs) > 0:
// CORRECT — a zero-assignment user has a non-nil empty slice → no rows.
if clientIDs != nil {
query += " WHERE client_id = ANY($1)"
args = append(args, pq.Array(clientIDs))
}
// WRONG — len()==0 falls through to the unfiltered branch and returns
// EVERY tenant's rows. This is the empty-slice cross-tenant leak.
if len(clientIDs) > 0 { ... }
A regression test — internal/store/scoping_regression_test.go — fails the build if
any store file uses the len(clientIDs) > 0 (or len(filter.ClientIDs) > 0) pattern.
Do not silence it; fix the scoping.
2. Per-client permission — ac.Can(perm, clientID)
RequirePermission only union-checks for routes whose client is a path param
(no ?client_id in the query string): it passes if the caller holds the permission on
any of their clients. So a caller with hosts:delete on Client A passes the
middleware for a DELETE /hosts/{hostID} request targeting Client B's host.
The handler must re-check against the server-resolved client:
clientID, ok := requireHostAccess(w, r, ac, h.hosts, h.envs, hostID)
if !ok {
return
}
if !ac.Can(auth.PermHostsDelete, clientID) { // NOT the union check — this client
apiutil.WriteError(w, r, http.StatusForbidden, "forbidden", "access denied")
return
}
3. Per-client-permission scoping — ac.ClientIDsWithPermission(perm)
For a permission-gated list/search surface, scope by the clients where the caller
actually holds that permission — not by every client they can reach. Using
AllowedClientIDs() here would expose clients where the caller has only other
permissions.
// List service-desk tickets the caller is allowed to READ.
clientIDs := ac.ClientIDsWithPermission(auth.PermServiceDeskRead) // nil = super-admin
tickets, err := h.store.List(ctx, clientIDs, filter)
Same nil / non-nil-empty semantics as AllowedClientIDs(), so the store branches
on if clientIDs != nil.
4. Ownership resolution / IDOR prevention — requireHostAccess / requireEnvAccess
Resolve a child resource to its owning client server-side and 403 on mismatch. These
live in internal/api/scope.go:
clientID, ok := requireHostAccess(w, r, ac, hosts, envs, hostID) // host → env → client
clientID, ok := requireEnvAccess(w, r, ac, envs, envID) // env → client
For nested children (run → host, file → host, token → client) verify the child belongs to the authorized parent server-side. Never treat the mere existence of a path UUID as authorization:
run, err := h.runs.GetByID(ctx, runID)
// ...
if run.HostID != hostID { // the run must belong to the host the caller is authorized for
apiutil.WriteError(w, r, http.StatusNotFound, "not_found", "run not found")
return
}
Return 404 (not 403) for a child that exists but belongs elsewhere, so the endpoint does not confirm the UUID's existence to an attacker.
Global (non-tenant) resources require super-admin
Some resources have no client_id — they are platform-wide:
- the compliance framework / control / mapping library (the global catalog), and
- the global audit log (
GET /api/v1/audit-log).
Reading or mutating these is a platform action. Gate them on ac.IsSuperAdmin, not on
tenant membership:
if !ac.IsSuperAdmin {
apiutil.WriteError(w, r, http.StatusForbidden, "forbidden", "super-admin required")
return
}
Per-client audit visibility remains available via the scoped
/admin/access-audit/by-client/{clientId} endpoint (which verifies the path client is
in AllowedClientIDs()).
Grant guards — no privilege escalation
A caller may never grant a permission, role, or scope it does not itself hold. Assignment and role-authoring paths (users, roles, teams, service accounts) must use the grant guards:
ac.CanGrantPermissions(perms)— global/union form, for role authoring where the role is not yet bound to a client.ac.CanGrantPermissionsForClient(perms, clientID)— per-client form, for assignment paths that target a known client (prevents over-granting permission X on client B because you hold X on client A).
Additionally:
- Setting
is_super_admin = truerequiresac.IsSuperAdmin. - A wildcard service-account scope (
client_scope: ["*"]) requiresac.IsSuperAdmin. - A service-account / user-client scope must be a subset of
ac.AllowedClientIDs().
// Assigning a role on a specific client — caller must already hold every
// permission that role would grant ON THAT CLIENT.
if !ac.CanGrantPermissionsForClient(role.Permissions, clientID) {
apiutil.WriteError(w, r, http.StatusForbidden, "forbidden",
"cannot grant permissions you do not hold")
return
}
Checklist for every new endpoint
Walk this list for each route before it merges:
- Authenticated? It's behind
CombinedAuthMiddleware(every/api/v1route is). - Permission-gated? Either
RequirePermission(auth.PermX)middleware or an in-handlerac.Can(perm, clientID)check (ideally both). - Tenant-scoped? List/search surfaces pass
AllowedClientIDs()— orClientIDsWithPermission(perm)for a permission-gated surface — into the store, which branches onif clientIDs != nil(neverlen() > 0). - Right permission class on writes? Destructive/mutating ops require
:write/:delete— not mere tenant membership (HasAccessToClient). - Nested children ownership-verified? Every child UUID (run→host, file→host, token→client) is checked to belong to the authorized parent; mismatch → 404.
- Global resource → super-admin? No-
client_idresources (compliance library, global audit log) are gated onac.IsSuperAdmin. - Grant path → subset check? Any path that grants permissions/roles/scope uses
CanGrantPermissions/CanGrantPermissionsForClient;is_super_adminand wildcard["*"]scope require super-admin. - Cross-tenant negative tests added? Add unit tests proving a scoped caller gets 403/404 against another tenant's resource — not just the happy path.
Reference
- Audit report:
docs/audits/RBAC-AUDIT-2026-06-01.md - Primitives:
internal/auth/context.go(AuthContextmethods),internal/api/scope.go(requireHostAccess/requireEnvAccess) - Regression guard:
internal/store/scoping_regression_test.go