CHG-0047: Server Certificates for Tenants' Services

CHG-0047: Server Certificates for Tenants’ Services #

Date2026-10-11
Change typeDeployment · Configuration
ClassificationStructural
StatusComplete
Window2026-10-11, about 17:30 to 18:55 UTC, with the ceremony in the middle
Sitemobile
Systemsdv02idn001v01 (OpenBao: a new PKI mount and CA), dv02prv001v01 (the Deevnet API); the provider; one tenant’s workload (eds)
Automationdeevnet.mgmt playbooks/tenant-service-ca.yml, site.yml --tags openbao,deevnet-api; deevnet-provisioning-api; terraform-provider-deevnet; inventory mobile
RiskMedium. Most likely to go wrong: the signing role admits a name it should not, which would let a certificate be issued for a substrate name that every client would accept
Related changesCHG-0033 (built the hierarchy and the Tenant Device CA this follows), CHG-0034 (device certificates, planned and independent of this), CHG-0036 (where OpenBao’s CA keys are kept, planned)
Related incidentsNone
Related runbooksIssuing CA, Certificates

Summary #

A tenant workload cannot serve HTTPS that the site trusts: the Substrate CA certifies only the substrate, and the Tenant Device CA only devices. The eds tenant serves a console over plain HTTP for that reason.

ADR-0037 decides a third issuing CA, the Tenant Service CA, in OpenBao. After this change a tenant sends a signing request through the Deevnet API for a name in its own zone and receives a one-year server certificate that chains to the Deevnet Root CA. The eds console is the first to use one.

Goal #

  • OpenBao has a mount pki-tenant-service whose CA is the Mobile Tenant Service CA, chaining to the Deevnet Root CA through the Mobile Site CA.
  • The mount has one signing role. It signs a request for console.eds.mobile.deevnet.net, and refuses one for grafana.mobile.deevnet.net, for mobile.deevnet.net, for a wildcard and for a name outside deevnet.net.
  • The API’s OpenBao policy allows signing on that role and nothing else on the mount.
  • A tenant’s deevnet_certificate applies and returns a leaf that meets the Certificates standard: the subject, names, usages and lifetime of a tenant service certificate, whatever the signing request asked for.
  • One tenant cannot obtain a certificate for a name in another tenant’s zone.
  • https://console.eds.mobile.deevnet.net/ opens in a browser that trusts only the Deevnet Root CA.
  • The Tenant Device CA, its mount and every existing certificate are unchanged.

Scope #

In scope: the mount, the CA and its ceremony, the signing role and the API’s policy; the API’s certificate route and the provider’s resource; the API’s release and deployment; one tenant service moved to HTTPS.

Out of scope: revocation; renewal by anything but a tenant’s own apply; expiry alerting; device certificates (CHG-0034); where OpenBao’s CA keys are kept (CHG-0036); the tenant runbook and the architecture pages, which are written when this is built.

Risk and impact #

RiskWhereGuard
The signing role admits a substrate nameevery client that trusts the rootThe role permits only names at least two labels below the site’s zone, and the API builds names only inside the caller’s zone. Step 4 asks OpenBao directly for names it must refuse, before the API is given the policy
The role copies something from a requestevery certificate it signsThe role ignores the request’s subject and names. Step 4 signs a request that asks for a CA, a client usage and another name, and reads what comes back
The policy change breaks the API’s existing accesstenant admission, restoreThe policy gains one path; the rest is unchanged. Step 5 reads a tenant after the change
The OpenBao role’s run disturbs the Tenant Device CAdevice certificates, when builtThe Tenant Device CA is in state 3 (installed) and its tasks do nothing there. Step 2 reads its certificate before and after
The ceremony signs the wrong requestthe site’s trustThe transfer tooling checks the subject, key size and manifest on each side, and the operator confirms the CA’s name and the hashes on paper
A tenant’s certificate expires unnoticedthat tenant’s serviceRecorded as a consequence of ADR-0037. Nothing in this change watches it
The API is restarted for the new versiontenants applying at that momentThe API is provisioning-only; nothing at runtime depends on it

Prerequisites #

  • Pull requests merged: deevnet-docs (ADR-0037, this record, the standard) and deevnet.mgmt
  • Pull request merged: the inventory. Not done before the change
  • The inventory’s vaults re-encrypted and its working branch closed. Not done before the change
  • Vault decrypted, collections built
  • The ceremony kit to hand: the offline Pi, the pi-pki media, the key media, the transfer media and the paper record
  • deevnet-provisioning-api and terraform-provider-deevnet merged with the certificate route and resource. Released during step 5, not before

Procedure #

Step 1: Read what is there #

Read-only.

Run:

