Skip to main content

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 permission PermX on 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 an ac.IsSuperAdmin gate).

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 = true requires ac.IsSuperAdmin.
  • A wildcard service-account scope (client_scope: ["*"]) requires ac.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:

  1. Authenticated? It's behind CombinedAuthMiddleware (every /api/v1 route is).
  2. Permission-gated? Either RequirePermission(auth.PermX) middleware or an in-handler ac.Can(perm, clientID) check (ideally both).
  3. Tenant-scoped? List/search surfaces pass AllowedClientIDs() — or ClientIDsWithPermission(perm) for a permission-gated surface — into the store, which branches on if clientIDs != nil (never len() > 0).
  4. Right permission class on writes? Destructive/mutating ops require :write / :deletenot mere tenant membership (HasAccessToClient).
  5. Nested children ownership-verified? Every child UUID (run→host, file→host, token→client) is checked to belong to the authorized parent; mismatch → 404.
  6. Global resource → super-admin? No-client_id resources (compliance library, global audit log) are gated on ac.IsSuperAdmin.
  7. Grant path → subset check? Any path that grants permissions/roles/scope uses CanGrantPermissions / CanGrantPermissionsForClient; is_super_admin and wildcard ["*"] scope require super-admin.
  8. 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 (AuthContext methods), internal/api/scope.go (requireHostAccess / requireEnvAccess)
  • Regression guard: internal/store/scoping_regression_test.go