Security

TerraDesk holds two of the most sensitive things you have: the credentials that can change your infrastructure, and the authority to say a change may go ahead. This page explains how both are protected.

Deployment model

Devhub is one container image plus a PostgreSQL database. You deploy it with a Helm chart into a Kubernetes cluster you run.

  • Your Terraform code, state, plan output, logs, and cloud credentials never leave your infrastructure.
  • The database is yours. The chart can create one with CloudNativePG, or use one you already operate.
  • Devhub has no network path into your install. There is no support tunnel, no channel that phones home with your data, and no vendor access to your database.

A cloud-hosted option also exists, where the app itself runs in Devhub's cluster. TerraDesk requires a Tunnel in that mode. A workspace on a cloud-hosted install cannot run jobs in Devhub's cluster, so your Terraform still runs in your own infrastructure, against credentials that stay there.

How a Terraform run is isolated

Every plan and every apply is a one-shot Kubernetes Job, created for that run and torn down afterwards. Nothing is reused between runs.

The pod

securityContext:
  runAsUser: 1000
  runAsGroup: 1000
  fsGroup: 1000
restartPolicy: Never
automountServiceAccountToken: false
backoffLimit: 0
ttlSecondsAfterFinished: 3600
  • Never root. The pod and every container in it run as uid/gid 1000.
  • No Kubernetes credentials. automountServiceAccountToken: false means the runner gets no service account token. Your Terraform, your providers, and anything a module pulls in have no path to the Kubernetes API.
  • No retries. backoffLimit: 0, so a failed apply is never quietly re-run.
  • Nothing persisted. The only volumes are two emptyDirs, /workspace and /home/terradesk, and they die with the pod. The Job is cleaned up an hour later.

Each container

Every container in the run gets the same hardened context. That includes the ones TerraDesk builds (git, init, plan, apply, and the plan-transfer containers) and any init container you configure:

securityContext:
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
  runAsUser: 1000
  runAsGroup: 1000
  runAsNonRoot: true
  seccompProfile:
    type: RuntimeDefault

Your init containers are not an exception. They also cannot take the container names TerraDesk reserves for itself.

Source

The repository is cloned shallow, at the one branch the run is for. It uses a short-lived GitHub App installation token minted for that run. There is no long-lived deploy key and no SSH agent in the pod.

Credentials

Workspace secrets are encrypted at rest (see Encryption) and decrypted only while a run is being prepared. They go into a Kubernetes Secret in Devhub's namespace, written just before the Job starts, and reach the containers through envFrom. For a workspace with a Tunnel, the Secret goes into the Tunnel's namespace. A secret value never appears in the Job manifest, in an image, or on disk in the workspace volume.

On Google Cloud, workload identity removes the credential altogether. TerraDesk mints a short-lived token for the run's service account and exchanges it for an access token on the service account you name, so no long-lived cloud key is stored anywhere in the install.

The plan file never leaves the cluster

A plan and the apply that follows it are two separate Jobs, so plan.out has to move between them. It moves through Devhub, and the address the runner calls is worked out rather than configured:

  • A runner in Devhub's own namespace calls http://devhub.<namespace>.svc.cluster.local, an in-cluster address. The plan file never touches your public hostname, never crosses whatever terminates TLS at your edge, and is unaffected by an identity-aware proxy in front of the app.
  • A runner started through a Tunnel calls the Tunnel's Service in its own namespace, and the Tunnel relays the request to Devhub over the connection it already holds. Devhub serves only the plan upload and download to requests that arrive this way.

Both calls carry a token scoped to that one plan and valid for four hours. The endpoints accept nothing else.

Runs in another cluster

When the infrastructure lives somewhere Devhub cannot reach, give the workspace a Tunnel. The Tunnel runs in that cluster and opens an outbound WebSocket to Devhub, authenticated with a token. Nothing inbound to your cluster is opened.

Job orchestration runs in Devhub. Devhub creates the Job, writes its Secret, and reads Pod status and logs by sending Kubernetes API requests through the Tunnel. The Tunnel relays them under its own service account, in its own namespace. That service account's Role is namespace-scoped and covers Jobs, Pods, Pod logs, and Secrets, so that is the limit of what Devhub can do in the cluster.

What decides that a change may apply

The apply gate is where change control happens, and it is built to be audited:

  • Approvals name a person. Each one records the user who approved and the time they did it.
  • The requirement can be rechecked. Every plan stores the resource changes it proposed, so you can recompute what it needed rather than take it on trust.
  • Policies set the count per resource. A resource policy can demand specific approvers, or an approval count of its own, for certain resources and actions. An explicit count replaces the workspace baseline for the resources the policy governs, and no policy can set it to zero.
  • Nothing is deleted. Plans and approvals are permanent. Workspaces are archived rather than removed, and releasing a reservation records who did it and when.
  • Only one apply can start. A plan moves into applying under a row lock, so it can never have two apply jobs running, and the plans it invalidates are canceled in the same transaction.
  • A drift run can never apply. Drift detection is refresh-only. Fixing drift goes through an ordinary plan and the full gate.

