Skip to content

Tenant isolation

Audience

PLANA staff, customer CTOs, security reviewers. This page describes what one tenant can and cannot do to another.

PLANA is a multi-tenant platform. Every customer's data shares the same cluster, the same PostgreSQL server, and (within a major version) the same worker Deployment. Isolation comes from a layered set of guarantees, each of which would have to fail before a tenant could observe another tenant's data.

What is shared

ResourceShared scope
Kubernetes clusterAll tenants on all majors
Envoy GatewayAll tenants
PostgreSQL server (pg01)All tenants and platform DBs
Redis / ValkeyAll tenants and platform services
NFS filestore exportAll tenants of a given major
worker-odoo DeploymentAll tenants of a given major (and edition Starter/Pro share the same pool)
ai-agents runtimeAll tenants invoking agents

What is per-tenant

ResourcePer-tenant scope
PostgreSQL databaseYes — one DB per tenant, named after the hostname
Filestore subdirectoryYes — /var/lib/odoo/filestore/{db_name} mounted via SubPath
HTTPRouteYes — keyed on the hostname
Backup CronJobYes — one per tenant
Matrix support roomYes — #support-{slug}-{projectId:03d}
Redis session prefixYes — odoo:session:{db}:
Redis bus channel prefixYes — odoo:bus:{db}:
Stripe customer recordYes — in the platform Stripe account

Layers of isolation

Layer 1 — hostname → database

A request hits Envoy Gateway and is routed to the worker-odoo Service in the right version namespace based on the hostname. The worker reads the HTTP Host header and applies Odoo's dbfilter=^%h$ setting, which restricts the connection to the database whose name equals the host header.

If the host header is acme.planapulse.app, Odoo will only connect to the database named acme.planapulse.app. There is no UI to switch databases at runtime; the /web/database/* endpoints are blocked at the gateway level (since 2026-05-20) to prevent any path that could enumerate or select other DBs.

Layer 2 — PostgreSQL database boundary

Each tenant has its own PostgreSQL database. The plana role owns every tenant DB, but Odoo's connection is opened with dbname={db_name} and PostgreSQL enforces the database-level boundary. Cross-database SQL requires the foreign data wrapper, which is not installed.

A vulnerability that allowed arbitrary SQL execution inside Odoo (e.g. through a safe_eval bypass) would still be limited to that one database.

Layer 3 — filestore SubPath mount

The NFS export is mounted into the worker pod, but each per-tenant filestore is a separate SubPath. The worker's filesystem view shows /var/lib/odoo/filestore/{db_name}/... and nothing else from the export.

This is a kubelet-level guarantee: SubPath mounts cannot escape upward in the source tree from the kubelet's perspective. A file path constructed inside Odoo cannot reach another tenant's filestore because the worker pod literally does not see it.

Layer 4 — session and bus prefixes in Redis

Sessions live in Redis with the key odoo:session:{db}:{token}. The plana_session_redis module sets the session prefix from the DB name on worker startup, and dbfilter prevents a worker process from serving a different DB. So even though Redis is shared, session lookup uses a DB-scoped prefix.

Same pattern for the Odoo bus (plana_bus_redis): channel names are prefixed with the DB name, so a longpoll subscriber for tenant A cannot see tenant B's broadcasts.

Layer 5 — RBAC inside Odoo

Within a single tenant's database, Odoo's standard record rules and groups control what users can see and do. PLANA layers base_user_role and plana_user_roles on top to give tenant administrators a real RBAC model on top of Odoo's group-based system. This is in-tenant access control — relevant for "should sales see HR salaries" — not cross-tenant isolation.

Layer 6 — NetworkPolicies

Cluster-level NetworkPolicies restrict which pods can talk to which:

  • Worker pods can reach only pg01, Redis, NFS, and the Envoy data plane
  • Agent pods can reach only the worker Service for their tenant (or ai-agents reaches all workers but uses a tenant-scoped XML-RPC API key)
  • Tools namespaces (Forgejo, Matrix, etc.) are network-isolated from tenant namespaces

A compromised pod cannot reach an arbitrary network address. Egress default-deny is in place; allowed destinations are listed by NetworkPolicy.

Layer 7 — image pull, Pod Security Standards

Worker pods run with:

  • runAsUser: 100 (v18+) or 101 (v17), non-root
  • readOnlyRootFilesystem: true
  • allowPrivilegeEscalation: false
  • Dropped capabilities except those Odoo specifically needs
  • A seccompProfile: RuntimeDefault

The cluster enforces the Restricted Pod Security Standard on tenant namespaces. Even if a tenant pod is exploited, the attacker cannot escalate to root or modify the read-only root filesystem.

Layer 8 — audit logging

Every Kubernetes API call that mutates state is captured by an in-cluster ValidatingAdmissionWebhook and sent to Loki. If a tenant ever observed another tenant's data through a cluster-level path, the trail would be visible in the audit log. Audit retention is 90 days.

The shared worker-odoo Deployment — why it's not a problem

Multiple tenants share the same Odoo worker pods. Each request is handled independently:

  1. The worker process opens a fresh DB cursor scoped to the host header's DB.
  2. Odoo's request-handling thread serves the request, possibly writing to the DB and the filestore subdirectory for that tenant.
  3. The cursor closes; the next request can be for a different tenant.

Tenant A and tenant B are concurrent at the request level inside the same pod, but each request is bound to its DB and its filestore subpath from the start. A bug in Odoo that stored data on a global Python module attribute could leak across requests — we monitor for this kind of regression in our integration tests, and Odoo upstream is generally very careful about it.

For tenants who want process-level isolation, the Enterprise edition includes a dedicated K8s node and a single-tenant worker pool. The same isolation mechanisms apply, just with the shared-process layer removed.

What is NOT isolated

Honest list of cross-tenant shared resources:

  • Cluster-level events — if a tenant pod is OOM-killed, the Kubernetes event is visible to anyone with cluster RBAC. We do not expose cluster RBAC to customers.
  • The DNS resolver — every tenant resolves via the same cluster CoreDNS. No tenant can manipulate another's DNS, but resolver behaviour is shared.
  • The image registry — all tenants pull the same base image. A compromise of the registry would affect every tenant, but registry isolation is a registry-side concern, not a tenant-side one.
  • The pg01 instance — one VM, finite IOPS. A tenant running an expensive report will not see another tenant's data, but it can cause brief contention on the database server. Pro and Enterprise tiers get larger per-tenant connection pools to mitigate this.

What a customer can do to verify isolation

  • Issue a SQL query through Odoo's developer mode and confirm it sees only their database. (Available to tenant administrators via the standard Odoo developer console.)
  • Check the host header → DB binding on their own tenant by changing the URL to a non-existent subdomain — the request will hit Envoy, be routed to the worker, the worker will fail dbfilter, and return a standard Odoo error rather than serving any DB.
  • Inspect their HTTPRoute — Pro and Enterprise customers can be given read access to their own PLANAClient resource and the HTTPRoute it emits, demonstrating the per-tenant hostname binding.

Where to read more

© PLANA Digital Ltd.