Enterprise Drivers

Vault Driver

Database passwords, API keys and other credentials from HashiCorp Vault or OpenBao in your Kipchak API's config, with KV, Transit and any other secrets engine at runtime.

Introduction

Note: This driver is only available with Kipchak Enterprise.

A HashiCorp Vault and OpenBao driver for Kipchak. Database passwords, API keys and other credentials stay in Vault instead of environment variables, where they are access-controlled, audited and changed in one place, and your API reads them when it starts.

  • Secrets in config: write vault('secret/orders-api/database', 'password') in any config file. The driver reads each secret once when the worker starts and puts the value in its place, so the Doctrine, S3, Kafka or any other driver reads a plain value and needs no change.
  • Auth for every platform: token (or a Vault Agent token file), AppRole, Kubernetes, JWT/OIDC and TLS certificates. The driver logs in again, or renews its token, before the token expires, and logs in again if the token is revoked.
  • KV, versions 1 and 2, detected per mount: get(), earlier versions, put() with check-and-set, patch(), delete() and list().
  • Transit: encrypt(), decrypt() and rewrap(), with keys that never leave Vault.
  • Any other engine: read(), write(), list() and delete() on any path, with the lease details.
  • Built for FrankenPHP's long-lived workers: one login per worker, connections to Vault kept open between requests, and short timeouts with retries for a sealed or standby server.

Built on the Kipchak HTTP driver. Vault and OpenBao share one API; the driver is tested against OpenBao 2.4 and HashiCorp Vault 1.20.

Installation

composer require kipchak/driver-vault

Copy the sample config to config/kipchak.vault.php, and initialise the driver in drivers/drivers.php straight after Config and Logger, before any driver whose config uses vault():

use Kipchak\Driver\Vault\Vault;

Config::initialise($container);
Logger::initialise($container);
Vault::initialise($container);
Doctrine::initialise($container);

Each connection in the config becomes a client in the container under drivers.secrets.vault.<name>. Vault::get() returns the default connection, and Vault::get('dr') another.

Configuration

<?php

use function Kipchak\Core\env;

return [
    // false skips Vault: every vault() reference takes its default, for local development without Vault.
    'enabled' => env('VAULT_ENABLED', 'true') === 'true',

    // Replace the vault() references in every config file when the driver is initialised.
    'resolve_config' => true,

    'connections' => [
        'default' => [
            'url' => env('VAULT_ADDR', 'https://vault:8200'),
            'namespace' => env('VAULT_NAMESPACE', ''),

            'auth' => [
                'method' => 'approle',
                'role_id' => env('VAULT_ROLE_ID', ''),
                'secret_id_file' => '/run/secrets/vault-secret-id',
            ],

            'tls' => [
                'ca' => '/etc/ssl/vault/ca.pem',
                // 'cert' => '/etc/ssl/vault/client.pem',
                // 'key' => '/etc/ssl/vault/client-key.pem',
            ],

            'timeout' => 5,               // seconds per request
            'connect_timeout' => 2,
            'retries' => 2,               // for a failed connection, or a sealed, standby or overloaded server
            'retry_delay_ms' => 200,

            // 'kv_versions' => ['secret' => 2],
            'cache_ttl' => 0,             // seconds to keep runtime KV reads in the worker's memory
        ],
    ],
];

The settings are checked when the worker starts, and a mistake names the connection and the setting. tls.ca verifies Vault's certificate; without it, the system's trusted CAs are used. cert and key send a client certificate, for cert auth or a listener that requires one.

The driver finds the KV version of each mount, and the mount a path is under, through sys/internal/ui/mounts, which Vault's default policy lets every token read. For a token without the default policy, list the KV mounts in kv_versions.

Secrets in config

use function Kipchak\Driver\Vault\vault;

return [
    'dbal' => [
        'connections' => [
            'primary' => [
                'dbname' => 'orders',
                'user' => vault('secret/orders-api/database', 'username'),
                'password' => vault('secret/orders-api/database', 'password'),
                'host' => 'postgres',
                'driver' => 'pdo_pgsql',
            ],
        ],
    ],
];

vault($path, $key) takes the secret's full path, mount included: secret/orders-api/database is the secret orders-api/database in the KV mount secret, whichever KV version it is. When the driver is initialised, it replaces every reference in every config file with the value, reading each secret once however many keys are used, and drivers initialised after it see plain values. The value keeps its type: a number stays a number, and a JSON object becomes an array, so a whole map of API keys can live in one secret:

'authorised_keys' => vault('secret/orders-api/api-keys', 'keys'),

A missing secret or key, a refusal, or a Vault that cannot be reached stops the worker from starting, with an exception that names the path. FrankenPHP then does not start, and your container platform restarts the container: an API never runs with a credential missing.

vault() takes two more arguments:

vault('secret/db', 'password', connection: 'dr');        // another connection
vault('secret/db', 'password', default: 'local');        // used only when 'enabled' is false

The default is never used when Vault is enabled and fails. A reference used before the driver has replaced it, because a driver was initialised before Vault, throws an exception that says so.