Authentication

  • Name
    OIDC single sign-on
    Description

    Set up with a discovery document URI, client id, and client secret. An optional groups claim maps your identity provider's groups onto Devhub roles, so access follows your directory.

  • Name
    Authenticating proxy
    Description

    If you already front the app with an identity-aware proxy, Devhub can trust a header carrying the signed-in email, and a second one carrying groups. Once this is set, it is the only way anyone can sign in.

  • Name
    WebAuthn passkeys
    Description

    A hardware-backed second factor. An organization can require it, and a user without a passkey enrolls one before they can reach anything.

  • Name
    Sessions
    Description

    Signed cookies, SameSite=Lax, 30-day maximum age. Sessions can also be revoked server side: signing out invalidates every session for that user, so a cookie copied beforehand stops working immediately.

API keys

API keys are prefixed dh_ and stored hashed, never as the token itself. A key cannot be read back after you create it, so a lost key is replaced rather than recovered.

Every key has a permission set and an optional expiry, and the permission is checked against the route on each request. TerraDesk's workspace endpoints need full_access. Narrower scopes exist so a CI job that only triggers a workflow or reports coverage can hold a key that can do nothing else.

Authorization

  • The organization is the boundary. Every record is scoped by organization_id, and so is every query.
  • Permissions are layered. A check resolves as super_admin bypass → organization-wide role bypass → a per-object grant.
  • Grants are per resource. A permission ties an action, such as approve on one workspace, to a user or a role. Being able to see something never implies being able to act on it.
  • A role can also grant organization permissions. A super admin can give a role super admin, audit log viewer, manager or billing admin. Everyone in the role holds that permission while they are in the role. If roles come from a groups claim, a directory group can make its members super admins this way, and a member who leaves the group loses the permission at their next sign-in. Removing a permission from a role does not change the permissions set on a member directly.
  • Membership is archived, not deleted, so old approvals still resolve to a person.

Encryption

At rest

Sensitive columns are encrypted with AES-256-GCM before they reach the database, using a key only your install holds. In TerraDesk that covers workspace secrets, the plan log, and the saved plan output. Encryption is versioned, so you can rotate keys without a flag day.

This is separate from whole-database encryption, which is a property of the database you run. It means a backup, a replica, or a support dump carries no readable secrets.

In transit

  • The connection to PostgreSQL supports require, verify-ca, and full verify, with your own CA certificate and an optional client certificate.
  • TLS at the edge is terminated by your ingress. The chart includes an optional Traefik IngressRoute.
  • Configuration secrets are read from files under /etc/secrets rather than environment variables, so they never show up in a pod description or a dump of the environment.

Application hardening

  • Content Security Policy on every browser response, with a per-request nonce: default-src 'self', connect-src 'self', script-src 'self' 'nonce-…'. No third-party script origin at all: no analytics, tag manager, or session recorder.
  • CSRF protection on all browser routes, plus the standard secure browser headers.
  • The application container runs as non-root, with allowPrivilegeEscalation: false, all capabilities dropped, and the RuntimeDefault seccomp profile.
  • An optional NetworkPolicy ships with the chart and restricts ingress and egress to what the app needs.
  • The Kubernetes role is small and namespace-scoped: create and delete Jobs, read Pods and Pod logs, and create Secrets and service account tokens. Nothing cluster-wide, and no access to other workloads.

What leaves your install

  • Name
    Licensing
    Type
    outbound
    Description

    The install registers with Devhub's licensing service and refreshes its license. Requests are signed with the installation's own Ed25519 key, and the license that comes back is verified locally. What travels is licensing data: the organization's identity, its name, VAT ID and billing address, the email addresses holding seats, and the date it first reached each setup milestone, such as connecting a database or running a query. No customer content.

  • Name
    Error reporting
    Type
    outbound
    Description

    Production builds report application exceptions to Sentry: stack traces and the surrounding application context. Enough to diagnose a crash, not a copy of your data.

  • Name
    Tracing
    Type
    off by default
    Description

    OpenTelemetry export is off unless you set ENABLE_TELEMETRY=true, and then it goes to a collector you choose.

Your Terraform source, state, plan output, plan logs, workspace secrets, and cloud credentials are not sent anywhere. They live in your database, encrypted, and in the short-lived pods that run your Terraform.

Anything else

If something you need is not covered here, such as a control mapping, a security questionnaire, or a walkthrough with our engineering team, write to [email protected] and we will work through it with you.

Was this page helpful?