Skip to content

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):

  1. 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".
  2. 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.
  3. The admin pastes the token into the operator on the cluster, either via the Cluster CR (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>
  1. 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 as Active, and never accepts the registration token again.
  2. 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=X requires the credential's identity to match X. A mismatch returns 403 Forbidden.
  • POST /api/agent/jobs/{jobId}/ack requires the job's target cluster_id to match the credential's identity.
  • POST /api/agent/events requires the cluster_id field 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:

  1. Generate a key pair for the cluster (openssl genpkey -algorithm Ed25519 -out tls.key).
  2. Submit a CSR to the organization's CA; obtain tls.crt signed by the same CA Odoo's server cert chains to.
  3. Set Cluster.spec.security.mtls.enabled = true and apply tls.crt/tls.key to Secret/vworkspace-operator-credentials. The operator's Cluster reconciler picks up the new fields and reconfigures the outbound HTTP client.
  4. 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 event JobPayloadSignatureInvalid.
  • 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 as JobPayloadDecryptionFailed.

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:

  1. The operator calls POST /api/agent/credentials/rotate with 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).
  2. The operator writes the new credential into Secret/vworkspace-operator-credentials (replacing the old data), updates its in-memory HTTP client, and continues operating.
  3. 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_id is 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.