The values are read once per worker. After you change a secret in Vault, restart the workers (or roll out the deployment) for the config to pick it up. Values read at runtime, below, are current at every read.

Authentication

MethodSettingsNotes
tokentoken or token_fileA token file is read again when the token expires, so a Vault Agent sink works. A renewable token is renewed.
approlerole_id or role_id_file, secret_id or secret_id_file, mountEach worker logs in on its own: give the secret ID enough uses (secret_id_num_uses 0, or more than workers times restarts).
kubernetesrole, jwt_file, mountjwt_file defaults to the pod's service account token, read at every login so a projected token's rotation is picked up.
jwtrole, jwt or jwt_file, mountJWT/OIDC auth, for a token issued by your CI system or identity provider.
certrole, mount, and tls.cert and tls.keyTLS certificate auth.

mount defaults to the method's name (approle, kubernetes, jwt, cert).

The driver logs in on first use and keeps the token for the life of the worker. With a fifth of the token's life left, or a minute for long-lived tokens, it logs in again, or renews a token it cannot replace. If Vault refuses a token that should still be valid, because it was revoked, the driver checks the token, logs in again and repeats the request once. A request the token's policies do not allow is not retried.

Usage

use Kipchak\Driver\Vault\Vault;

$vault = Vault::get();

KV

$kv = $vault->kv('secret');                              // the KV mount; its version is detected

$secret = $kv->get('orders-api/partner');                // null when missing or deleted
$secret->get('api_key');                                 // SecretNotFoundException when there is no such key
$secret->version;
$secret->createdTime;

$kv->get('orders-api/partner', version: 3);              // an earlier version (KV 2)
$version = $kv->put('orders-api/partner', ['api_key' => $new], cas: $secret->version);   // fails if it changed since
$kv->patch('orders-api/partner', ['rotated_by' => 'ops']);   // change some keys, keep the rest (KV 2)
$kv->delete('orders-api/partner');                       // the latest version; earlier ones remain (KV 2)
$kv->list('orders-api');                                 // ['partner', 'jobs/']

Paths are relative to the mount. $vault->secret('secret/orders-api/partner') and $vault->value('secret/orders-api/partner', 'api_key') take the full path instead, as vault() does.

cache_ttl keeps reads in the worker's memory for that many seconds, which suits secrets read on every request. A write through the same client clears the cached copy.

Transit

$transit = $vault->transit();                            // or transit('other-mount')

$ciphertext = $transit->encrypt('pii', 'jane.doe@example.com');   // vault:v1:...
$email = $transit->decrypt('pii', $ciphertext);
$ciphertext = $transit->rewrap('pii', $ciphertext);      // re-encrypt with the key's latest version

For keys created with derived=true, pass the same context to encrypt() and decrypt(). Ciphertexts name the key version, so they still decrypt after the key is rotated.

Other engines

$response = $vault->read('database/creds/reporting');    // null when Vault answers 404
$response->data['username'];
$response->leaseId;
$response->leaseDuration;

$vault->write('pki/issue/orders-api', ['common_name' => 'orders.internal']);
$vault->list('ssh/roles');
$vault->delete('secret/metadata/old');
$vault->call('POST', 'sys/leases/lookup', ['lease_id' => $response->leaseId]);   // any endpoint, with the client's token

Errors

  • VaultConnectionException: Vault could not be reached, after the retries. Answer 503.
  • VaultUnavailableException: Vault answered 503, because it is sealed or starting.
  • PermissionDeniedException: the token's policies do not allow the request. Vault also answers 403 for a secret that does not exist under a path the token may not read.
  • SecretNotFoundException: no secret at the path, or no such key in it.
  • VaultException: the base class of all of these, with Vault's status and errors.

Paths are checked before they are sent: an empty segment, . or .. raises InvalidArgumentException, so a path built from user input cannot reach another endpoint. Tokens and secret values are kept out of exception messages, logs and var_dump().

Health

$vault->health();      // ['initialized' => true, 'sealed' => false, 'version' => '2.4.1', ...]
$vault->tokenInfo();   // the token's policies, TTL and metadata

Limits

  • Dynamic secrets are read but not renewed. A database credential from the database secrets engine expires with its lease, so the driver logs a warning when one is read into config. Read dynamic credentials at runtime, or use a static role. Lease renewal and rotation are planned for a later release.
  • A reference replaces a whole value. To build a DSN from a secret, read the parts separately.
  • The Vault config cannot use vault() itself.

Performance

At boot, each worker logs in once and reads each secret once. At runtime, a KV read or a Transit call is one request over a connection that stays open.

In testing on the Kipchak Enterprise runtime in worker mode, an API whose database password and API keys were held only in OpenBao served 33,900 requests over 60 seconds, each reading KV, encrypting with Transit or querying Postgres, with an AppRole token that expired every five seconds. Every request succeeded and memory stayed steady. With OpenBao paused, runtime reads answered 503 after the configured timeout while database queries carried on, and the API recovered as soon as OpenBao returned.

Source

The source code for this driver is hosted internally.

Previous
LDAP