curl -s --cacert pki/deevnet-root-ca.pem https://<openbao>:8200/v1/pki-tenant-device/ca/pem | openssl x509 -noout -subject -fingerprint -sha256
curl -s -o /dev/null -w '%{http_code}\n' --cacert pki/deevnet-root-ca.pem https://<openbao>:8200/v1/pki-tenant-service/ca/pem

Verify:

  1. The Tenant Device CA is the certificate in the inventory.
  2. pki-tenant-service does not exist.

Undo: nothing to undo.

Step 2: The mount, the key and the request #

Not disruptive. Creates the mount and, inside OpenBao, the Tenant Service CA’s key.

Run:

cd ansible-collection-deevnet.mgmt
ansible-playbook playbooks/tenant-service-ca.yml

Verify:

  1. pki/mobile/deevnet-mobile-tenant-service-ca.csr is written, with subject O=Deevnet, OU=Mobile Site, CN=Deevnet Mobile Tenant Service CA and an RSA 3072 key.
  2. The mount exists and has no CA.
  3. The Tenant Device CA reads as it did in step 1.

Undo: Undo Step 2

Step 3: The ceremony #

Offline, by the operator: Issuing CA, with --ca tenant-service.

Verify:

  1. deevnet-pki-transfer.sh accept passes and writes pki/mobile/deevnet-mobile-tenant-service-ca.pem.
  2. Its fingerprint is the one on paper.

Undo: none. A signed CA certificate cannot be withdrawn; an unused one is left uninstalled.

Step 4: Install the CA, and the signing role #

Not disruptive. Nothing can call the role yet.

Run:

ansible-playbook playbooks/tenant-service-ca.yml
ansible-playbook playbooks/tenant-service-ca.yml      # a second run changes nothing

Verify:

  1. GET pki-tenant-service/ca/pem is the certificate in the inventory, and chains to the root.
  2. With the automation’s own token, the role signs a request for probe.eds.mobile.deevnet.net. The leaf has the subject, names and usages of a tenant service certificate, a CA:FALSE it did not take from the request, and one year.
  3. The role refuses grafana.mobile.deevnet.net, mobile.deevnet.net, *.eds.mobile.deevnet.net, example.com and an IP address.
  4. A request that asks for CA:TRUE, clientAuth and a second name gets none of them.

Undo: Undo Step 4

Step 5: The API #

Restarts the API.

Run:

ansible-playbook playbooks/site.yml --tags openbao --limit openbao      # the API's policy
ansible-playbook playbooks/site.yml --tags deevnet-api --limit deevnet_api

Verify:

  1. GET /version is the new version.
  2. A tenant is read through the API as before.
  3. With a tenant’s token, a certificate is issued for a label; with another tenant’s token, the same path answers 404.

Undo: Undo Step 5

Step 6: The first tenant service #

The eds tenant’s own change, recorded in its own records. Its console moves to HTTPS.

Verify:

  1. openssl s_client -connect console.eds.mobile.deevnet.net:443 -CAfile deevnet-root-ca.pem -verify_return_error verifies with the root alone.
  2. The page opens in a browser on the trusted network with no warning.

Verification #

The Goal’s seven facts, each read after step 6.

Undo #

Undo Step 5 #

Set the API’s version back and run the deevnet-api play; remove the policy path and run the openbao play. Certificates already issued stay valid until they expire.

Undo Step 4 #

Delete the role: DELETE pki-tenant-service/roles/<role>. The CA stays installed and can sign nothing.

Undo Step 2 #

Disable the mount: DELETE sys/mounts/pki-tenant-service, which destroys the key. Remove the request from the inventory. Practical until step 3; after it, the Site CA has signed a certificate for a key that no longer exists, which is harmless and stays on the paper record.

Outcome #

