Tenant Admission

Tenant Admission #

The operator’s half of creating a tenant. The operator admits a name; the tenant then declares everything else itself, through the Deevnet API ( ADR-0015). The tenant’s half is Tenant Operations.

Admission is the only substrate act a new tenant needs. The operator never edits an inventory file to make a tenant, and the tenant never holds a Proxmox credential, a vault password or an index.


Before you start #

The APIhttps://api.mobile.deevnet.net, reachable from the Builder
A work sessionOpenBao open: ANSIBLE_ASK_VAULT_PASS=true make bao-open from ansible-collection-deevnet.mgmt ( A work session). Admission, a Wi-Fi key rotation and every operator-only call below need it. While it is sealed the API answers 503, OpenBao is sealed or unreachable; tenant work waits until it is opened, and changes nothing
The operator tokennothing to fetch: make admit reads it from the running API container over SSH, and never prints it. It is also vault_deevnet_api_token, in the inventory’s deevnet_api group vault, for calling the API by hand
The Deevnet Root CAansible-inventory-deevnet/pki/deevnet-root-ca.pem: the one trust anchor for every Deevnet site, the same for every tenant ( ADR-0031). Tenants download it under the same name
The providerthe tenant installs deevnet/deevnet 0.5.x itself with install-provider.sh (on Windows, install-provider.ps1) from the tenant downloads ( Before You Start). No role installs it. Before tenants will be downloading from the site, check the downloads tree is current: deevnet.builder site.yml --tags fetched-artifacts, the provider repo’s make stage, the image factory’s make pi-backend-publish, then deevnet.mgmt site.yml --tags tenant-downloads

1. Admit the name #

From ansible-collection-deevnet.mgmt on the Builder:

make admit NAME=mabell                          # or bind the Wi-Fi key to one laptop:
make admit NAME=mabell MAC=AA-BB-CC-00-11-22
admitted mabell: expires 2026-09-26T…Z, Wi-Fi DVNTM-TD
handover details: /home/<you>/mabell-admission.txt (mode 0600)

The terminal shows nothing secret. ~/<name>-admission.txt holds everything the tenant is handed: the enrollment token and its expiry, the API endpoint, the DVNTM-TD Wi-Fi key, the Deevnet Root CA’s URL and fingerprint, and the tenant’s first three steps. It is written to be read and passed on by a person.

The Wi-Fi key is the tenant’s own DVNTM-TD key ( ADR-0029): a tenant needs that network to reach the API at all, so its first key comes with the admission. MAC= binds it to one laptop. When the tenant creates itself, the key becomes its own Wi-Fi key admission.

The name^[a-z][a-z0-9]{0,7}$ — it becomes a PVE SDN zone ID, a DNS label and a state-store user
A reserved namerefused with 400: home, mobile, mgmt, dvnt, dvntm, device, mqtt and artifacts. A tenant’s name is the second label of its site-less names, <service>.<tenant>.deevnet.net, so a tenant named for a site or a global alias would hold that site’s or alias’s names ( ADR-0040 §4). The list grows with the sites and aliases
The tokensingle-use, expires after DEEVNET_ENROLLMENT_TTL (72h by default)
A name already registeredanswers 409. Admission creates nothing but that key; it only authorizes
Admitting the name againissues a new key; the one handed over before stops working. make admit refuses while ~/<name>-admission.txt exists; FORCE=1 admits anyway
An admission never usedmake unadmit NAME=<name> revokes its key (DELETE /v1/admissions/<name>) and deletes the handover file. The token itself just expires

2. Hand over four things #

The tenant needs exactly these, and nothing else from the substrate. All four are in ~/<name>-admission.txt:

  1. the enrollment token
  2. the API endpoint, https://api.mobile.deevnet.net
  3. the DVNTM-TD Wi-Fi key ( §3)
  4. the Deevnet Root CA, at https://downloads.mobile.deevnet.net/deevnet-root-ca.pem, with its fingerprint

The token is bound to the name. Presenting it for a different name spends it and answers 401, so a mistyped name costs a new admission.

Delivery is by hand today. ADR-0012 §9 says the token is delivered age-encrypted to the tenant’s own key; that tooling is not built. Until it is, hand the token over on a channel you would trust with a password, and admit close to when the tenant will apply — an unspent token is a credential for 72 hours. Delete ~/<name>-admission.txt once it is handed over: after the first apply the Wi-Fi key in it is the tenant’s to keep, not yours.

3. Where the tenant applies from #

A tenant’s applies need OpenBao open too, so they run inside a work session. While it is sealed, every call with the tenant’s token answers 503, OpenBao is sealed or unreachable; tenant work waits until it is opened, and nothing changes; the tenant applies again once the session is open.

