Authentication and identity¶
Status: Alpha Last Updated: 2026-05-30
This document describes how a cluster authenticates itself to the control plane (and vice versa) in Pull mode, what the credential lifecycle looks like, and the optional security layers an organization can turn on for production deployments. Push and GitOps modes use simpler credential models, summarized at the end.
The design goals: minimum credential surface on Odoo (no kubeconfig); per-cluster scoped tokens; clear rotation procedures; no secret material in logs. The rationale is recorded in ../adr/0003-pull-mode-as-default-connectivity.md.
Cluster identity¶
Every cluster has a stable identity record in Odoo, projected into the cluster as a single Cluster CR (ops.vworkspace.io/v1alpha1). The identity record consists of:
| Field | Origin | Use |
|---|---|---|
cluster_id |
Generated by Odoo at registration time | Stable, opaque ID. Surfaces as app.vworkspace.io/cluster-id label on every resource the operator creates. |
display_name |
Set by the admin in Odoo | Human-readable; appears in Odoo's Cluster Registry UI and in the cluster's own Cluster CR. |
owning_organization |
Set by Odoo | Which org owns the cluster. Cross-org reads are not possible by construction. |
public_key (optional) |
The cluster generates and registers | Used when signed or encrypted payloads are enabled. |
allowed_namespaces |
Set by Odoo | Which namespaces the operator is willing to manage on this cluster. |
allowed_catalog_entries |
Set by Odoo | Catalog entries the operator may install from. |
allowed_operation_templates |
Set by Odoo | Operation templates the operator may run. |
connectivity_mode |
Set by Odoo | pull, push, or gitops. |
The cluster does not hold credentials for other clusters. Odoo holds the identity records and (for Pull mode) the bootstrap material; the cluster holds its own outbound credential to Odoo.
The one-time registration token¶
A new cluster is registered by an Odoo administrator (or the AI assistant on the administrator's behalf):
- The admin opens the Cluster Registry view in Odoo, fills in display name, owning organization, allowed namespaces, allowed catalog entries, and allowed operation templates, and clicks "Issue registration token".
- Odoo creates the identity record (status:
Pending) and issues a one-time, time-bounded registration token. The token is shown to the admin once; Odoo stores only a hash. - The admin pastes the token into the operator on the cluster, either via the
ClusterCR (spec.registration.token) or via the CLI helper:
kubectl -n vworkspace-system exec deploy/vworkspace-app-operator \
-- vworkspace-app-operator register --token=<one-time-token>
- The operator presents the token on its first outbound connection to the control plane's
POST /api/agent/register. Odoo verifies the token (matching hash, not expired, not used), generates a long-lived bearer token plus an optional client certificate, returns them to the operator over the same TLS connection, marks the identity record asActive, and never accepts the registration token again. - The operator persists the long-lived material as a Kubernetes
Secret(vworkspace-system/vworkspace-operator-credentials), zeroes the in-memory copy of the one-time token, and begins pulling jobs.
The one-time token is the only piece of secret material the admin handles. It is short-lived (default 24 hours), single-use, and the only path Odoo offers for issuing a long-lived credential to a cluster.
The long-lived bootstrap credential¶
The bootstrap credential is the credential the operator presents on every subsequent outbound call to Odoo. The default shape is a bearer token; with optional mTLS enabled, a client certificate is added.
The credential lives in:
Secret/vworkspace-system/vworkspace-operator-credentials
data:
token: <base64-encoded bearer token>
ca.crt: <base64-encoded Odoo CA (for verifying the server cert)>
tls.crt: <optional, present when mTLS is enabled>
tls.key: <optional, present when mTLS is enabled>
The Secret is owned by the operator's Cluster CR (metadata.ownerReferences), restricted to the vworkspace-system namespace, and accessible only to the operator's ServiceAccount (RBAC details in rbac.md). It is never copied to any other namespace and never written to logs.
The bootstrap credential has a server-defined expiry; the operator monitors expires_at returned with each refresh and rotates ahead of expiry, see "Token rotation" below.
Scoped tokens per cluster¶
A bootstrap credential can fetch jobs and post status only for its own cluster_id. Odoo enforces this server-side on every request:
GET /api/agent/jobs?cluster=Xrequires the credential's identity to matchX. A mismatch returns403 Forbidden.POST /api/agent/jobs/{jobId}/ackrequires the job's targetcluster_idto match the credential's identity.POST /api/agent/eventsrequires thecluster_idfield in the body to match the credential's identity.
Cluster-scope is the only authorization scope the bootstrap credential carries. There is no "per-namespace" cluster credential and no "operator can fetch but not post" credential. RBAC at finer granularity is enforced inside the cluster by the operator's admission webhook and the namespace-scoped Roles in rbac.md.
Optional mTLS¶
For organizations that operate a small PKI (most production deployments behind a corporate or institutional CA), the operator can present a client certificate in addition to the bearer token, and pin Odoo's server certificate to the issuing CA.
Enabling mTLS:
- Generate a key pair for the cluster (
openssl genpkey -algorithm Ed25519 -out tls.key). - Submit a CSR to the organization's CA; obtain
tls.crtsigned by the same CA Odoo's server cert chains to. - Set
Cluster.spec.security.mtls.enabled = trueand applytls.crt/tls.keytoSecret/vworkspace-operator-credentials. The operator's Cluster reconciler picks up the new fields and reconfigures the outbound HTTP client. - Odoo's reverse proxy (or its built-in TLS handler) is configured to require client certs and verify them against the same CA bundle.
With mTLS, the bearer token is reduced to a session marker; the certificate is the primary credential. Compromise of the bearer token alone is no longer sufficient.
Optional payload signing and encryption¶
For Pull-mode deployments where job payloads flow through infrastructure the cluster does not fully trust (a corporate proxy, a message broker), payloads can be:
- Signed by Odoo with an control-plane-side keypair, verified by the operator with Odoo's public key (stored in
Cluster.status.odooPublicKey). The operator rejects payloads that do not verify, with audit eventJobPayloadSignatureInvalid. - Encrypted to the cluster's public key (uploaded to the control plane at registration time). Odoo encrypts payloads that contain chart-values material on send; the operator decrypts on receive using
Secret/vworkspace-system/vworkspace-operator-keypair. Decryption failures surface asJobPayloadDecryptionFailed.
Toggles:
apiVersion: ops.vworkspace.io/v1alpha1
kind: Cluster
metadata:
name: cluster-prod-1
namespace: vworkspace-system
spec:
security:
requireSignedPayloads: true
requireEncryptedSecretPayloads: true
mtls:
enabled: true
The defaults in v1alpha1 are conservative: "warn but admit" on missing signatures, "do not require encryption". Organizations turn both on for production clusters.
Token rotation¶
The operator rotates its bootstrap credential on a schedule defined by Odoo (default: weekly, configurable per organization). The flow:
- The operator calls
POST /api/agent/credentials/rotatewith the current credential. Odoo issues a new bearer token (and optionally a new client cert), returns them, and continues to accept the old credential for a grace period (default 24 hours). - The operator writes the new credential into
Secret/vworkspace-operator-credentials(replacing the old data), updates its in-memory HTTP client, and continues operating. - After the grace period, Odoo invalidates the old credential.
Rotation is fully automatic; no human action is required. The operator emits a Kubernetes event on rotation and an audit event to Odoo. If rotation fails (e.g., control plane unreachable), the operator continues to use the existing credential until it expires; it does not stop reconciling because it could not rotate.
Manual rotation is supported: an admin in Odoo can revoke a credential, which forces the operator to re-rotate (or to re-register if the credential was revoked before rotation). The operator surfaces the revocation as Cluster.status.conditions[Authenticated]=False.
How the control plane authenticates operator status posts¶
Status flows the other way: the operator posts to POST /api/agent/events (and the per-job /ack, /status, /result endpoints) on Odoo. the control plane authenticates these posts in one of two ways:
- mTLS (the recommended path when mTLS is enabled overall). Odoo's reverse proxy authenticates the client certificate, and the cluster's
cluster_idis derived from the certificate's CN/SAN. - HMAC (the default when mTLS is not enabled). Each post carries an HMAC over its canonical body using a key derived from the bootstrap credential. Odoo verifies the HMAC server-side. The same key is rotated alongside the bootstrap credential.
Neither approach uses bearer-token-only authentication for status posts; the bearer token alone authenticates the GET-side job fetch, but writes require either mTLS or HMAC. This protects against a token-only credential being replayed against the status endpoints from outside the cluster.
Push mode¶
In Push mode, Odoo holds a per-cluster ServiceAccount kubeconfig scoped to apps.vworkspace.io and ops.vworkspace.io resources (and optional read on helm.toolkit.fluxcd.io for status). The cluster does not hold any credentials for Odoo. Token rotation is a kubeconfig refresh; the kubeconfig lives in Odoo's platform store, restricted to the Cluster Registry view's authorized users. The same audit-event flow is used for status; Odoo subscribes via the K8s API for resource updates (server-side watch on the operator's CRDs).
GitOps mode¶
In GitOps mode, Odoo holds a Git write credential and the cluster holds a Git read credential. Each credential is scoped to the Git repository the organization has dedicated to the cluster (or to a subdirectory within a multi-cluster repository). Status flows back via the same audit-event endpoint used in Pull mode: the operator is still installed on the cluster and is still authenticated to the control plane for status posts, but the inbound intent reaches the cluster through Git rather than through POST /api/agent/jobs.
Stored credentials are Kubernetes Secrets¶
Everything in this document that says "credential" lives in a Kubernetes Secret in the vworkspace-system namespace, with the operator's ServiceAccount as the only principal authorized to read it. No credential is written to:
- Container image layers.
- Configuration files mounted via ConfigMap.
- Kubernetes Events emitted by the operator.
- Audit events sent to Odoo.
- Structured log lines.
The operator's structured logger redacts any value at a key path that resolves into the credential Secret. The credential is read at startup, cached in memory under a unsafeCredential wrapper that overrides String() and MarshalJSON to return [REDACTED], and never re-marshalled into a struct that flows to logs.
Related material¶
- least-privilege.md — Why the credentials Secret is restricted to
vworkspace-systemand not duplicated elsewhere. - secrets-handling.md — How chart-values secrets (a different class of secret) are handled.
- threat-model.md — Adversary model for the credential surface.
- ../install/cluster-bootstrap.md — Bootstrap procedure with the one-time-token exchange.
- ../adr/0003-pull-mode-as-default-connectivity.md — Why Pull mode is the default.