Skip to content

Multi-tenancy architecture

Several teams can share one cluster without seeing each other’s agents, credentials or traffic. This page explains how a Workspace achieves that, why access is decided in management-api rather than in Kubernetes RBAC, and which routes check the workspace boundary.

A Workspace is a cluster-scoped resource that owns exactly one Kubernetes namespace. The controller creates the namespace when spec.namespace.create is true, labels it with defaultTags for cost attribution, and refuses to adopt a namespace that another Workspace owns. With admission webhooks enabled, a second Workspace naming the same namespace is rejected before it is stored.

Inside that namespace the controller creates the pieces that carry the isolation. The diagram shows what one Workspace produces.

graph LR
ws[Workspace] --> ns[Namespace]
ws --> sa[ServiceAccounts workspace-NAME-ROLE-sa]
ws --> np[NetworkPolicy workspace-NAME-isolation]
ws --> tokrole[Role workspace-NAME-management-api-token]
sa --> rb[RoleBindings workspace-NAME-ROLE]
rb --> cr[ClusterRoles omnia-workspace-owner, editor, viewer]
tokrole --> mapi[management-api ServiceAccount]
ns --- sa
ns --- np
ns --- tokrole

Three roles exist, and each has a ServiceAccount bound to a Helm-installed ClusterRole of the same name:

  • viewer reads Omnia resources, ConfigMaps, pods, logs and events. It cannot read Secrets.
  • editor creates, updates and deletes AgentRuntimes, PromptPacks, ToolRegistries, ToolPolicies, Connections, SkillSources, the Arena resources and ConfigMaps. It reads Secrets for referencing, and reads Providers without changing them.
  • owner can do everything an editor can, plus create, update and delete Providers, write Secrets, and patch the Workspace itself, which is how members are managed.

Providers sit with the owner because a Provider carries a credential, so an editor can reference one and cannot redirect it.

The role bindings on a Workspace are not Kubernetes RoleBindings for people. management-api, which serves every dashboard API call, works out a user’s role per request, from three inputs: the IdP groups in spec.roleBindings, unexpired entries in spec.directGrants, and the anonymousAccess role. The user gets the highest role any of them confers. An enabled anonymousAccess is also a floor, so a signed-in user is never worse off than a visitor. anonymousAccess.role defaults to viewer.

ServiceAccounts are the exception. For each ServiceAccount in spec.roleBindings, the controller creates a real RoleBinding to the matching ClusterRole, because CI and GitOps tools call the Kubernetes API directly and never pass through management-api.

Kubernetes RBAC wants one binding per subject per namespace, so N users across M workspaces means N×M objects, all kept in step with the identity provider by hand. Group claims avoid that. A Workspace lists a handful of groups, membership changes happen in the IdP, and the next request sees them. The cost is that the Kubernetes API server never sees the human. It sees a workspace ServiceAccount, so the audit log names the role used and management-api has to be trusted to pick the right one.

Once management-api has a role, it makes the Kubernetes call as that role’s ServiceAccount, with a short-lived token from the TokenRequest API.

sequenceDiagram
participant U as User
participant D as management-api
participant K as Kubernetes API
U->>D: Request workspace resources
D->>K: Get Workspace
K-->>D: Workspace spec
D->>D: Match groups, direct grants and anonymous access to a role
alt no cached token for this workspace and role
D->>K: TokenRequest for workspace-NAME-ROLE-sa, 1 hour
K-->>D: Token
end
D->>K: Call with the role's token
K-->>D: Resources
D-->>U: Response

Tokens are cached per workspace and role, and re-requested once they are within five minutes of the expiry the API server reported, so a one-hour token is reused for about 55 minutes.

If a token cannot be minted, management-api fails the request. It never falls back to its own ServiceAccount.

The same concern shapes management-api’s own permissions. Its ClusterRole reaches only what no Workspace role can, such as reading and patching Workspaces, and it has no cluster-wide serviceaccounts/token. Held cluster-wide, that verb would let a compromised management-api mint the operator’s token. Each Workspace instead grants it a Role in the Workspace’s own namespace that allows create on serviceaccounts/token for the three role ServiceAccounts, and another that lets it read, create and update the Secrets a Connection’s token is written to. The dashboard itself is static files served by nginx: its pod mounts no ServiceAccount token, and nothing grants its ServiceAccount anything.