Every apply has to reach the API, and after the first one the state store too. Give the tenant the DVNTM-TD key, the one in the admission’s wifi, not a shared one: that segment reaches the API, the state store, the broker, the log store, Grafana and SSH on tenant workloads, and nothing else ( CHG-0022, CHG-0024). Guest is internet-only and IoT cannot reach the API. A trusted seat still works, but it reaches the management plane, so don’t offer it to a visitor. See Before You Start for how the tenant side reads this.

You have no standing access to a tenant’s workloads ( ADR-0028). They clone the fedora-tenant-* template, which carries no a_autoprov, and trust only the keys in the tenant’s ssh_keys. To help on one, the tenant adds your public key and replaces the workload.


Rotating a tenant’s Wi-Fi key #

A tenant’s DVNTM-TD password is handed over once: the API never returns a key’s password again, so a lost one is replaced, not recovered. Rotate the key to give it a new password — after a loss, a possible leak, or on a schedule:

make rotate-wifi-key NAME=<tenant>                 # the admission key
make rotate-wifi-key NAME=<tenant> KEY=<key>       # any other key

It shows the key’s trust class, SSID and MAC binding, asks for the tenant’s name, then deletes the key and creates it again under the same name and settings. The new password goes to ~/<tenant>-wifi-<key>.txt (mode 0600), to hand over like an admission. The old password stops working on every device that used it; each one forgets the network and joins again.

Only the admission key is the operator’s to rotate. It came with the admission and the tenant’s Terraform does not declare it. A key the tenant declares, such as its devices’ key, lives in the tenant’s state, and rotating it here would leave that state holding the old password: the tenant rotates its own with terraform apply -replace=<the key's resource>.


Reissuing a state secret #

A tenant that has lost the keys to its own state gets a new state secret from the operator. The keys live inside the state they unlock, and the site never hands back the stored secret, so a new one is the only way back in. OpenBao must be open, in a work session:

make reissue-state-secret NAME=<tenant>

It asks for the tenant’s name, then has the API mint a new secret and set it on the state store. The old secret stops working for everyone who holds it, CI included. The new one goes to ~/<tenant>-state-secret.txt (mode 0600), as the two lines of the tenant’s .backend.env, to hand over like an admission.

The tenant must not restore from its old state afterwards. That state still holds the old secret, and a restore sends back the secrets in state, which win. A restore from it would put the old secret back and lock the tenant out again.

What the substrate does on the first apply #

Networkan EVPN zone and VRF on the tenant hypervisor, one VNet, a /24 from 10.20.128.0/18, anycast .1, SNAT at the exit node
DNS<tenant>.<site>.deevnet.net and its reverse, delegated, with a TSIG key the tenant writes records with. The Mobile Factory serves no site-less zone: a tenant’s <tenant>.deevnet.net names resolve only at a production site ( ADR-0040 §1)
Statea bucket prefix and keys in the state store
Logspartitions (index, 0..2), an ingest and a read token, and the vmauth routes that separate them from every other tenant ( ADR-0027)
Dashboardsa Grafana organization named for the tenant, one Editor login, and three log data sources carrying its read token, with fixed UIDs ( ADR-0024). A delete renames the emptied organization deleted-<tenant>-<id>, because Grafana 13 cannot delete one
Devices, if declareda PPSK per trust class, registry entries, and MQTT accounts confined to the tenant’s own topic prefix

The index is the tenant’s number in all of it — subnet, VXLAN VNI, log account — so it is allocated once, against both the registry and the live fabric, and never by hand.

To confirm the fabric side, pvesh get /cluster/sdn/zones on the tenant hypervisor shows vrf_<tenant> with its own VNI.


Operator-only calls #

CallUse
GET /v1/tenantsevery tenant, by index, without secrets. The list is lean — ask GET /v1/tenants/{name} before concluding something is missing
DELETE /v1/tenants/{name}take a tenant out of service, after its workloads (the tenant can call it too, with its own token). Use make remove-tenant, which deletes the workloads first: Tenant Removal
POST /v1/tenants/{name}/reconcilere-ensure every backend with the secrets the registry holds. The repair after a backend is rebuilt, and how a tenant created before a service existed is handed that service’s credentials

When it goes wrong #

SymptomWhat it means
409 on admissionthe name is already registered. The tenant uses its own token, not a new admission
401 on the tenant’s first applythe enrollment token was issued for a different name, and is now spent. Admit again
501 on every tenant routethe API has no site configured, or no OpenBao
A tenant lists with no log storethe list endpoint returns lean records. Ask the detail endpoint
Page last modified: November 12, 2026