diff --git a/docs/index.rst b/docs/index.rst index c206d01..f36c880 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -109,7 +109,8 @@ This array contains an element named ``keys``, whose value is an array of the se [ "keys": [ - "hello" + "hello", + "world" ] ] Fetching a secret @@ -149,6 +150,78 @@ Fetching a secret $data = $response->getData(); // Raw array with secret's content. + // ... +Secrets Engines overlays +================== +Each secret engine (such as Key/Value [versions 1/2], Cubbyhole, Transit, etc.) comes with a different APIs. +Overlays provide a way to use secret engine without worrying about underlaying path structure, in a more object-oriented way. + +To use one, simply create new instance while specifying authenticated Vault client and path at which it is located: + +.. code-block:: php + + setAuthenticationStrategy(new TokenAuthenticationStrategy('463763ae-0c3b-ff77-e137-af668941465c')) + ->authenticate(); + + if (!$authenticated) { + // Throw an exception or handle authentication failure. + } + + // Create an instance of KV2 secret engine overlay, mounted at path "secret" using authenticated client + $kv2 = new KeyValueVersion2SecretsEngine($client, 'secret'); + +List secret keys +---------------- + +.. code-block:: php + + // Request exception could appear here. + /** @var \Vault\ResponseModels\KeyValueVersion2\ListResponse $response */ + $response = $kv2->list(''); // Corresponds to calling $client->keys('/secret/metadata/') + $keys = $response->getKeys(); // Raw array of secret's keys + +Notice, that secret engine overlay knows structure of Vault response, so it can parse and objectify it. +It means you don't have to look for "keys" index in raw data array as before. On success, $keys would be equal to: + +.. code-block:: php + + [ + "hello", + "world" + ] + +Fetching a secret +---------------- + +.. code-block:: php + + // Request exception could appear here. + /** @var \Vault\ResponseModels\KeyValueVersion2\ReadResponse $response */ + $response = $kv2->read('database'); // Corresponds to calling $client->read('secret/data/database'); + + $data = $response->getData(); // Raw array with secret's content. + $metadata = $response->getMetadata(); // Object containing KV2 secret version metadata + // ... Indices and tables diff --git a/src/BaseClient.php b/src/BaseClient.php index 7b28243..ef2f796 100644 --- a/src/BaseClient.php +++ b/src/BaseClient.php @@ -25,9 +25,9 @@ */ abstract class BaseClient implements LoggerAwareInterface { - public const VERSION_1 = 'v1'; - use LoggerAwareTrait; + + public const VERSION_1 = 'v1'; /** * @var string @@ -40,7 +40,7 @@ abstract class BaseClient implements LoggerAwareInterface protected $token; /** - * @var Namespace + * @var string */ protected $namespace; @@ -116,9 +116,10 @@ public function head(string $path): Response */ public function send(string $method, string $path, string $body = ''): ResponseInterface { + $method = strtoupper($method); $headers = [ 'User-Agent' => 'VaultPHP/1.0.0', - 'Content-Type' => 'application/json', + 'Content-Type' => ($method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'), ]; if ($this->token) { @@ -134,7 +135,7 @@ public function send(string $method, string $path, string $body = ''): ResponseI $this->baseUri = $this->baseUri->withQuery($query); } - $request = $this->requestFactory->createRequest(strtoupper($method), $this->baseUri->withPath($path)); + $request = $this->requestFactory->createRequest($method, $this->baseUri->withPath($path)); foreach ($headers as $name => $value) { $request = $request->withHeader($name, $value); @@ -309,7 +310,7 @@ public function setToken(Token $token) } /** - * @return Namespace + * @return string */ public function getNamespace(): string { @@ -317,7 +318,7 @@ public function getNamespace(): string } /** - * @param String $namespace + * @param string $namespace * * @return $this */ diff --git a/src/BaseObject.php b/src/BaseObject.php index 9ac5170..f1191a9 100644 --- a/src/BaseObject.php +++ b/src/BaseObject.php @@ -2,15 +2,16 @@ namespace Vault; -use RuntimeException; +use Vault\Exceptions\RuntimeException; use Vault\Helpers\ArrayHelper; +use Vault\Helpers\ModelHelper; /** - * Class Object + * Class BaseObject * * @package Vault */ -class BaseObject +class BaseObject implements \JsonSerializable { /** * Object constructor. @@ -193,4 +194,14 @@ public function getFields(): array return $result; } + + /** + * @return array + * + * @throws RuntimeException + */ + public function jsonSerialize(): array + { + return ModelHelper::snakelize($this->toArray(), false); + } } diff --git a/src/Builders/KeyValueVersion1/ListResponseBuilder.php b/src/Builders/KeyValueVersion1/ListResponseBuilder.php new file mode 100644 index 0000000..1556014 --- /dev/null +++ b/src/Builders/KeyValueVersion1/ListResponseBuilder.php @@ -0,0 +1,27 @@ +getData(); + + return new ListResponse(ModelHelper::camelize($data, false)); + } +} diff --git a/src/Builders/KeyValueVersion2/ListResponseBuilder.php b/src/Builders/KeyValueVersion2/ListResponseBuilder.php new file mode 100644 index 0000000..c49912c --- /dev/null +++ b/src/Builders/KeyValueVersion2/ListResponseBuilder.php @@ -0,0 +1,27 @@ +getData(); + + return new ListResponse(ModelHelper::camelize($data, false)); + } +} diff --git a/src/Builders/KeyValueVersion2/ReadConfigurationResponseBuilder.php b/src/Builders/KeyValueVersion2/ReadConfigurationResponseBuilder.php new file mode 100644 index 0000000..93e5dcf --- /dev/null +++ b/src/Builders/KeyValueVersion2/ReadConfigurationResponseBuilder.php @@ -0,0 +1,27 @@ +getData(); + + return new Configuration(ModelHelper::camelize($data, false)); + } +} diff --git a/src/Builders/KeyValueVersion2/ReadMetadataResponseBuilder.php b/src/Builders/KeyValueVersion2/ReadMetadataResponseBuilder.php new file mode 100644 index 0000000..df699c3 --- /dev/null +++ b/src/Builders/KeyValueVersion2/ReadMetadataResponseBuilder.php @@ -0,0 +1,36 @@ +getData(); + + $versions = []; + foreach ($data['versions'] as $versionNumber => $versionData) { + $versionData['custom_metadata'] = $data['custom_metadata'] ?? null; + $versionData['version'] = $versionNumber; + $versions[$versionNumber] = new VersionMetadata(ModelHelper::camelize($versionData, false)); + } + $data['versions'] = $versions; + + return new ReadMetadataResponse(ModelHelper::camelize($data, false)); + } +} diff --git a/src/Builders/KeyValueVersion2/ReadResponseBuilder.php b/src/Builders/KeyValueVersion2/ReadResponseBuilder.php new file mode 100644 index 0000000..95e6677 --- /dev/null +++ b/src/Builders/KeyValueVersion2/ReadResponseBuilder.php @@ -0,0 +1,30 @@ +getData(); + + $data['metadata'] = new VersionMetadata(ModelHelper::camelize($data['metadata'], false)); + + return new ReadResponse($data); + } +} diff --git a/src/Builders/KeyValueVersion2/ReadSubkeysResponseBuilder.php b/src/Builders/KeyValueVersion2/ReadSubkeysResponseBuilder.php new file mode 100644 index 0000000..f9176f5 --- /dev/null +++ b/src/Builders/KeyValueVersion2/ReadSubkeysResponseBuilder.php @@ -0,0 +1,30 @@ +getData(); + + $data['metadata'] = new VersionMetadata(ModelHelper::camelize($data['metadata'], false)); + + return new ReadSubkeysResponse($data); + } +} diff --git a/src/Builders/KeyValueVersion2/VersionMetadataResponseBuilder.php b/src/Builders/KeyValueVersion2/VersionMetadataResponseBuilder.php new file mode 100644 index 0000000..dd38b34 --- /dev/null +++ b/src/Builders/KeyValueVersion2/VersionMetadataResponseBuilder.php @@ -0,0 +1,27 @@ +getData(); + + return new VersionMetadata(ModelHelper::camelize($data, false)); + } +} diff --git a/src/Helpers/ModelHelper.php b/src/Helpers/ModelHelper.php index fe0d7aa..d0ac12f 100644 --- a/src/Helpers/ModelHelper.php +++ b/src/Helpers/ModelHelper.php @@ -2,8 +2,10 @@ namespace Vault\Helpers; +use Vault\Exceptions\RuntimeException; + /** - * Class Model + * Class ModelHelper * * @package Vault\Helper */ @@ -36,4 +38,41 @@ private static function camelizeString(string $data): string return lcfirst($camelizedString); } + + /** + * @param array $data + * @param bool $recursive + * + * @return array + * + * @throws RuntimeException + */ + public static function snakelize(array $data, $recursive = true): array + { + $return = []; + + foreach ($data as $key => $value) { + if (is_array($value) && $recursive) { + $value = self::snakelize($value, $recursive); + } + + $return[self::snakelizeString($key)] = $value; + } + + return $return; + } + + private static function snakelizeString(string $data): string + { + $snakelizedString = preg_replace('~(?<=\\w)([A-Z])~u', '_$1', $data); + + if ($snakelizedString === null) { + throw new RuntimeException(sprintf( + 'preg_replace returned null when trying to snakelize value "%s"', + $data + )); + } + + return mb_strtolower($snakelizedString); + } } diff --git a/src/Models/KeyValueVersion2/Configuration.php b/src/Models/KeyValueVersion2/Configuration.php new file mode 100644 index 0000000..c2ec7be --- /dev/null +++ b/src/Models/KeyValueVersion2/Configuration.php @@ -0,0 +1,106 @@ +casRequired; + } + + /** + * Set {@see $casRequired cas_required} + * + * @param bool $casRequired + * + * @return $this + */ + public function setCasRequired(bool $casRequired): self + { + $this->casRequired = $casRequired; + + return $this; + } + + /** + * Get {@see $deleteVersionAfter delete_version_after} + * + * @return string + */ + public function getDeleteVersionAfter(): string + { + return $this->deleteVersionAfter; + } + + /** + * Set {@see $deleteVersionAfter delete_version_after} + * + * @param string $deleteVersionAfter + * + * @return $this + */ + public function setDeleteVersionAfter(string $deleteVersionAfter): self + { + $this->deleteVersionAfter = $deleteVersionAfter; + + return $this; + } + + /** + * Get {@see $maxVersions max_versions} + * + * @return int + */ + public function getMaxVersions(): int + { + return $this->maxVersions; + } + + /** + * Set {@see $maxVersions max_versions} + * + * @param int $maxVersions + * + * @return $this + */ + public function setMaxVersions(int $maxVersions): self + { + $this->maxVersions = $maxVersions; + + return $this; + } +} diff --git a/src/Models/KeyValueVersion2/SecretMetadata.php b/src/Models/KeyValueVersion2/SecretMetadata.php new file mode 100644 index 0000000..970d263 --- /dev/null +++ b/src/Models/KeyValueVersion2/SecretMetadata.php @@ -0,0 +1,42 @@ +customMetadata; + } + + /** + * Set {@see $customMetadata custom_metadata} + * + * @param array|null $customMetadata + * + * @return $this + */ + public function setCustomMetadata(?array $customMetadata): self + { + $this->customMetadata = $customMetadata; + + return $this; + } +} diff --git a/src/Models/KeyValueVersion2/WriteOptions.php b/src/Models/KeyValueVersion2/WriteOptions.php new file mode 100644 index 0000000..782918f --- /dev/null +++ b/src/Models/KeyValueVersion2/WriteOptions.php @@ -0,0 +1,44 @@ +cas; + } + + /** + * Set {@see $cas cas} + * + * @param int $cas + * + * @return $this + */ + public function setCas(?int $cas): self + { + $this->cas = $cas; + + return $this; + } +} diff --git a/src/ResponseModels/KeyValueVersion1/ListResponse.php b/src/ResponseModels/KeyValueVersion1/ListResponse.php new file mode 100644 index 0000000..504b3c8 --- /dev/null +++ b/src/ResponseModels/KeyValueVersion1/ListResponse.php @@ -0,0 +1,26 @@ +keys; + } +} diff --git a/src/ResponseModels/KeyValueVersion2/ListResponse.php b/src/ResponseModels/KeyValueVersion2/ListResponse.php new file mode 100644 index 0000000..294ab3c --- /dev/null +++ b/src/ResponseModels/KeyValueVersion2/ListResponse.php @@ -0,0 +1,26 @@ +keys; + } +} diff --git a/src/ResponseModels/KeyValueVersion2/ReadMetadataResponse.php b/src/ResponseModels/KeyValueVersion2/ReadMetadataResponse.php new file mode 100644 index 0000000..37ac4b9 --- /dev/null +++ b/src/ResponseModels/KeyValueVersion2/ReadMetadataResponse.php @@ -0,0 +1,78 @@ +createdTime; + } + + /** + * @return int + */ + public function getCurrentVersion(): int + { + return $this->currentVersion; + } + + /** + * @return int + */ + public function getOldestVersion(): int + { + return $this->oldestVersion; + } + + /** + * @return string|null + */ + public function getUpdatedTime(): ?string + { + return $this->updatedTime; + } + + /** + * @return VersionMetadata[] + */ + public function getVersions(): array + { + return $this->versions; + } +} diff --git a/src/ResponseModels/KeyValueVersion2/ReadResponse.php b/src/ResponseModels/KeyValueVersion2/ReadResponse.php new file mode 100644 index 0000000..00fddde --- /dev/null +++ b/src/ResponseModels/KeyValueVersion2/ReadResponse.php @@ -0,0 +1,29 @@ +data; + } +} diff --git a/src/ResponseModels/KeyValueVersion2/ReadSubkeysResponse.php b/src/ResponseModels/KeyValueVersion2/ReadSubkeysResponse.php new file mode 100644 index 0000000..7660898 --- /dev/null +++ b/src/ResponseModels/KeyValueVersion2/ReadSubkeysResponse.php @@ -0,0 +1,29 @@ +subkeys; + } +} diff --git a/src/ResponseModels/KeyValueVersion2/Traits/MetadataTrait.php b/src/ResponseModels/KeyValueVersion2/Traits/MetadataTrait.php new file mode 100644 index 0000000..93703e9 --- /dev/null +++ b/src/ResponseModels/KeyValueVersion2/Traits/MetadataTrait.php @@ -0,0 +1,26 @@ +metadata; + } +} diff --git a/src/ResponseModels/KeyValueVersion2/VersionMetadata.php b/src/ResponseModels/KeyValueVersion2/VersionMetadata.php new file mode 100644 index 0000000..b0dcb33 --- /dev/null +++ b/src/ResponseModels/KeyValueVersion2/VersionMetadata.php @@ -0,0 +1,78 @@ +createdTime; + } + + /** + * @return array|null + */ + public function getCustomMetadata(): ?array + { + return $this->customMetadata; + } + + /** + * @return string|null + */ + public function getDeletionTime(): ?string + { + return $this->deletionTime; + } + + /** + * @return bool + */ + public function isDestroyed(): bool + { + return $this->destroyed; + } + + /** + * @return int + */ + public function getVersion(): int + { + return $this->version; + } +} diff --git a/src/SecretsEngines/AbstractSecretsEngine.php b/src/SecretsEngines/AbstractSecretsEngine.php new file mode 100644 index 0000000..a544651 --- /dev/null +++ b/src/SecretsEngines/AbstractSecretsEngine.php @@ -0,0 +1,78 @@ +client = $client; + if (empty($mount)) { + throw new RuntimeException('Secrets Engine require not-empty mount path'); + } + if ($mount[0] !== '/') { + $mount = '/'.$mount; + } + $this->mount = $mount; + } + + /** + * @param string $path Path of the secret + * + * @return string + */ + public function buildPath(string $path): string + { + return sprintf('%s/%s', $this->client->buildPath($this->mount), $path); + } + + /** + * @return Client + */ + public function getClient(): Client + { + return $this->client; + } + + /** + * @param Client $client + * + * @return $this + */ + public function setClient(Client $client): self + { + $this->client = $client; + + return $this; + } + + /** + * @return string + */ + public function getMount(): string + { + return $this->mount; + } +} diff --git a/src/SecretsEngines/CubbyholeSecretsEngine.php b/src/SecretsEngines/CubbyholeSecretsEngine.php new file mode 100644 index 0000000..0dc803c --- /dev/null +++ b/src/SecretsEngines/CubbyholeSecretsEngine.php @@ -0,0 +1,26 @@ +client->get( + parent::buildPath($path) + ); + } + + /** + * List secrets at specified path + * + * @param string $path Path to list secrets from + * @return ListResponse + **/ + public function list(string $path): ListResponse + { + return ListResponseBuilder::build( + $this->client->list( + parent::buildPath($path) + ) + ); + } + + /** + * Create or update specified secret + * + * @param string $path Path of the secret + * @param array $data Payload to write + * @return Response + **/ + public function createOrUpdate(string $path, array $data = []): Response + { + return $this->client->post( + parent::buildPath($path), + json_encode($data) + ); + } + + /** + * Delete secret + * + * @param string $path Path of the secret + * @return Response + **/ + public function delete(string $path): Response + { + return $this->client->delete( + parent::buildPath($path) + ); + } +} diff --git a/src/SecretsEngines/KeyValueVersion2SecretsEngine.php b/src/SecretsEngines/KeyValueVersion2SecretsEngine.php new file mode 100644 index 0000000..6585543 --- /dev/null +++ b/src/SecretsEngines/KeyValueVersion2SecretsEngine.php @@ -0,0 +1,282 @@ +client->post( + parent::buildPath('config'), + json_encode($config) + ); + } + + /** + * Read current secrets engine configuration + * + * @return Configuration + **/ + public function readConfiguration(): Configuration + { + return ReadConfigurationResponseBuilder::build( + $this->client->get( + parent::buildPath('config') + ) + ); + } + + /** + * Read specified secret version + * + * @param string $path Path of the secret + * @param int $version Version to read (0 = latest) + * @return ReadResponse + **/ + public function read(string $path, int $version = 0): ReadResponse + { + return ReadResponseBuilder::build( + $this->client->get( + parent::buildPath( + sprintf('data/%s?version=%d', $path, $version) + ) + ) + ); + } + + /** + * Create new version of a secret + * + * @param string $path Path of the secret + * @param array $data Payload to write + * @param WriteOptions|null $options Write options + * @return VersionMetadata + **/ + public function createOrUpdate(string $path, array $data = [], ?WriteOptions $options = null): VersionMetadata + { + $payload = [ + 'data' => $data, + ]; + if ($options) { + $payload['options'] = $options; + } + return VersionMetadataResponseBuilder::build( + $this->client->post( + parent::buildPath('data/'.$path), + json_encode($payload) + ) + ); + } + + /** + * Patch existing secret + * + * @param string $path Path of the secret + * @param array $data Payload to write + * @param WriteOptions|null $options Write options + * @return VersionMetadata + **/ + public function patch(string $path, array $data = [], ?WriteOptions $options = null): VersionMetadata + { + $payload = [ + 'data' => $data, + ]; + if ($options) { + $payload['options'] = $options; + } + return VersionMetadataResponseBuilder::build( + $this->client->patch( + parent::buildPath('data/'.$path), + json_encode($payload) + ) + ); + } + + /** + * Read subkeys within a secret + * + * @param string $path Path of the secret + * @param int $version Version to read (0 = latest) + * @param int $depth Deepest nesting level (0 = no limit) + * @return ReadSubkeysResponse + **/ + public function readSubkeys(string $path, int $version = 0, int $depth = 0): ReadSubkeysResponse + { + return ReadSubkeysResponseBuilder::build( + $this->client->get( + parent::buildPath( + sprintf('subkeys/%s?version=%d&depth=%d', $path, $version, $depth) + ) + ) + ); + } + + /** + * Delete latest version of the secret + * + * @param string $path Path of the secret + * @return Response + **/ + public function deleteLatest(string $path): Response + { + return $this->client->delete( + parent::buildPath('data/'.$path) + ); + } + + /** + * Delete specified secret versions + * + * @param string $path Path of the secret + * @param int[] $versions Versions to delete + * @return Response + **/ + public function deleteVersions(string $path, array $versions = []): Response + { + $payload = [ + 'versions' => $versions + ]; + return $this->client->post( + parent::buildPath('delete/'.$path), + json_encode($payload) + ); + } + + /** + * Undelete specified secret versions + * + * @param string $path Path of the secret + * @param int[] $versions Versions to delete + * @return Response + **/ + public function undeleteVersions(string $path, array $versions = []): Response + { + $payload = [ + 'versions' => $versions + ]; + return $this->client->post( + parent::buildPath('undelete/'.$path), + json_encode($payload) + ); + } + + /** + * Destroy (hard delete) specified secret versions + * + * @param string $path Path of the secret + * @param int[] $versions Versions to delete + * @return Response + **/ + public function destroyVersions(string $path, array $versions = []): Response + { + $payload = [ + 'versions' => $versions + ]; + return $this->client->put( + parent::buildPath('destroy/'.$path), + json_encode($payload) + ); + } + + /** + * List secrets at specified path + * + * @param string $path Path to list secrets from + * @return ListResponse + **/ + public function list(string $path): ListResponse + { + return ListResponseBuilder::build( + $this->client->list( + parent::buildPath('metadata/'.$path) + ) + ); + } + + /** + * Read specified secret metadata + * + * @param string $path Path of the secret + * @return ReadMetadataResponse + **/ + public function readMetadata(string $path): ReadMetadataResponse + { + return ReadMetadataResponseBuilder::build( + $this->client->get( + parent::buildPath('metadata/'.$path) + ) + ); + } + + /** + * Create or update specified secret metadata + * + * @param string $path Path of the secret + * @param SecretMetadata $metadata Metadata to set + * @return Response + **/ + public function createOrUpdateMetadata(string $path, SecretMetadata $metadata): Response + { + return $this->client->post( + parent::buildPath('metadata/'.$path), + json_encode($metadata) + ); + } + + /** + * Patch specified secret metadata + * + * @param string $path Path of the secret + * @param array $metadata Metadata to set + * @return Response + **/ + public function patchMetadata(string $path, array $metadata): Response + { + return $this->client->patch( + parent::buildPath('metadata/'.$path), + json_encode($metadata) + ); + } + + /** + * Delete metadata and all versions + * + * @param string $path Path of the secret + * @return Response + **/ + public function deleteMetadata(string $path): Response + { + return $this->client->delete( + parent::buildPath('metadata/'.$path) + ); + } +}