Vault42 Admin Gateway is a standalone binary (cmd/admin-gateway/) for RBAC-protected administrative operations. It binds exclusively to loopback, requires mutual TLS (mTLS) client certificates, and enforces role-based permissions through 6 layers of defense-in-depth.
All admin operations -- key management, user management, audit log access, client management, and admin account management -- are served exclusively through the admin gateway. The main vault42 binary does not expose any admin endpoints.
Operator (SSH tunnel) ──► 127.0.0.1:9443 (mTLS) ──► Admin Gateway
│
├── RBAC + Session Auth
├── Audit Logging
└── PostgreSQL (vault_admin role)
The admin gateway is a separate Go binary with its own:
- Database role (
vault_admin) -- full CRUD on admin tables, read on user tables plus lock/unlock and erasure - TLS configuration -- mTLS with client certificate verification
- Session system -- 64-byte tokens, SHA256-hashed, stored in
auth.admin_sessions - RBAC model -- hardcoded in Go (not configurable via SQL, preventing injection-based escalation)
- Server-rendered HTML dashboard -- no client-side JavaScript frameworks
Any single layer failure cannot compromise the gateway:
| Layer | Mechanism | Effect |
|---|---|---|
| 1 | Kubernetes hostNetwork: true |
Binds to node loopback only |
| 2 | NetworkPolicy deny-all ingress | No pod-to-pod or external traffic |
| 3 | No Kubernetes Service created | Only SSH tunnel or direct node access |
| 4 | mTLS (RequireAndVerifyClientCert) |
Client certificate required |
| 5 | LocalOnly middleware |
Rejects non-loopback RemoteAddr |
| 6 | RejectProxyHeaders middleware |
Blocks X-Forwarded-*, Via, Forwarded headers |
Three roles with strict hierarchy (hardcoded in internal/rbac/):
| Role | Inherits | Additional Permissions |
|---|---|---|
viewer |
-- | List/read keys, audit, users, sessions, clients, config, metrics |
operator |
viewer |
Rotate/revoke keys, lock/unlock users, ban/unban users, force/withdraw a password reset, revoke sessions, create/revoke/rotate clients, write config |
super_admin |
operator |
Manage/create/revoke admin accounts |
31 permissions total. Permission checks are Go code -- not database queries -- so SQL injection cannot escalate privileges.
All configuration via environment variables:
| Variable | Type | Default | Required | Description |
|---|---|---|---|---|
ADMIN_GW_LISTEN_ADDR |
string | 127.0.0.1:9443 |
No | Bind address (must be loopback) |
ADMIN_GW_TLS_CERT_FILE |
string | -- | Yes | Server TLS certificate path |
ADMIN_GW_TLS_KEY_FILE |
string | -- | Yes | Server TLS private key path |
ADMIN_GW_CLIENT_CA_FILE |
string | -- | Yes | Client CA certificate for mTLS verification |
ADMIN_GW_CLIENT_CN_ALLOWLIST |
string | (empty) | No | Comma-separated identities allowed to complete the handshake, matched exactly against the leaf CN and its DNS, email and URI SANs. Empty pins nothing: any certificate this CA has signed is accepted, and startup logs a warning naming AR-9. |
ADMIN_GW_CLIENT_CRL_FILE |
string | (empty) | No | Path to a PEM or DER CRL signed by the client CA. Re-read on every handshake. An unreadable path is fatal at boot. Empty checks nothing. |
ADMIN_GW_SESSION_TTL |
duration | 1h |
No | Admin session lifetime |
ADMIN_GW_MAX_FAILED_LOGINS |
int | 5 |
No | Failed login attempts before lockout |
ADMIN_GW_LOCKOUT_DURATION |
duration | 30m |
No | Account lockout duration |
ADMIN_GW_AUTO_MIGRATE |
bool | false |
No | Run database migrations on startup |
ADMIN_GW_SHUTDOWN_TIMEOUT |
duration | 15s |
No | Graceful shutdown wait time |
ADMIN_GW_DEV_MODE |
bool | false |
No | Relaxes loopback enforcement for development behind ingress controllers. Disables LocalOnly and RejectProxyHeaders middleware. Also disables killswitch by default. |
ADMIN_GW_KILLSWITCH |
bool | true |
No | When enabled, a non-loopback request triggers a panic (pod crash) instead of a 403. The crash signals a security breach and triggers CrashLoopBackOff for immediate visibility. Defaults to true in production, false in dev mode. |
DB_HOST |
string | localhost |
No | PostgreSQL host |
DB_PORT |
string | 5432 |
No | PostgreSQL port |
DB_NAME |
string | vault |
No | Database name |
DB_SSLMODE |
string | require |
No | PostgreSQL SSL mode |
DB_MAX_CONNS |
int | 5 |
No | Max database connections |
DB_ADMIN_PASSWORD_FILE |
string | -- | Yes | Path to vault_admin DB password |
MASTER_KEY_FILE |
string | -- | Yes | Path to 32-byte AES-256 master key |
VAULT_FIRST_BOOT_CREDENTIAL_FILE |
string | -- | Conditional | Path the first super_admin password is appended to. Required when stdout is not a terminal (every Kubernetes pod). See First Boot. |
All endpoints are prefixed with /admin/.
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
POST |
/admin/auth/login |
None (rate-limited 10/min) | -- | Login with username + password + optional TOTP |
POST |
/admin/auth/logout |
Session | -- | Revoke current session |
GET |
/admin/status |
Session | -- | Current admin info + 2FA status |
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
POST |
/admin/admins/me/totp/setup |
Session | -- | Generate TOTP secret + QR URI |
POST |
/admin/admins/me/totp/verify |
Session | -- | Verify TOTP code and enable 2FA |
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
GET |
/admin/keys |
Session + RBAC | keys:list |
List signing key metadata |
POST |
/admin/keys/rotate |
Session + RBAC | keys:rotate |
Generate new signing key |
DELETE |
/admin/keys/{kid} |
Session + RBAC | keys:revoke |
Revoke a signing key |
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
GET |
/admin/users |
Session + RBAC | users:list |
List users (paginated) |
GET |
/admin/users/{id} |
Session + RBAC | users:read |
Get user details |
POST |
/admin/users/{id}/lock |
Session + RBAC | users:lock |
Lock user account |
POST |
/admin/users/{id}/unlock |
Session + RBAC | users:unlock |
Unlock user account |
PUT |
/admin/users/{id}/roles |
Session + RBAC | users:roles |
Replace a user's role set; refuses admin-tier and off-catalog names |
POST |
/admin/users/{id}/require-password-reset |
Session + RBAC | users:reset |
Force a password reset and revoke the account's live sessions |
POST |
/admin/users/{id}/clear-password-reset |
Session + RBAC | users:reset |
Withdraw a forced password reset |
POST |
/admin/users/{id}/ban |
Session + RBAC | users:ban |
Ban an account with a reason and revoke its live sessions |
POST |
/admin/users/{id}/unban |
Session + RBAC | users:ban |
Lift a ban |
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
GET |
/admin/sessions |
Session + RBAC | admins:manage |
List active admin sessions, not user ones |
POST |
/admin/sessions/revoke-all |
Session + RBAC | sessions:revoke |
Revoke all sessions |
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
GET |
/admin/audit |
Session + RBAC | audit:read |
Query audit logs (filters: user_id, event_type, since, until, min_risk_score). Admin-plane rows need admins:manage |
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
GET |
/admin/clients |
Session + RBAC | clients:list |
List service clients |
GET |
/admin/clients/{id} |
Session + RBAC | clients:read |
Get client details |
POST |
/admin/clients |
Session + RBAC | clients:create |
Create service client |
POST |
/admin/clients/{id}/revoke |
Session + RBAC | clients:revoke |
Revoke client |
POST |
/admin/clients/{id}/rotate-secret |
Session + RBAC | clients:rotate |
Rotate client secret |
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
GET |
/admin/config |
Session + RBAC | config:read |
Read configuration |
PUT |
/admin/config |
Session + RBAC | config:write |
Update configuration |
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
GET |
/admin/metrics |
Session + RBAC | metrics:read |
Get operational metrics |
| Method | Path | Auth | Permission | Description |
|---|---|---|---|---|
GET |
/admin/admins |
Session + RBAC | admins:manage |
List admin accounts |
POST |
/admin/admins |
Session + RBAC | admins:create |
Create admin account (20-char min password) |
POST |
/admin/admins/{id}/revoke |
Session + RBAC | admins:revoke |
Revoke admin (prevents self-revocation) |
| Path | Description |
|---|---|
/admin/login |
Login form |
/admin/ |
Dashboard |
/admin/ui/users |
User management |
/admin/ui/keys |
Signing key management |
/admin/ui/sessions |
Session browser |
/admin/ui/audit |
Audit log viewer |
/admin/ui/clients |
Service client management |
/admin/ui/admins |
Admin account management |
/admin/ui/config |
Configuration |
/admin/static/* |
CSS + JS assets |
Migration 001_initial_schema.sql creates (among other tables):
auth.admin_roles-- Reference table:viewer,operator,super_adminwith description and rankauth.admin_users-- UUID id, unique username, Argon2id password hash, role (FK), encrypted TOTP secret, TOTP verified flag, last_totp_counter (replay prevention), locked_until, failed_login_count, timestamps, created_by (self-referential FK)auth.admin_sessions-- Session id, admin_id (FK with CASCADE), SHA256 token hash, IP, user agent, timestamps, revoked flag
| Role | Scope |
|---|---|
vault_admin |
Full CRUD on admin tables, read on user tables plus lock/unlock and EXECUTE on the erasure tombstone, full on clients + config, read + append on audit |
vault_app |
SELECT only on admin tables (for verification), no INSERT/UPDATE/DELETE |
On first startup, if no admin accounts exist, the gateway automatically creates a super_admin account named admin with a random 64-character hex password. The password is delivered through VAULT_FIRST_BOOT_CREDENTIAL_FILE (or to a terminal), never to the process log. The log records only that a password was written and where:
FIRST BOOT: super_admin "admin" created; its password was written to /run/first-boot/credentials and is not in this log.
Without a credential file and without a terminal, first boot refuses rather than storing the hash of a password nobody holds. Change this password immediately after first login. TOTP enrolment is required after that login.
The admin gateway includes a killswitch mechanism that crashes the pod when a non-loopback request is detected. This is a defense-in-depth measure: if all other layers (bind address, NetworkPolicy, mTLS, etc.) fail and a remote attacker reaches the gateway, the hard crash:
- Stops the breach immediately -- no response is sent, the process terminates
- Creates visibility -- Kubernetes CrashLoopBackOff triggers alerting and is visible in
kubectl get pods - Leaves an audit trail -- a best-effort audit entry (
admin:killswitch_triggered, risk score 100) is written before the crash
The killswitch is enabled by default (ADMIN_GW_KILLSWITCH=true) and disabled automatically in dev mode. The Recovery middleware explicitly re-panics killswitch signals -- it cannot accidentally swallow them.
The admin gateway uses its own database role (vault_admin) with different privileges from the main API role (vault_app):
| Table | vault_app |
vault_admin |
|---|---|---|
auth.users |
SELECT, INSERT, DELETE + column-level UPDATE (excludes id, email, created_at, deleted, deleted_at, banned, ban_reason, disabled; email_verified and import_pending narrowed by trigger, below) |
SELECT, INSERT (import) + column-level UPDATE on locked_until and failed_login_count (001), must_reset_password (039), banned and ban_reason (043) only |
auth.clients |
SELECT, INSERT (narrowed by trigger, below) | SELECT, INSERT, UPDATE |
auth.admin_config |
SELECT, INSERT, UPDATE | SELECT, INSERT, UPDATE, DELETE |
auth.admin_users |
none (revoked in 002) | Full CRUD |
auth.admin_sessions |
none (revoked in 002) | Full CRUD |
auth.app_roles |
SELECT | SELECT, INSERT, DELETE |
auth.signing_keys |
SELECT, INSERT, DELETE + column-level UPDATE on status, retired_at, expires_at only (DELETE narrowed by trigger, below) |
SELECT, INSERT + the same column-level UPDATE |
audit.audit_log |
SELECT, INSERT | SELECT, INSERT |
vault_app's DELETE on auth.signing_keys is the one grant in this table that a
trigger, rather than the grant itself, makes safe. It exists so the retention
sweep can reap retired keys, and PostgreSQL has no row scope for a privilege, so
the bare grant would also cover the active key and every retired key still
verifying live tokens. Migration 020 pairs it with signing_keys_reap_scope, a
BEFORE DELETE trigger that refuses any row outside the sweep's predicate.
That trigger deliberately says nothing about a revoked row. Same-event triggers
fire in name order and signing_keys_reap_scope sorts ahead of
signing_keys_revocation_terminal, so excluding revoked rows from its WHEN
clause is what leaves migration 017 as the only guard that answers for a revoked
key. vault_admin holds no DELETE here, and 020 states the revoke explicitly so
the absence reads as a decision rather than an oversight.
The UPDATE on auth.signing_keys is column-level for the opposite reason: a
trigger cannot make the whole grant safe. kid, private_key, public_key,
algorithm and created_at are written by exactly one statement in the tree,
the upsert in keystore.Import, and migration 037 moved that upsert into
auth.import_signing_key, a SECURITY DEFINER function on 015's pattern, so the
raw privilege has no remaining caller and comes off both roles. What is left is
status, retired_at and expires_at, which Revoke and Import's retire
step genuinely write and which 017, 020, 026, 027 and 035 already judge. The
split is between the columns a guard can reason about and the ones it cannot: a
re-import and a substitution of key material differ only in whether the
ciphertext opens under the master key, which the database does not hold, so that
write is constrained by who may issue it rather than by what it contains.
kid also carries 037's signing_keys_kid_immutable trigger, because the
privilege answers only for these two roles while the trigger states the invariant
for every other one, including the owner.
vault_app's INSERT on auth.clients is the other grant a trigger rather than
the grant itself makes safe. The grant exists so declarative seeding can register
clients at startup, and scopes is a plain TEXT[], so it also authorized
writing a client row carrying mint:token and kms:unwrap with a chosen
secret_hash and then authenticating as it at POST /client/token -- the whole
authorization behind the two privileged endpoints, reachable by INSERT. Migration
023 pairs the grant with clients_capability_scope_guard, which refuses any row
carrying a scope in auth.capability_scopes() unless the writer holds
vault_admin. POST /admin/clients is therefore the only way to create a
privileged client: it is gated on clients:create, which belongs to
super_admin alone, and writes an admin:client_create audit row naming the
acting admin. A VAULT_SEED_FILE or a vault add-client that asks for a
capability scope now fails, loudly, naming the scope. Ordinary client seeding is
unchanged.
The account-state columns of auth.users are split the same way, by migration
024. disabled has no UPDATE writer anywhere in the tree -- it is set once at
INSERT by the import path -- so the grant 004 made to vault_app is revoked
outright rather than guarded. banned and ban_reason were in the same position
until 043 granted them to vault_admin, which is the admin plane's ban lever and
not a return of the privilege 024 took off vault_app. email_verified and
import_pending keep theirs, because email confirmation and import claiming are
vault_app's own work, and users_account_state_transitions narrows each to the
one direction its writer moves in: an address that is confirmed stays confirmed,
and an account that is claimed stays claimed. locked_until is deliberately not
narrowed; see AR-18 in security.md.
The erasure cascade behind DELETE /admin/users/{id} additionally gives vault_admin DELETE on the per-user tables plus column-level SELECT (user_id) on auth.social_accounts, auth.password_history, auth.totp_secrets, auth.webauthn_credentials and auth.backup_codes. PostgreSQL requires SELECT on every column read in a WHERE clause, so DELETE alone is not enough to run DELETE ... WHERE user_id = $1; the grant is column-level so the role still cannot read the encrypted TOTP secret, the WebAuthn public keys, the backup-code hashes or the password history it is allowed to destroy.
The erasure tombstone is not in that table because it is not a grant. Scrubbing a user row writes email, display_name and avatar_url, and a column grant for those is standing: it authorises UPDATE auth.users SET email = ... WHERE id = <anyone> just as much as it authorises the scrub, which is an account takeover because password reset follows the address. Migration 009 made that grant to both roles and migration 015 revoked it. The tombstone now runs inside auth.erase_user_identity(user_id, tombstone_email), a SECURITY DEFINER function owned by the migration role with EXECUTE revoked from PUBLIC and granted to vault_app and vault_admin. It refuses any address that is not deleted-<the id of the row being scrubbed>@<domain>.invalid, so the one write it can perform is one nobody can receive mail at.
Neither role may purge the audit log: EXECUTE on audit.cleanup_old_entries() is revoked from PUBLIC and granted to vault_app alone, which is where the retention sweeper runs.
This separation ensures that even if the main API is compromised (e.g., via SQL injection), the attacker cannot modify admin accounts, clients, or configuration. The admin gateway role is restricted from modifying user identity data (password, email, display name, avatar) -- it can lock/unlock an account and erase one, and nothing else on the user row.
Two database triggers back the Go RBAC model on auth.admin_users. auth.deny_role_escalation (BEFORE UPDATE) refuses to raise an existing admin's role, and auth.deny_role_escalation_on_insert (BEFORE INSERT, migration 016) refuses to create an admin that outranks the creator recorded in created_by, or to create one with no creator at all once the first admin exists.
The UPDATE half is a real ceiling: it compares against OLD.role, which comes from the row. The INSERT half is not, and this document used to claim otherwise. On an INSERT every value comes from the statement, and vault_admin can read auth.admin_users, so anything able to write that table can first look up a genuine super_admin id and put it in created_by. The trigger turns a one-statement backdoor into a two-statement one and enforces a useful invariant against RBAC regressions in Go; it is not a boundary against a caller that reaches the database. What actually closes SQL injection here is that every admin-plane query is parameterised. See AR-14 in security.md.
- Anti-enumeration: Login always runs Argon2id, even for non-existent usernames
- Session tokens: 64-byte random, SHA256-hashed before database storage
- TOTP secrets: Encrypted at rest with AES-256 (master key)
- TOTP replay prevention: Each accepted TOTP code's time-step counter is stored per admin. Replayed codes (same or earlier counter) are rejected within the ±1 period window
- Account lockout: Configurable failed attempts threshold and lockout duration. Lockout counter is atomic (SQL
RETURNINGclause) -- immune to race conditions under concurrent login attempts - Admin revocation: Deleting an admin CASCADE deletes all sessions -- no race window between session revoke and admin revoke.
created_byisON DELETE SET NULL(migration 042) so revoking an admin who opened other accounts succeeds and those accounts survive with their provenance moved into the audit row; before 042 it wasNO ACTION, and revoking any admin who had created another failed with a 500 that left the account and its live sessions in place - Audit trail: All admin mutations logged with admin ID, timestamp, IP, user agent
- No external dependencies: Uses stdlib HTTP only (no frameworks)
- RBAC hardcoded: Permission maps defined in Go code, not database -- immune to SQL injection escalation
- Security headers: CSP, HSTS (2 years), X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Permissions-Policy, Cache-Control: no-store (all responses including static assets)
- Request ID: Always server-generated (never trusts client
X-Request-ID) - Max body size: 64KB limit
- Helm TLS validation:
adminGateway.tls.secretNameis required when the gateway is enabled -- Helm template fails with a clear error if missing
Full rationale in Security Decisions & Accepted Risks (AR-6 through AR-9).
- Session timing oracle (AR-6): Invalid session tokens return slightly faster than valid ones
- Session token in sessionStorage (AR-7): Required for JS API calls; protected by CSP + 6-layer enforcement
- Global login rate limit (AR-8): Loopback-only means one IP; per-account lockout is the primary defense
- Client cert identity pinning is optional (AR-9):
ADMIN_GW_CLIENT_CN_ALLOWLISTandADMIN_GW_CLIENT_CRL_FILEexist and fail closed once set. Empty allowlist still accepts any certificate this CA has signed. An unreadable CRL path is fatal at boot. - innerHTML for empty states (M5): Hardcoded strings only, no interpolated variables
scripts/generate-admin-certs.shGenerates CA, server, and client certificates in secrets/admin-gateway/:
ca.crt-- Certificate Authorityserver.key,server.crt-- Server certificate (SANs: localhost, 127.0.0.1, ::1)client.key,client.crt-- Client certificate (CN: admin-operator)
The admin gateway is deployed via Vault42 Helm chart (charts/vault/templates/admin-gateway.yaml):
helm upgrade --install vault42 charts/vault/ \
-f charts/vault/values.yamlAccess via SSH tunnel to the node:
ssh -L 9443:127.0.0.1:9443 <node>
curl --cert client.crt --key client.key --cacert ca.crt \
https://localhost:9443/admin/statusdocker build -t vault42-admin-gateway:dev -f Dockerfile.admin-gateway .
docker run --rm \
-v ./secrets/admin-gateway:/certs:ro \
-v ./secrets:/secrets:ro \
-e ADMIN_GW_TLS_CERT_FILE=/certs/server.crt \
-e ADMIN_GW_TLS_KEY_FILE=/certs/server.key \
-e ADMIN_GW_CLIENT_CA_FILE=/certs/ca.crt \
-e ADMIN_GW_CLIENT_CN_ALLOWLIST=admin-operator \
-e MASTER_KEY_FILE=/secrets/master.key \
-e DB_ADMIN_PASSWORD_FILE=/secrets/db-admin-password \
-e DB_HOST=host.docker.internal \
vault42-admin-gateway:devMulti-arch images (amd64 + arm64) published to GHCR on release:
ghcr.io/42-v/vault42-admin-gateway:<version>
ghcr.io/42-v/vault42-admin-gateway:latest
The main vault binary still provides CLI admin commands (add-client, list-clients, revoke-all-sessions, rotate-admin-token, rotate-jwks, seed, cleanup-recovery, export-audit) authenticated by ADMIN_TOKEN_FILE or --admin-token. These require pod exec access (shell access to the running container), which provides equivalent security to the admin gateway's SSH tunnel. Live key rotate/list/revoke is the admin gateway (POST /admin/keys/...). vault rotate-jwks writes a PKCS#1 PEM and a discarded UUID; it does not rotate the live store and cannot be mounted as SIGNING_KEY_FILE (LoadSigningKeyPEM is PKCS#8 only). File-based keys are produced by scripts/generate-secrets.sh. cleanup-audit, revoke-client, rotate-client-secret, lock-user and unlock-user are retired stubs that print an error and write nothing.
Declarative seeding is also available at startup via the VAULT_SEED_FILE env var, which loads a JSON file and idempotently creates clients and users before the server starts. See seed.example.json for the file format. A seeded client may not carry a vault42 capability scope (mint:token, kms:unwrap, svcdoc:read, svcdoc:write, admin, admin:read, admin:write): the seeder runs under vault_app and migration 023 reserves those for POST /admin/clients, so a seed file asking for one aborts startup naming the scope.