WhenStepsWhat happened
2026-10-11, about 17:38 UTC1The Tenant Device CA read as the inventory’s certificate (66:05:74:BC…). pki-tenant-service did not exist.
17:392tenant-service-ca.yml enabled the mount, made the key and request inside OpenBao and wrote the request to the inventory: O=Deevnet, OU=Mobile Site, CN=Deevnet Mobile Tenant Service CA, RSA 3072. It also wrote the signing role and read it back. The Tenant Device CA was unchanged.
18:383deevnet-pki-transfer.sh prepare … --ca tenant-service passed. Manifest 201ec5cd…bba0c8, request public key ed734df3…668867.
about 18:403Signed on the offline Pi with the pi-pki image as flashed.
18:433accept passed and wrote the certificate to the inventory. Fingerprint 3D:D2:97:32:D5:A3:FB:82:93:8B:1D:D1:06:28:11:4F:40:4C:5B:D6:E1:53:00:5C:8A:7B:18:F5:64:DE:20:98. Valid to 2031-10-11.
18:444Installed with the Site CA and made the default issuer. A second run changed nothing. The mount’s CA is the inventory’s certificate, and the Tenant Device CA still reads as before.
18:454tenant-service-ca-verify.yml passed. From a request that named grafana.mobile.deevnet.net, example.com and an address, and asked to be a CA and a client, the role issued a certificate for the two names passed beside it and nothing else: subject O=Deevnet, OU=Mobile Tenant Services, CN=probe.chg0047.mobile.deevnet.net, CA:FALSE, server authentication only, 365 days though ten years were asked for, a chain of two, verifying with the root alone. It refused all eleven names it must: a substrate service, the API, the site’s zone, a tenant’s zone itself, a wildcard, another site’s zone, a public name, a substrate name beside a good one, an address, a URI, and no name.
18:465deevnet-provisioning-api v0.11.0 tagged and staged, at the second attempt (below).
18:485site.yml --limit deevnet_api --tags deevnet-api. GET /version answered v0.11.0. With the eds tenant’s token: its own tenant 200, its certificates 200 and empty, another tenant’s certificates 404.
18:495terraform-provider-deevnet v0.7.0 tagged, released and staged.
18:506The eds tenant applied deevnet_certificate.console: three resources added, and a second plan showed nothing to change. The leaf is for console.eds.mobile.deevnet.net alone, to 2027-10-11, serial 3d:0e:05:17:…:af:3d.
18:536https://console.eds.mobile.deevnet.net/ verified with the root alone, TLS 1.3, and with a trusted computer’s own trust store. The plain address redirects to it.

All seven of the Goal’s facts hold. One was read with curl and openssl, not by a person looking at a browser.

Departures from the plan #

  • The API’s signing permission was written at step 2, not step 5. tenant-service-ca.yml writes every policy before it enables the mount, the API’s among them. The risk table had the role tested “before the API is given the policy”. The API then deployed was v0.10.0, which has no code that signs, so nothing could use the permission until step 5, and the role was tested before that. The order the plan wanted held in effect, not in the letter.
  • Step 5’s first command was not run. The policy was already written, so there was nothing for an openbao play to add. OpenBao is also administered from playbooks/openbao.yml, not from site.yml with a limit, which the plan had wrong.
  • Step 4’s verification became a playbook. The plan described the checks; they were written as playbooks/tenant-service-ca-verify.yml during the change, and that is what ran.
  • The offline Pi’s clock was four hours behind. The certificate’s validity starts at 14:40:41 UTC on the day, about four hours before it was signed: local time, entered as UTC. It is valid now and expires four hours early in 2031. The signing tool refuses a date before its image was built, and has no way to notice one that is merely a few hours off.
  • The Builder’s /home was full. make stage for the API failed with no space left on device: 20 GB of 20 GB, mostly container build layers and Go’s build cache, with this change’s own builds on top. The Go build cache and dangling images were cleared, which freed about 3.7 GB, and the second attempt staged. The tag had already been pushed.
  • The API and provider were merged before they were tagged. Neither could be tested against a CA that had not been signed. Each was tagged only after step 4 had shown what the role really returns.
  • The pages that describe what exists were written after, not before. Prerequisite 1 named the documentation; ADR-0037, this record and the standard were merged first, and Trust and Identity, the runbooks and the tenant’s page with this record’s completion.
  • The inventory was not merged first. Its working tree held decrypted vaults on another branch when the change began, so the request and the certificate were written into it and left uncommitted. See Follow-ups.
  • The offline ceremony’s menu still read “Substrate or Tenant Device”. The image was not rebuilt for a label: nothing offline is specific to which issuing CA is signed.

Follow-ups #

  • Commit the Tenant Service CA’s request and certificate to the inventory, with site_tenant_service_ca_file, once its vaults are re-encrypted. Done 2026-10-11, about an hour after the change: inventory pull request 83, with the vaults re-encrypted.
  • How a tenant service certificate is revoked (ADR-0037 open question 1).
  • Nothing tells a tenant a certificate is close to expiring (ADR-0037 open question 2). The first one expires 2027-10-11.
  • Where OpenBao’s CA keys are kept now covers two CAs ( CHG-0036).
  • The Builder’s /home is 20 GB and holds every container build. Move the container store to /srv, or prune on a schedule. The API’s image build also copies bin/, 369 MB of old tarballs, into its build context.
  • The pi-pki image’s menu and banner name two issuing CAs. The collection’s script is corrected; the next image built for another reason carries it.
  • The signing role admits any name two or more labels below the site’s zone, so a.b.<tenant>.<site>.deevnet.net would be signed if asked. Only the API asks, and it sends one label. A role per tenant would close it (ADR-0037, alternatives).
  • The provider’s TestAccCertificate has not been run. It needs a throwaway tenant.
  • A check that the offline machine’s clock is within reason of the request’s own date.
Page last modified: October 11, 2026