Every Provider and ToolRegistry an agent uses lives in the agent’s own namespace, and a reference naming another namespace is refused:

  • A toolRegistryRef leaves the AgentRuntime Pending with the ToolRegistryCrossNamespace reason and stops its Deployments. The agent pod’s Role cannot read a foreign namespace, and registry-scoped ToolPolicies would not match.
  • A providerRef fails the AgentRuntime with the ProviderCrossNamespace reason and stops its Deployments, so a pod already running does not keep another workspace’s credential.

Two workspaces that need the same model each get their own Provider and their own credential Secret. The duplication is the price of making sure that one workspace’s configuration cannot change what another workspace’s agents can reach.

Setting spec.networkPolicy.isolate: true makes the controller write a NetworkPolicy named workspace-<name>-isolation, and setting it back to false deletes the policy. The policy selects every pod in the namespace except codegen sandbox pods and sandbox egress proxy pods. Kubernetes unions the allows of every policy that selects a pod, so including a sandbox would add this policy’s broad allows to the one pod that must reach almost nothing. Sandboxes keep only the stricter policy written for their claim.

graph TB
pods[Workspace pods]
pods --> dns[DNS: kube-system port 53]
pods <--> same[Same namespace]
pods <--> opns[Operator namespace]
pods --> ext[0.0.0.0/0 except 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16]
pods <--> custom[allowFrom and allowTo rules]

The operator namespace is allowed in both directions so management-api, the operator and Prometheus can reach workspace pods, and so session-api and memory-api can reach the Postgres, Redis and tracing services that run beside the operator. The controller learns that namespace from its POD_NAMESPACE variable, and omits the rule when the variable is empty, which leaves allowFrom and allowTo as the only cross-namespace paths. Reaching a cluster service in any other namespace needs an explicit allowTo.

Blocking the RFC 1918 ranges from the external-API rule is what stops a workspace reaching another tenant’s pods, so it only works when the cluster gives pods private addresses. allowPrivateNetworks removes those exclusions for local work, such as agents calling a local LLM server, and weakens tenant isolation. allowExternalAPIs: false removes the internet rule, which leaves only the destinations you list.

Network policy is defence in depth. If the application-layer checks fail, traffic between tenants still has to cross a boundary the dashboard does not control. See the Workspace reference for every field.

Two data-plane services serve one workspace each. Both check the workspace on some routes and not on others.

  • memory-api checks the workspace it is given against its own workspace on the routes that take it as a query parameter and on the ingest routes. Routes that take it in the request body, including saving and updating memories, links between memories, and both retrieve calls, do not compare it. The institutional and agent-memory list, delete and save routes do not compare it either. Any caller memory-api admits, including any agent pod in the service group, can therefore name another workspace’s UID.
  • session-api checks that session creation names the caller’s own namespace and workspace. It checks the namespace on session deletes, bulk deletes, the disclosure summary, the per-session eval-result summary, and the aggregate, discover and scenario-history routes for evals, provider calls and provider usage. Session list and search, the eval-results list and the Arena matrix take a namespace parameter and do not compare it with the caller.

session-api skips its checks when authentication is disabled, and memory-api skips its check when it was started without its workspace UID. For the routes that do not check, the NetworkPolicy is what keeps another workspace’s pods away from these services.

The 1:1 mapping keeps RBAC, quotas and NetworkPolicies on the objects Kubernetes already scopes by namespace, so existing tooling works unchanged. The price is that a team needing two isolated environments needs two Workspaces.

A Workspace’s spec.quotas caps the compute requests and object counts of its namespace. The controller enforces it through a Kubernetes ResourceQuota there. Limits, and object counts of Omnia kinds, take a ResourceQuota and LimitRange of your own beside it. See Limit resource usage.

Third-party multi-tenancy controllers such as Capsule add their own abstractions and another component to operate. Workspaces cover only what Omnia needs, including the dashboard’s role model, at the cost of being specific to Omnia.

The three fixed roles are enough for team-level access. Finer rules, such as per-agent permissions, belong in an external policy engine rather than in more roles.