From 1a0025aebe59e5f4071a5255babf7ffff378e8ff Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sun, 11 Oct 2026 10:51:53 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(secret):=20Tiger=5FSecret=20=E2=80=94?= =?UTF-8?q?=20provider-neutral=20secrets=20/=20key-store=20(TIGER-291)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A public core feature module that resolves secrets through a config-selected backend, so WHERE a secret lives is a config choice and the caller never cares — the same pattern as Tiger_Location / Tiger_Mail / the auth credential provider. AWS SSM is an adapter, never the interface. - library/Tiger/Secret.php — the facade: resolve() (scheme ref -> fetch; else plaintext passthrough — the one-line swap at every *_enc call site), get(), register(), put(); lazy self-wiring from tiger.secret.* so it is correct with no Bootstrap (web/CLI/tests) and never depends on module activation. - Adapters: Local (Tiger_Crypto enc:, the default), Env, File, Chain — all dependency-free, in core; Ssm + SecretsManager capability-gated on the AWS SDK (shipped by tiger-sdk-aws, never vendored) with an injectable client, dormant and null-resolving without it. Writer interface (put/delete) for ops tooling. - modules/secret — manifest + Bootstrap (eager-wires configured backends) + AGENTS.md + docs/en. - Default driver is `local` = today's behavior, so shared-hosting installs are unaffected and need no SDK. On the fleet, driver=ssm authenticates with the EC2 instance role (nothing baked, nothing in user-data) — which also breaks the bootstrap chicken-and-egg: crypto.key can come from SSM because SSM trusts the role, not the key. - 52 unit tests (facade + every adapter; cloud via a stubbed client); full unit suite green (1083). CAPABILITIES.md regenerated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 12 +- library/Tiger/Secret.php | 296 ++++++++++++++++++ library/Tiger/Secret/Adapter/Chain.php | 61 ++++ library/Tiger/Secret/Adapter/Env.php | 38 +++ library/Tiger/Secret/Adapter/File.php | 34 ++ library/Tiger/Secret/Adapter/Interface.php | 40 +++ library/Tiger/Secret/Adapter/Local.php | 36 +++ .../Tiger/Secret/Adapter/SecretsManager.php | 121 +++++++ library/Tiger/Secret/Adapter/Ssm.php | 123 ++++++++ library/Tiger/Secret/Writer/Interface.php | 36 +++ modules/secret/AGENTS.md | 56 ++++ modules/secret/Bootstrap.php | 56 ++++ modules/secret/docs/en/_index.md | 72 +++++ modules/secret/module.json | 6 + tests/Unit/Secret/Adapter/ChainTest.php | 89 ++++++ tests/Unit/Secret/Adapter/EnvTest.php | 50 +++ tests/Unit/Secret/Adapter/FileTest.php | 52 +++ tests/Unit/Secret/Adapter/LocalTest.php | 47 +++ .../Secret/Adapter/SecretsManagerTest.php | 99 ++++++ tests/Unit/Secret/Adapter/SsmTest.php | 111 +++++++ tests/Unit/Secret/SecretTest.php | 181 +++++++++++ 21 files changed, 1615 insertions(+), 1 deletion(-) create mode 100644 library/Tiger/Secret.php create mode 100644 library/Tiger/Secret/Adapter/Chain.php create mode 100644 library/Tiger/Secret/Adapter/Env.php create mode 100644 library/Tiger/Secret/Adapter/File.php create mode 100644 library/Tiger/Secret/Adapter/Interface.php create mode 100644 library/Tiger/Secret/Adapter/Local.php create mode 100644 library/Tiger/Secret/Adapter/SecretsManager.php create mode 100644 library/Tiger/Secret/Adapter/Ssm.php create mode 100644 library/Tiger/Secret/Writer/Interface.php create mode 100644 modules/secret/AGENTS.md create mode 100644 modules/secret/Bootstrap.php create mode 100644 modules/secret/docs/en/_index.md create mode 100644 modules/secret/module.json create mode 100644 tests/Unit/Secret/Adapter/ChainTest.php create mode 100644 tests/Unit/Secret/Adapter/EnvTest.php create mode 100644 tests/Unit/Secret/Adapter/FileTest.php create mode 100644 tests/Unit/Secret/Adapter/LocalTest.php create mode 100644 tests/Unit/Secret/Adapter/SecretsManagerTest.php create mode 100644 tests/Unit/Secret/Adapter/SsmTest.php create mode 100644 tests/Unit/Secret/SecretTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 2511ae4..b8d89fb 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**213 classes** across **33 capabilities** · **22 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**222 classes** across **33 capabilities** · **23 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -320,6 +320,15 @@ - **Tiger_Comment** `@api` — the subject registry and policy gate for comments, ratings and reviews. · `library/Tiger/Comment.php` - **Tiger_Comment_Spam** `@api` — the spam-check registry, and the first checker: the in-platform AI agent. · `library/Tiger/Comment/Spam.php` - **Tiger_Message** `@api` — the platform's way of telling people things (TIGER-114). · `library/Tiger/Message.php` +- **Tiger_Secret** `@api` — provider-neutral resolution of secrets and sensitive config values. · `library/Tiger/Secret.php` +- **Tiger_Secret_Adapter_Chain** `@api` — try several backends in order; the first hit wins. · `library/Tiger/Secret/Adapter/Chain.php` +- **Tiger_Secret_Adapter_Env** `@api` — resolve a secret from an environment variable (the 12-factor backend). · `library/Tiger/Secret/Adapter/Env.php` +- **Tiger_Secret_Adapter_File** `@api` — resolve a secret from a file on disk (the mounted-secret backend). · `library/Tiger/Secret/Adapter/File.php` +- **Tiger_Secret_Adapter_Interface** `@api` — the contract every secret backend implements. · `library/Tiger/Secret/Adapter/Interface.php` +- **Tiger_Secret_Adapter_Local** `@api` — the default backend: an inline ciphertext decrypted by Tiger_Crypto. · `library/Tiger/Secret/Adapter/Local.php` +- **Tiger_Secret_Adapter_SecretsManager** `@api` — AWS Secrets Manager backend (the rotation-managed sibling of SSM). · `library/Tiger/Secret/Adapter/SecretsManager.php` +- **Tiger_Secret_Adapter_Ssm** `@api` — AWS Systems Manager Parameter Store (SecureString) backend. · `library/Tiger/Secret/Adapter/Ssm.php` +- **Tiger_Secret_Writer_Interface** `@api` — the OPTIONAL write side of a secret backend. · `library/Tiger/Secret/Writer/Interface.php` ## Modules (`modules/*` — activatable features) @@ -341,6 +350,7 @@ - **Register** (`register`, plugin) · services: Registration, Status · `modules/register` - **Schedule** (`schedule`, developer) · services: Schedule · `modules/schedule` - **Search** (`search`, plugin) · services: Search · `modules/search` +- **Secrets** (`secret`, app) — Provider-neutral secrets / key-store resolution (Tiger_Secret) — local-encrypted by default, with SSM / Secrets Manager / env / file / Vault adapters selectable by config. · `modules/secret` - **SEO** (`seo`, app) · services: Head, Pages, Schema, Social · `modules/seo` - **Signup** (`signup`, plugin) · services: Signup · `modules/signup` - **System** (`system`, plugin) · services: Acl, Dashboard, Logs, Modules, Nav, Settings, Sites, Updates · `modules/system` diff --git a/library/Tiger/Secret.php b/library/Tiger/Secret.php new file mode 100644 index 0000000..ecc6f25 --- /dev/null +++ b/library/Tiger/Secret.php @@ -0,0 +1,296 @@ +`. The facade dispatches by scheme to the registered + * adapter; an UNKNOWN scheme (or a value with no scheme at all) is treated as plaintext and returned + * unchanged. That single rule is what makes adoption a one-liner at every existing `*_enc` call site: + * swap `Tiger_Crypto::decrypt($enc)` / plaintext-fallback logic for `Tiger_Secret::resolve($value)`, and + * the same config now accepts a plaintext value, an `enc:` blob, or an `ssm:`/`vault:`/`env:` reference. + * + * WHY THIS IS NOT AWS-SPECIFIC — the default driver is `local`, which is today's behavior (an inline + * Tiger_Crypto value). A shared-hosting install with `tiger.secret.driver` unset behaves exactly as + * before; the whole layer is invisible until an operator points `driver` at a store. On the WebTigers + * fleet it is `ssm` and secrets come from Parameter Store via the instance IAM role — nothing baked into + * a golden image, nothing hand-placed. + * + * WHY IT BREAKS THE BOOTSTRAP CHICKEN-AND-EGG — `tiger.crypto.key` cannot come from the app's own + * encrypted config (it is the key that decrypts it), but it CAN come from SSM, because SSM authenticates + * with the EC2 instance role, not crypto.key. The root of trust becomes the instance role + KMS, not a + * pre-shared secret. + * + * The built-in dependency-free adapters (local/enc, env, file) self-register on first use, and the + * config-driven ones (ssm, secretsmanager, chain) lazily wire from `tiger.secret..*` — so the + * facade is correct in any context (web, CLI, a test) with no module Bootstrap required. The module + * `modules/secret` eager-wires them and is the documented home; it is never load-bearing for correctness. + * + * Resolved values are memoized per process (a store lookup is not repeated within a request). Secrets + * are never logged. Register a custom backend from a module Bootstrap with register(). + * + * @api + */ +class Tiger_Secret +{ + /** @var array scheme => adapter */ + protected static $_adapters = []; + + /** @var array ref => resolved value (per-process memo of successful resolutions) */ + protected static $_cache = []; + + /** Dependency-free built-ins, lazily instantiated on first use. */ + protected static $_builtins = [ + 'enc' => 'Tiger_Secret_Adapter_Local', + 'local' => 'Tiger_Secret_Adapter_Local', + 'env' => 'Tiger_Secret_Adapter_Env', + 'file' => 'Tiger_Secret_Adapter_File', + ]; + + /** + * Register (or replace) a backend for a scheme. Call from a module Bootstrap. + * + * @param string $scheme the reference scheme (e.g. 'ssm', 'vault') + * @param Tiger_Secret_Adapter_Interface $adapter the backend + * @return void + */ + public static function register(string $scheme, Tiger_Secret_Adapter_Interface $adapter): void + { + $scheme = strtolower(trim($scheme)); + if ($scheme !== '') { + self::$_adapters[$scheme] = $adapter; + self::$_cache = []; + } + } + + /** + * The adapter for a scheme, or null. Registered adapters win; then dependency-free built-ins + * (enc/local/env/file); then the config-driven lazies (ssm/secretsmanager/chain) wired from + * `tiger.secret..*`. + * + * @param string $scheme the reference scheme + * @return Tiger_Secret_Adapter_Interface|null + */ + public static function adapter(string $scheme): ?Tiger_Secret_Adapter_Interface + { + $scheme = strtolower(trim($scheme)); + if ($scheme === '') { + return null; + } + if (isset(self::$_adapters[$scheme])) { + return self::$_adapters[$scheme]; + } + if (isset(self::$_builtins[$scheme])) { + $class = self::$_builtins[$scheme]; + return self::$_adapters[$scheme] = new $class(); + } + $lazy = self::_lazyFromConfig($scheme); + if ($lazy !== null) { + return self::$_adapters[$scheme] = $lazy; + } + return null; + } + + /** + * Whether a scheme has a usable backend registered or lazily buildable. + * + * @param string $scheme the reference scheme + * @return bool + */ + public static function has(string $scheme): bool + { + return self::adapter($scheme) !== null; + } + + /** + * The known schemes (built-ins plus anything registered or lazily wired so far). + * + * @return string[] + */ + public static function schemes(): array + { + return array_values(array_unique(array_merge(array_keys(self::$_builtins), array_keys(self::$_adapters)))); + } + + /** + * Resolve a CONFIG VALUE. A `scheme:path` reference to a known scheme is fetched from its backend; + * anything else (including a value with no recognized scheme) is returned unchanged as plaintext. + * Returns null only when a recognized reference could not be resolved — the caller treats that + * exactly as a missing credential. This is the one-line swap at an `*_enc` call site. + * + * @param mixed $value the config value (plaintext, `enc:`/`ssm:`/`env:`/… reference, or '') + * @return string|null the resolved secret, the plaintext, '' for empty, or null if unresolvable + */ + public static function resolve($value): ?string + { + $value = (string) $value; + if ($value === '') { + return ''; + } + $ref = self::_parse($value); + if ($ref === null) { + return $value; // not a recognized reference -> plaintext passthrough + } + return self::_fetch($ref[0], $ref[1], $value); + } + + /** + * Resolve an explicit reference. A `scheme:path` dispatches to that backend; a BARE name (no scheme) + * is resolved against the configured default driver (`tiger.secret.driver`) — meaningful for an + * external store (ssm/env/vault) with its own prefix, not for `local`. + * + * @param string $ref a `scheme:path` reference, or a bare name for the default driver + * @return string|null the secret value, or null when unresolvable + */ + public static function get(string $ref): ?string + { + $ref = (string) $ref; + if ($ref === '') { + return null; + } + $parsed = self::_parse($ref); + if ($parsed === null) { + $scheme = self::_defaultScheme(); // bare name -> default driver + return self::_fetch($scheme, $ref, 'bare:' . $scheme . ':' . $ref); + } + return self::_fetch($parsed[0], $parsed[1], $ref); + } + + /** + * Whether a backend for a scheme can accept writes (implements the writer interface and is available). + * + * @param string $scheme the reference scheme + * @return bool + */ + public static function canWrite(string $scheme): bool + { + $a = self::adapter($scheme); + return $a instanceof Tiger_Secret_Writer_Interface && $a->available(); + } + + /** + * Store a secret at a `scheme:path` reference, when its backend supports writing (ssm, secretsmanager). + * An ops/provisioning helper — never the request path. + * + * @param string $ref a `scheme:path` reference + * @param string $value the secret value to store + * @return bool true on success; false when the scheme is unknown or not writable + */ + public static function put(string $ref, string $value): bool + { + $parsed = self::_parse($ref); + if ($parsed === null) { + return false; + } + [$scheme, $path] = $parsed; + $a = self::adapter($scheme); + if (!($a instanceof Tiger_Secret_Writer_Interface) || !$a->available()) { + return false; + } + self::$_cache = []; + return $a->put($path, $value); + } + + /** + * Clear the per-process memo and all registered/lazy adapters. + * + * @return void + * @internal for tests + */ + public static function _reset(): void + { + self::$_adapters = []; + self::$_cache = []; + } + + // ---- internals --------------------------------------------------------- + + /** Parse `scheme:path` iff the scheme is known; else null (treat the whole value as plaintext). */ + protected static function _parse(string $value): ?array + { + if (!preg_match('/^([a-z][a-z0-9+_-]*):(.*)$/s', $value, $m)) { + return null; + } + $scheme = $m[1]; + if (!self::has($scheme)) { + return null; // unknown scheme (e.g. an https:// URL, a user:pass string) -> plaintext + } + return [$scheme, $m[2]]; + } + + /** Fetch via the scheme's adapter, memoized by the original ref string; fail-soft to null. */ + protected static function _fetch(string $scheme, string $path, string $cacheKey): ?string + { + if (array_key_exists($cacheKey, self::$_cache)) { + return self::$_cache[$cacheKey]; + } + $adapter = self::adapter($scheme); + $out = null; + if ($adapter && $adapter->available()) { + try { + $out = $adapter->get($path); + } catch (Throwable $e) { + $out = null; + } + } + if ($out !== null) { + self::$_cache[$cacheKey] = $out; // memoize only successful resolutions + } + return $out; + } + + /** Build a config-driven adapter for a scheme from `tiger.secret..*`, or null. */ + protected static function _lazyFromConfig(string $scheme): ?Tiger_Secret_Adapter_Interface + { + $sec = self::_cfgSecret(); + switch ($scheme) { + case 'ssm': + if (!class_exists('Tiger_Secret_Adapter_Ssm')) { return null; } + $o = ($sec && $sec->get('ssm') instanceof Zend_Config) ? $sec->ssm->toArray() : []; + return new Tiger_Secret_Adapter_Ssm($o); + case 'secretsmanager': + if (!class_exists('Tiger_Secret_Adapter_SecretsManager')) { return null; } + $o = ($sec && $sec->get('secretsmanager') instanceof Zend_Config) ? $sec->secretsmanager->toArray() : []; + return new Tiger_Secret_Adapter_SecretsManager($o); + case 'chain': + $list = $sec ? (string) $sec->get('chain') : ''; + if ($list === '') { return null; } + $schemes = array_filter(array_map('trim', explode(',', $list))); + return $schemes ? new Tiger_Secret_Adapter_Chain($schemes) : null; + } + return null; + } + + /** The configured default driver scheme (`tiger.secret.driver`), default 'local'. */ + protected static function _defaultScheme(): string + { + $sec = self::_cfgSecret(); + if ($sec) { + $d = strtolower(trim((string) $sec->get('driver'))); + if ($d !== '') { + return $d; + } + } + return 'local'; + } + + /** The `tiger.secret` Zend_Config node, or null. */ + protected static function _cfgSecret(): ?Zend_Config + { + if (!Zend_Registry::isRegistered('Zend_Config')) { + return null; + } + $cfg = Zend_Registry::get('Zend_Config'); + if ($cfg instanceof Zend_Config && $cfg->get('tiger') && $cfg->tiger->get('secret') instanceof Zend_Config) { + return $cfg->tiger->secret; + } + return null; + } +} diff --git a/library/Tiger/Secret/Adapter/Chain.php b/library/Tiger/Secret/Adapter/Chain.php new file mode 100644 index 0000000..aa0704b --- /dev/null +++ b/library/Tiger/Secret/Adapter/Chain.php @@ -0,0 +1,61 @@ +_schemes = array_values(array_filter( + array_map(static fn($s) => strtolower(trim((string) $s)), $schemes), + static fn($s) => $s !== '' && $s !== 'chain' + )); + } + + public function available(): bool + { + foreach ($this->_schemes as $scheme) { + $a = Tiger_Secret::adapter($scheme); + if ($a && $a->available()) { + return true; + } + } + return false; + } + + public function get(string $path): ?string + { + foreach ($this->_schemes as $scheme) { + $a = Tiger_Secret::adapter($scheme); + if (!$a || !$a->available()) { + continue; + } + try { + $v = $a->get($path); + } catch (Throwable $e) { + $v = null; + } + if ($v !== null) { + return $v; + } + } + return null; + } +} diff --git a/library/Tiger/Secret/Adapter/Env.php b/library/Tiger/Secret/Adapter/Env.php new file mode 100644 index 0000000..c62f07d --- /dev/null +++ b/library/Tiger/Secret/Adapter/Env.php @@ -0,0 +1,38 @@ +` → Tiger_Crypto::decrypt(). No external store, + * no cloud SDK, no network — so a shared-hosting install with `tiger.secret.driver = local` (the + * default) behaves exactly as before and the whole secrets layer is invisible. + * + * Read-only by design: there is no "store" to put a secret into — the ciphertext IS the stored form. + * (Tiger_Crypto::encrypt() mints one; that's an install/rotation concern, not this adapter's job.) + * + * @api + */ +class Tiger_Secret_Adapter_Local implements Tiger_Secret_Adapter_Interface +{ + public function available(): bool + { + return class_exists('Tiger_Crypto') && Tiger_Crypto::isConfigured(); + } + + public function get(string $path): ?string + { + if ($path === '' || !class_exists('Tiger_Crypto')) { + return null; + } + try { + return Tiger_Crypto::decrypt($path); + } catch (Throwable $e) { + return null; + } + } +} diff --git a/library/Tiger/Secret/Adapter/SecretsManager.php b/library/Tiger/Secret/Adapter/SecretsManager.php new file mode 100644 index 0000000..4c71fdb --- /dev/null +++ b/library/Tiger/Secret/Adapter/SecretsManager.php @@ -0,0 +1,121 @@ +_opts = $opts; + $this->_client = $opts['client'] ?? null; + } + + public function available(): bool + { + return $this->_client !== null || class_exists('Aws\\SecretsManager\\SecretsManagerClient'); + } + + public function get(string $path): ?string + { + if (!$this->available()) { + return null; + } + try { + $res = $this->_client()->getSecretValue(['SecretId' => $this->_name($path)]); + $val = $res['SecretString'] ?? null; + return ($val === null || $val === '') ? null : (string) $val; + } catch (Throwable $e) { + return null; + } + } + + public function put(string $path, string $value): bool + { + if (!$this->available()) { + return false; + } + $name = $this->_name($path); + try { + $this->_client()->putSecretValue(['SecretId' => $name, 'SecretString' => $value]); + return true; + } catch (Throwable $e) { + // Most likely the secret doesn't exist yet — create it. + try { + $this->_client()->createSecret(['Name' => $name, 'SecretString' => $value]); + return true; + } catch (Throwable $e2) { + return false; + } + } + } + + public function delete(string $path): bool + { + if (!$this->available()) { + return false; + } + try { + $this->_client()->deleteSecret([ + 'SecretId' => $this->_name($path), + 'ForceDeleteWithoutRecovery' => true, + ]); + return true; + } catch (Throwable $e) { + return false; + } + } + + /** Join a relative id under the configured prefix (Secrets Manager names are path-like). */ + protected function _name(string $path): string + { + $prefix = rtrim((string) ($this->_opts['prefix'] ?? ''), '/'); + if ($path === '') { + return $prefix; + } + if ($path[0] === '/') { + return ltrim($path, '/'); + } + return $prefix !== '' ? $prefix . '/' . $path : $path; + } + + /** The client — injected (tests) or a lazily-built SDK client using the role/static creds. */ + protected function _client() + { + if ($this->_client !== null) { + return $this->_client; + } + $args = ['region' => (string) ($this->_opts['region'] ?? 'us-east-1'), 'version' => 'latest']; + $key = (string) ($this->_opts['key'] ?? ''); + $sec = (string) ($this->_opts['secret'] ?? ''); + if ($key !== '' && $sec !== '') { + $args['credentials'] = ['key' => $key, 'secret' => $sec]; + } + $class = 'Aws\\SecretsManager\\SecretsManagerClient'; + $this->_client = new $class($args); + return $this->_client; + } +} diff --git a/library/Tiger/Secret/Adapter/Ssm.php b/library/Tiger/Secret/Adapter/Ssm.php new file mode 100644 index 0000000..12a2e65 --- /dev/null +++ b/library/Tiger/Secret/Adapter/Ssm.php @@ -0,0 +1,123 @@ +_opts = $opts; + $this->_client = $opts['client'] ?? null; + } + + public function available(): bool + { + return $this->_client !== null || class_exists('Aws\\Ssm\\SsmClient'); + } + + public function get(string $path): ?string + { + if (!$this->available()) { + return null; + } + try { + $res = $this->_client()->getParameter(['Name' => $this->_name($path), 'WithDecryption' => true]); + $val = $res['Parameter']['Value'] ?? null; + return ($val === null || $val === '') ? null : (string) $val; + } catch (Throwable $e) { + return null; + } + } + + public function put(string $path, string $value): bool + { + if (!$this->available()) { + return false; + } + try { + $this->_client()->putParameter([ + 'Name' => $this->_name($path), + 'Value' => $value, + 'Type' => 'SecureString', + 'Overwrite' => true, + ]); + return true; + } catch (Throwable $e) { + return false; + } + } + + public function delete(string $path): bool + { + if (!$this->available()) { + return false; + } + try { + $this->_client()->deleteParameter(['Name' => $this->_name($path)]); + return true; + } catch (Throwable $e) { + return false; + } + } + + /** Build the parameter name: an absolute path wins; a relative one is joined under the prefix. */ + protected function _name(string $path): string + { + $prefix = rtrim((string) ($this->_opts['prefix'] ?? ''), '/'); + if ($path === '') { + return $prefix; + } + if ($path[0] === '/') { + return $path; + } + return $prefix !== '' ? $prefix . '/' . $path : $path; + } + + /** The SSM client — the injected one (tests) or a lazily-built SDK client using the role/static creds. */ + protected function _client() + { + if ($this->_client !== null) { + return $this->_client; + } + $args = ['region' => (string) ($this->_opts['region'] ?? 'us-east-1'), 'version' => 'latest']; + $key = (string) ($this->_opts['key'] ?? ''); + $sec = (string) ($this->_opts['secret'] ?? ''); + if ($key !== '' && $sec !== '') { + $args['credentials'] = ['key' => $key, 'secret' => $sec]; + } + $class = 'Aws\\Ssm\\SsmClient'; + $this->_client = new $class($args); + return $this->_client; + } +} diff --git a/library/Tiger/Secret/Writer/Interface.php b/library/Tiger/Secret/Writer/Interface.php new file mode 100644 index 0000000..e0c36d6 --- /dev/null +++ b/library/Tiger/Secret/Writer/Interface.php @@ -0,0 +1,36 @@ + **Step 0 — check core before you build.** Before writing any mechanism, grep +> [`../../CAPABILITIES.md`](../../CAPABILITIES.md) — a generated, CI-checked index of every `Tiger_*` class and +> module. Core probably already has it. **Assume a capability exists until you have grepped the index and +> confirmed it doesn't.** + +Instructions for an AI assistant (or a new contributor) working on Tiger's **secret** module. For platform +conventions read the root **AGENTS.md**; this is the module-specific layer. + +> Provider-neutral secrets / key-store resolution. `Tiger_Secret` is the facade; a backend is a config +> choice (`tiger.secret.driver`), never baked into the caller — the same pattern as `Tiger_Location`, +> `Tiger_Mail`, and the auth credential provider. AWS SSM is **an** adapter, never **the** interface. + +## The one principle + +**A secret reference is `scheme:path`; the facade dispatches by scheme; an unknown scheme (or no scheme) +is plaintext.** That single rule makes adoption a one-liner at an `*_enc` call site: replace +`Tiger_Crypto::decrypt($enc)` / plaintext-fallback logic with `Tiger_Secret::resolve($value)`, and the +same config now accepts a plaintext value, an `enc:` blob, or an `ssm:` / `env:` / `file:` / `vault:` +reference. Nothing a caller does is AWS-specific. + +## Where things live + +- `library/Tiger/Secret.php` — the facade (`resolve`, `get`, `register`, `put`, `adapter`, `has`, `schemes`). +- `library/Tiger/Secret/Adapter/Interface.php` — the backend contract (`available()` + `get()`). +- `library/Tiger/Secret/Writer/Interface.php` — the optional write side (`put` / `delete`), for ops tooling. +- `library/Tiger/Secret/Adapter/{Local,Env,File,Chain,Ssm,SecretsManager}.php` — the backends. +- `modules/secret/Bootstrap.php` — eager-wires the config-driven backends at boot (convenience only). + +## The rules + +- **`local` is the default and a no-op change.** With `tiger.secret.driver` unset, resolution is today's + behavior (an inline `Tiger_Crypto` value via the `enc:` scheme). A shared-hosting install is unaffected + and needs no SDK. Never make `local` require anything external. +- **The facade must work with no Bootstrap.** Built-ins self-register lazily; ssm/secretsmanager/chain + wire lazily from `tiger.secret..*`. Correctness never depends on this module being active — the + Bootstrap only eager-wires for `schemes()` visibility + early error surfacing. Keep it that way. +- **Fail-soft, never fatal.** `available()` is capability-detection; `get()` returns null for + missing/unreadable/SDK-absent and must not throw for the ordinary case. The facade also guards every + call. A missing cloud SDK is a null, never a 500. +- **Cloud adapters are capability-gated and SDK-optional.** `Ssm`/`SecretsManager` reference + `Aws\…\…Client` only behind `class_exists` (or an injected client), so core vendors no AWS SDK — the + tiger-sdk-aws provider module supplies it on the fleet. Mirror `Hosting_Lib_Provisioner_Tigerden`. +- **Never log a secret.** Resolve, use, discard. No `Tiger_Log` line carries a resolved value. +- **The bootstrap secret is special.** `tiger.crypto.key` cannot come from the store it unlocks (it IS + the decrypt key) — it arrives via the instance role at firstboot (the fleet) or stays in local.ini + (shared hosting). The store's root of trust is the IAM role + KMS, not a pre-shared secret. + +## Adding a backend + +Implement `Tiger_Secret_Adapter_Interface` (+ `Tiger_Secret_Writer_Interface` if it writes), make +`available()` honest (return false when its dependency is absent), and register it under its scheme — +either from a module Bootstrap (`Tiger_Secret::register('vault', new …)`) or by adding a lazy case in +`Tiger_Secret::_lazyFromConfig()` for a config-driven one. Add unit tests under `tests/Unit/Secret/` +(the dep-free ones run in CI with no network; stub the client for a cloud one). diff --git a/modules/secret/Bootstrap.php b/modules/secret/Bootstrap.php new file mode 100644 index 0000000..046c9a4 --- /dev/null +++ b/modules/secret/Bootstrap.php @@ -0,0 +1,56 @@ +get('tiger') && $cfg->tiger->get('secret') instanceof Zend_Config) + ? $cfg->tiger->secret : null; + if (!$sec) { + return; // nothing to eager-wire; built-ins remain lazy + } + + // Instantiating via adapter() runs the facade's own lazy wiring + caches the result. + foreach (['ssm', 'secretsmanager', 'chain'] as $scheme) { + if ($sec->get($scheme) !== null) { + try { + Tiger_Secret::adapter($scheme); + } catch (Throwable $e) { + // Fail-soft: a bad adapter config must never take the site down at boot. + } + } + } + + // If a default driver is named and it's one of the external schemes, warm it too. + $driver = strtolower(trim((string) $sec->get('driver'))); + if ($driver !== '' && !in_array($driver, ['local', 'enc', 'env', 'file'], true)) { + try { + Tiger_Secret::adapter($driver); + } catch (Throwable $e) { + // ignore + } + } + } +} diff --git a/modules/secret/docs/en/_index.md b/modules/secret/docs/en/_index.md new file mode 100644 index 0000000..becd4e8 --- /dev/null +++ b/modules/secret/docs/en/_index.md @@ -0,0 +1,72 @@ + + +# Secrets & the key store + +Tiger resolves every sensitive value — an API key, a provider secret, an encryption key — through one +provider-neutral facade, `Tiger_Secret`. **Where** a secret lives is a configuration choice; the code that +needs it never cares. By default nothing changes from how Tiger has always worked: secrets stay encrypted +in your config. When you run on a cloud that offers a key store, you point one setting at it and your boxes +read their secrets from there instead — nothing baked into an image, nothing copied by hand. + +## You usually don't need to do anything + +The default driver is **`local`** — an encrypted value in `local.ini` or the config tier, decrypted with +your install's `tiger.crypto.key`. That's exactly how Tiger behaved before this feature existed. A +shared-hosting site needs no key store and no cloud SDK; this whole layer stays invisible. + +## Choosing a backend + +Set `tiger.secret.driver` to pick where bare secret lookups resolve from: + +| Driver | Secrets live in | Needs | +|---|---|---| +| `local` (default) | an encrypted value in your config | nothing | +| `env` | environment variables | nothing | +| `file` | files (e.g. `/run/secrets/…`, systemd/Docker/Kubernetes secrets) | nothing | +| `ssm` | **AWS SSM Parameter Store** (SecureString) | the AWS SDK + an instance IAM role | +| `secretsmanager` | **AWS Secrets Manager** | the AWS SDK + an instance IAM role | +| `chain` | tried in order (e.g. SSM, then env, then local) | per the listed drivers | + +```ini +; shared hosting / dev — the default; omit it entirely and you get this +tiger.secret.driver = local + +; a cloud box reading from AWS Parameter Store via its instance role +tiger.secret.driver = ssm +tiger.secret.ssm.region = us-east-1 +tiger.secret.ssm.prefix = /tiger/prod/node + +; resilient: the store if reachable, else an env var, else the local encrypted value +tiger.secret.driver = chain +tiger.secret.chain = ssm,env,local +``` + +## Pointing a single value at a store + +Any secret in your config can be a plain value, an encrypted value, **or a reference**. A reference is +`scheme:path`, and it's resolved on read: + +```ini +tiger.hosting.registrar.opensrs.api_key = ssm:/tiger/prod/relay/opensrs_api_key +tiger.hosting.storage.r2.secret = env:R2_SECRET +tiger.something.key = file:/run/secrets/something +``` + +A value with no recognized scheme is used as-is (plaintext), so mixing references and plain values in the +same file is fine. + +## Why the cloud path is the safe one + +When a box reads its secrets from a store, it authenticates with its **cloud IAM role**, not a secret you +put on the box. So nothing sensitive is baked into a machine image and nothing travels in start-up data — +the box boots with a role and pulls exactly the secrets it's allowed to. This is also what lets the +encryption key itself live in the store: the store trusts the role, not the key. + +## What never leaves + +Secrets are resolved, used, and discarded — they're never written to logs. The admin tooling that tests a +reference reports only whether it resolved, never the value itself. diff --git a/modules/secret/module.json b/modules/secret/module.json new file mode 100644 index 0000000..7958349 --- /dev/null +++ b/modules/secret/module.json @@ -0,0 +1,6 @@ +{ + "name": "Secrets", + "type": "app", + "category": ["operations", "security"], + "description": "Provider-neutral secrets / key-store resolution (Tiger_Secret) — local-encrypted by default, with SSM / Secrets Manager / env / file / Vault adapters selectable by config." +} diff --git a/tests/Unit/Secret/Adapter/ChainTest.php b/tests/Unit/Secret/Adapter/ChainTest.php new file mode 100644 index 0000000..fe9801f --- /dev/null +++ b/tests/Unit/Secret/Adapter/ChainTest.php @@ -0,0 +1,89 @@ +adapter(null)); + Tiger_Secret::register('hit', $this->adapter('found-here')); + Tiger_Secret::register('late', $this->adapter('too-late')); + + $chain = new Tiger_Secret_Adapter_Chain(['miss', 'hit', 'late']); + $this->assertSame('found-here', $chain->get('whatever')); + } + + #[Test] + public function all_misses_resolve_to_null(): void + { + Tiger_Secret::register('a', $this->adapter(null)); + Tiger_Secret::register('b', $this->adapter(null)); + + $this->assertNull((new Tiger_Secret_Adapter_Chain(['a', 'b']))->get('x')); + } + + #[Test] + public function an_unavailable_child_is_skipped(): void + { + Tiger_Secret::register('down', $this->adapter('unreachable', false)); + Tiger_Secret::register('up', $this->adapter('reached')); + + $this->assertSame('reached', (new Tiger_Secret_Adapter_Chain(['down', 'up']))->get('x')); + } + + #[Test] + public function available_reflects_whether_any_child_is_available(): void + { + Tiger_Secret::register('d1', $this->adapter(null, false)); + $this->assertFalse((new Tiger_Secret_Adapter_Chain(['d1']))->available()); + + Tiger_Secret::register('u1', $this->adapter('v')); + $this->assertTrue((new Tiger_Secret_Adapter_Chain(['d1', 'u1']))->available()); + } + + #[Test] + public function chain_cannot_recurse_into_itself(): void + { + // 'chain' is filtered out of the child list, so this resolves against 'real' only (no infinite loop). + Tiger_Secret::register('real', $this->adapter('ok')); + $chain = new Tiger_Secret_Adapter_Chain(['chain', 'real']); + $this->assertSame('ok', $chain->get('x')); + } + + private function adapter(?string $value, bool $available = true): Tiger_Secret_Adapter_Interface + { + return new class($value, $available) implements Tiger_Secret_Adapter_Interface { + public function __construct(private ?string $value, private bool $up) {} + public function available(): bool { return $this->up; } + public function get(string $path): ?string { return $this->value; } + }; + } +} diff --git a/tests/Unit/Secret/Adapter/EnvTest.php b/tests/Unit/Secret/Adapter/EnvTest.php new file mode 100644 index 0000000..4dfea8a --- /dev/null +++ b/tests/Unit/Secret/Adapter/EnvTest.php @@ -0,0 +1,50 @@ +assertTrue($a->available()); + $this->assertSame('yes', $a->get('TIGER_SEC_ENVTEST')); + } finally { + putenv('TIGER_SEC_ENVTEST'); + } + } + + #[Test] + public function falls_back_to_the_server_superglobal(): void + { + $_SERVER['TIGER_SEC_SRVTEST'] = 'srv'; + try { + $this->assertSame('srv', (new Tiger_Secret_Adapter_Env())->get('TIGER_SEC_SRVTEST')); + } finally { + unset($_SERVER['TIGER_SEC_SRVTEST']); + } + } + + #[Test] + public function an_unset_or_empty_variable_is_null(): void + { + $a = new Tiger_Secret_Adapter_Env(); + $this->assertNull($a->get('TIGER_SEC_DEFINITELY_UNSET')); + $this->assertNull($a->get('')); + } +} diff --git a/tests/Unit/Secret/Adapter/FileTest.php b/tests/Unit/Secret/Adapter/FileTest.php new file mode 100644 index 0000000..1aac782 --- /dev/null +++ b/tests/Unit/Secret/Adapter/FileTest.php @@ -0,0 +1,52 @@ +tmp !== null) { @unlink($this->tmp); $this->tmp = null; } + parent::tearDown(); + } + + #[Test] + public function reads_a_file_and_strips_one_trailing_newline(): void + { + $this->tmp = tempnam(sys_get_temp_dir(), 'tsecf'); + file_put_contents($this->tmp, "the-secret\n"); + $a = new Tiger_Secret_Adapter_File(); + + $this->assertTrue($a->available()); + $this->assertSame('the-secret', $a->get($this->tmp)); + } + + #[Test] + public function a_missing_file_is_null(): void + { + $this->assertNull((new Tiger_Secret_Adapter_File())->get('/no/such/tiger/secret/file')); + $this->assertNull((new Tiger_Secret_Adapter_File())->get('')); + } + + #[Test] + public function an_empty_file_is_null(): void + { + $this->tmp = tempnam(sys_get_temp_dir(), 'tsecf'); + file_put_contents($this->tmp, "\n"); + $this->assertNull((new Tiger_Secret_Adapter_File())->get($this->tmp)); + } +} diff --git a/tests/Unit/Secret/Adapter/LocalTest.php b/tests/Unit/Secret/Adapter/LocalTest.php new file mode 100644 index 0000000..77bd949 --- /dev/null +++ b/tests/Unit/Secret/Adapter/LocalTest.php @@ -0,0 +1,47 @@ +setCryptoConfig(); + $blob = Tiger_Crypto::encrypt('hunter2'); + $a = new Tiger_Secret_Adapter_Local(); + + $this->assertTrue($a->available()); + $this->assertSame('hunter2', $a->get($blob)); + } + + #[Test] + public function is_unavailable_without_a_crypto_key(): void + { + $a = new Tiger_Secret_Adapter_Local(); + $this->assertFalse($a->available()); + } + + #[Test] + public function a_malformed_or_empty_blob_is_null(): void + { + $this->setCryptoConfig(); + $a = new Tiger_Secret_Adapter_Local(); + + $this->assertNull($a->get('')); + $this->assertNull($a->get('not-valid-ciphertext')); + } +} diff --git a/tests/Unit/Secret/Adapter/SecretsManagerTest.php b/tests/Unit/Secret/Adapter/SecretsManagerTest.php new file mode 100644 index 0000000..068fcad --- /dev/null +++ b/tests/Unit/Secret/Adapter/SecretsManagerTest.php @@ -0,0 +1,99 @@ +calls[] = ['getSecretValue', $args]; + $id = $args['SecretId']; + if (!array_key_exists($id, $this->existing)) { throw new \RuntimeException('ResourceNotFound'); } + return ['SecretString' => $this->existing[$id]]; + } + public function putSecretValue(array $args): array + { + $this->calls[] = ['putSecretValue', $args]; + if (!array_key_exists($args['SecretId'], $this->existing)) { throw new \RuntimeException('ResourceNotFound'); } + $this->existing[$args['SecretId']] = $args['SecretString']; + return []; + } + public function createSecret(array $args): array + { + $this->calls[] = ['createSecret', $args]; + $this->existing[$args['Name']] = $args['SecretString']; + return []; + } + public function deleteSecret(array $args): array { $this->calls[] = ['deleteSecret', $args]; return []; } + }; + } + + #[Test] + public function reads_an_existing_secret_string(): void + { + $client = $this->client(['tiger/prod/stripe' => 'sk_live_x']); + $a = new Tiger_Secret_Adapter_SecretsManager(['client' => $client, 'prefix' => 'tiger/prod']); + $this->assertSame('sk_live_x', $a->get('stripe')); + } + + #[Test] + public function a_missing_secret_is_null(): void + { + $a = new Tiger_Secret_Adapter_SecretsManager(['client' => $this->client()]); + $this->assertNull($a->get('absent')); + } + + #[Test] + public function put_creates_when_absent_then_updates_when_present(): void + { + $client = $this->client(); + $a = new Tiger_Secret_Adapter_SecretsManager(['client' => $client, 'prefix' => 'tiger/prod']); + + // Absent -> putSecretValue throws -> falls back to createSecret. + $this->assertTrue($a->put('stripe', 'v1')); + $this->assertSame('createSecret', $client->calls[1][0]); + $this->assertSame('v1', $a->get('stripe')); + + // Present -> putSecretValue succeeds. + $this->assertTrue($a->put('stripe', 'v2')); + $this->assertSame('v2', $a->get('stripe')); + } + + #[Test] + public function delete_removes_the_secret(): void + { + $client = $this->client(['tiger/prod/x' => 'y']); + $a = new Tiger_Secret_Adapter_SecretsManager(['client' => $client, 'prefix' => 'tiger/prod']); + $this->assertTrue($a->delete('x')); + $this->assertSame('deleteSecret', $client->calls[0][0]); + } + + #[Test] + public function is_dormant_without_the_aws_sdk(): void + { + if (class_exists('Aws\\SecretsManager\\SecretsManagerClient')) { + $this->markTestSkipped('AWS SDK present; dormant path not demonstrable.'); + } + $a = new Tiger_Secret_Adapter_SecretsManager([]); + $this->assertFalse($a->available()); + $this->assertNull($a->get('x')); + } +} diff --git a/tests/Unit/Secret/Adapter/SsmTest.php b/tests/Unit/Secret/Adapter/SsmTest.php new file mode 100644 index 0000000..5c4bd19 --- /dev/null +++ b/tests/Unit/Secret/Adapter/SsmTest.php @@ -0,0 +1,111 @@ +calls[] = ['getParameter', $args]; + $name = $args['Name']; + return ['Parameter' => ['Value' => $this->params[$name] ?? '']]; + } + public function putParameter(array $args): array { $this->calls[] = ['putParameter', $args]; return []; } + public function deleteParameter(array $args): array { $this->calls[] = ['deleteParameter', $args]; return []; } + }; + } + + #[Test] + public function is_available_with_an_injected_client(): void + { + $a = new Tiger_Secret_Adapter_Ssm(['client' => $this->client()]); + $this->assertTrue($a->available()); + $this->assertInstanceOf(Tiger_Secret_Writer_Interface::class, $a); + } + + #[Test] + public function joins_a_relative_path_under_the_prefix(): void + { + $client = $this->client(['/tiger/prod/node/crypto_key' => 'the-key']); + $a = new Tiger_Secret_Adapter_Ssm(['client' => $client, 'prefix' => '/tiger/prod/node']); + + $this->assertSame('the-key', $a->get('crypto_key')); + $this->assertSame('/tiger/prod/node/crypto_key', $client->calls[0][1]['Name']); + $this->assertTrue($client->calls[0][1]['WithDecryption']); + } + + #[Test] + public function an_absolute_path_ignores_the_prefix(): void + { + $client = $this->client(['/other/abs/x' => 'abs-val']); + $a = new Tiger_Secret_Adapter_Ssm(['client' => $client, 'prefix' => '/tiger/prod/node']); + + $this->assertSame('abs-val', $a->get('/other/abs/x')); + } + + #[Test] + public function a_missing_or_empty_parameter_is_null(): void + { + $a = new Tiger_Secret_Adapter_Ssm(['client' => $this->client()]); // returns '' for anything + $this->assertNull($a->get('nope')); + } + + #[Test] + public function put_stores_a_securestring_and_delete_removes_it(): void + { + $client = $this->client(); + $a = new Tiger_Secret_Adapter_Ssm(['client' => $client, 'prefix' => '/tiger/prod']); + + $this->assertTrue($a->put('relay/opensrs_api_key', 'abc123')); + $put = $client->calls[0][1]; + $this->assertSame('/tiger/prod/relay/opensrs_api_key', $put['Name']); + $this->assertSame('abc123', $put['Value']); + $this->assertSame('SecureString', $put['Type']); + $this->assertTrue($put['Overwrite']); + + $this->assertTrue($a->delete('relay/opensrs_api_key')); + $this->assertSame('deleteParameter', $client->calls[1][0]); + } + + #[Test] + public function a_throwing_client_fails_soft(): void + { + $boom = new class { + public function getParameter(array $args): array { throw new \RuntimeException('aws down'); } + }; + $a = new Tiger_Secret_Adapter_Ssm(['client' => $boom]); + $this->assertNull($a->get('x')); + } + + #[Test] + public function is_dormant_without_the_aws_sdk(): void + { + if (class_exists('Aws\\Ssm\\SsmClient')) { + $this->markTestSkipped('AWS SDK is installed in this environment; the dormant path cannot be shown.'); + } + $a = new Tiger_Secret_Adapter_Ssm([]); // no injected client, no SDK + $this->assertFalse($a->available()); + $this->assertNull($a->get('anything')); + $this->assertFalse($a->put('x', 'y')); + } +} diff --git a/tests/Unit/Secret/SecretTest.php b/tests/Unit/Secret/SecretTest.php new file mode 100644 index 0000000..f5365d9 --- /dev/null +++ b/tests/Unit/Secret/SecretTest.php @@ -0,0 +1,181 @@ +assertSame('just-a-plain-key', Tiger_Secret::resolve('just-a-plain-key')); + } + + #[Test] + public function an_empty_value_resolves_to_empty_string(): void + { + $this->assertSame('', Tiger_Secret::resolve('')); + $this->assertSame('', Tiger_Secret::resolve(null)); + } + + #[Test] + public function a_value_with_an_unknown_scheme_is_treated_as_plaintext(): void + { + // An https URL or a user:pass string must not be mistaken for a reference. + $this->assertSame('https://store.example/x', Tiger_Secret::resolve('https://store.example/x')); + $this->assertSame('user:pass@host', Tiger_Secret::resolve('user:pass@host')); + } + + #[Test] + public function enc_scheme_decrypts_an_inline_tiger_crypto_blob(): void + { + $this->setCryptoConfig(); + $blob = Tiger_Crypto::encrypt('s3cr3t-value'); + + $this->assertSame('s3cr3t-value', Tiger_Secret::resolve('enc:' . $blob)); + // get() reaches the same backend explicitly. + $this->assertSame('s3cr3t-value', Tiger_Secret::get('enc:' . $blob)); + } + + #[Test] + public function enc_scheme_without_a_crypto_key_resolves_to_null_not_fatal(): void + { + // No crypto config in the registry -> Local adapter is unavailable. + $this->assertNull(Tiger_Secret::resolve('enc:not-decryptable')); + } + + #[Test] + public function env_scheme_resolves_an_environment_variable(): void + { + putenv('TIGER_SECRET_TEST_ENV=from-env'); + try { + $this->assertSame('from-env', Tiger_Secret::resolve('env:TIGER_SECRET_TEST_ENV')); + $this->assertNull(Tiger_Secret::resolve('env:TIGER_SECRET_TEST_MISSING')); + } finally { + putenv('TIGER_SECRET_TEST_ENV'); + } + } + + #[Test] + public function file_scheme_reads_a_file_and_strips_a_trailing_newline(): void + { + $tmp = tempnam(sys_get_temp_dir(), 'tsec'); + file_put_contents($tmp, "file-secret\n"); + try { + $this->assertSame('file-secret', Tiger_Secret::resolve('file:' . $tmp)); + } finally { + @unlink($tmp); + } + $this->assertNull(Tiger_Secret::resolve('file:/no/such/path/tiger-secret')); + } + + #[Test] + public function a_bare_name_resolves_against_the_configured_default_driver(): void + { + $this->setConfig(['tiger' => ['secret' => ['driver' => 'env']]]); + putenv('TIGER_SECRET_BARE=bare-value'); + try { + $this->assertSame('bare-value', Tiger_Secret::get('TIGER_SECRET_BARE')); + } finally { + putenv('TIGER_SECRET_BARE'); + } + } + + #[Test] + public function a_registered_custom_adapter_handles_its_scheme(): void + { + Tiger_Secret::register('vault', $this->fixedAdapter('vault-answer')); + + $this->assertTrue(Tiger_Secret::has('vault')); + $this->assertContains('vault', Tiger_Secret::schemes()); + $this->assertSame('vault-answer', Tiger_Secret::resolve('vault:secret/data/x')); + } + + #[Test] + public function successful_resolutions_are_memoized_per_process(): void + { + // An adapter that yields a different value each call proves the cache is used. + $counter = new class implements Tiger_Secret_Adapter_Interface { + public int $calls = 0; + public function available(): bool { return true; } + public function get(string $path): ?string { $this->calls++; return 'v' . $this->calls; } + }; + Tiger_Secret::register('count', $counter); + + $this->assertSame('v1', Tiger_Secret::resolve('count:x')); + $this->assertSame('v1', Tiger_Secret::resolve('count:x')); // cached, not v2 + $this->assertSame(1, $counter->calls); + + // A new registration clears the cache. + Tiger_Secret::register('count', $counter); + $this->assertSame('v2', Tiger_Secret::resolve('count:x')); + $this->assertSame(2, $counter->calls); + } + + #[Test] + public function an_adapter_that_throws_fails_soft_to_null(): void + { + $boom = new class implements Tiger_Secret_Adapter_Interface { + public function available(): bool { return true; } + public function get(string $path): ?string { throw new \RuntimeException('kaboom'); } + }; + Tiger_Secret::register('boom', $boom); + + $this->assertNull(Tiger_Secret::resolve('boom:x')); + } + + #[Test] + public function an_unavailable_adapter_is_skipped(): void + { + $down = new class implements Tiger_Secret_Adapter_Interface { + public function available(): bool { return false; } + public function get(string $path): ?string { return 'should-not-be-reached'; } + }; + Tiger_Secret::register('down', $down); + + $this->assertNull(Tiger_Secret::resolve('down:x')); + } + + #[Test] + public function get_on_an_empty_reference_is_null(): void + { + $this->assertNull(Tiger_Secret::get('')); + } + + /** A trivial always-available adapter returning a fixed value. */ + private function fixedAdapter(string $value): Tiger_Secret_Adapter_Interface + { + return new class($value) implements Tiger_Secret_Adapter_Interface { + public function __construct(private string $value) {} + public function available(): bool { return true; } + public function get(string $path): ?string { return $this->value; } + }; + } +} From cb2eec87ec693738ecf6f039598d94ce34cd2779 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sun, 11 Oct 2026 11:12:50 -0400 Subject: [PATCH 2/2] chore(release): bump Tiger_Version to 1.22.0 (Tiger_Secret key store) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- library/Tiger/Version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 811982d..210126e 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.21.0'; + const VERSION = '1.22.0'; }