From a735a94d6d37c18749c6eb407cca79157c9025df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 02:19:49 +0000 Subject: [PATCH 01/30] Add %env() config resolution + symfony/dotenv for Docker hosting Resolve Symfony-style %env(...)% references inside app.json / environment.json at load time so secrets can come from the process environment, Docker/Kubernetes secrets, or a .env file instead of being stored in the config files. Fully backwards compatible: a file with no "%env(" substring takes a byte-for-byte identical path. - New service \gcgov\framework\services\environment: - envVarResolver: recursive resolver over the decoded config tree. Whole-value refs yield typed results (int/bool/float/array/stdClass/ string); embedded refs are string-substituted. Processors (applied right-to-left): string, bool, not, int, float, trim, file, base64, json, default. `file` reads the file at the variable's value (the Docker-secrets pattern). `default` is a literal, innermost, greedy fallback (documented deviation from Symfony) so colons are legal. Env lookup: $_ENV -> $_SERVER (excluding HTTP_*) -> getenv(). - dotEnvLoader: idempotent symfony/dotenv wrapper; loads {root}/.env then .env.local (real environment always wins); usePutenv so getenv() sites observe values. No APP_ENV cascade. - environmentException: neutral, wrapped per layer. - Wire the three config choke points (config::setAppConfig/ setEnvironmentConfig, appContext::loadEnvironmentConfig): load .env, resolve, and rethrow environmentException as configException / cliException naming the offending variable. - tokenReplacer: add conf, template, yml, yaml, example extensions so `gf setup` replaces {app_*} tokens in Docker/nginx files. - composer.json: require symfony/dotenv ^7.1. - Tests for the resolver, the dotenv loader, and the two extended CLI suites. Docs: readme/environment-variables.md plus CLAUDE.md, gf.md, README.md updates. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JWacdhEDN9upUvYeagNKru --- CLAUDE.md | 18 + README.md | 6 + composer.json | 1 + readme/environment-variables.md | 182 ++++++++++ readme/gf.md | 16 +- src/cli/appContext.php | 11 +- src/cli/tokenReplacer.php | 2 +- src/config.php | 22 +- src/services/environment/dotEnvLoader.php | 70 ++++ src/services/environment/envVarResolver.php | 312 ++++++++++++++++++ .../environment/environmentException.php | 18 + tests/Unit/Cli/AppContextTest.php | 34 ++ tests/Unit/Cli/TokenReplacerTest.php | 22 ++ .../Services/Environment/DotEnvLoaderTest.php | 105 ++++++ .../Environment/EnvVarResolverTest.php | 230 +++++++++++++ 15 files changed, 1043 insertions(+), 6 deletions(-) create mode 100644 readme/environment-variables.md create mode 100644 src/services/environment/dotEnvLoader.php create mode 100644 src/services/environment/envVarResolver.php create mode 100644 src/services/environment/environmentException.php create mode 100644 tests/Unit/Services/Environment/DotEnvLoaderTest.php create mode 100644 tests/Unit/Services/Environment/EnvVarResolverTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 30478b1..f92250b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,24 @@ returning group keys, and tag constraints with `groups: [...]`. `config::getAppDir()`, `getRootDir()`, `getConfigDir()`, `getModelsDir()`, `getSrvDir()`, `getTempDir()` all work without setup. Config DTOs are `jsonDeserialize`-hydrated from the two JSON files. +### Environment variables in config — `%env(...)%` +Both JSON files (and the gf CLI's `environment-{variant}.json`) support **Symfony-style +`%env(...)%` references**, resolved at load time by +`\gcgov\framework\services\environment\envVarResolver` (see `readme/environment-variables.md`). +This keeps secrets out of the committed/gitignored config and lets them come from the process +environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hosting. +- **Fully backwards compatible**: a file with no `%env(` substring is loaded byte-for-byte as + before. You opt in by writing `%env(...)%`. +- Whole-value ref → typed result (`"%env(int:SMTP_PORT)%"` → `587`); embedded ref → string + substitution. Processors (right-to-left): `string,bool,not,int,float,trim,file,base64,json,default`. +- `file` reads the file at the variable's value (Docker secrets: `%env(trim:file:MONGO_URI_FILE)%`). +- `default:` is a **literal** fallback (deviation from Symfony), must be innermost, greedy + argument so colons are legal: `%env(default:mongodb://mongodb:27017:MONGO_URI)%`. +- `.env` loading (via `symfony/dotenv`, `dotEnvLoader::loadOnce()`): `{root}/.env` then + `.env.local`; **real environment always wins** over both. No `APP_ENV` cascade — env + selection stays with `gf env `. +- Missing required var → `configException` (runtime) / `cliException` (gf), naming the variable. + **`app.json`** → `\gcgov\framework\models\appConfig`: ```jsonc { diff --git a/README.md b/README.md index f0de15b..e501c52 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ Required configuration files: If either file is missing, the framework throws a config exception during request handling. +Both config files support **Symfony-style `%env(...)%` environment-variable references**, so +secrets (Mongo URIs, client secrets, SMTP credentials) can be injected from the process +environment, Docker/Kubernetes secrets, or a `.env` file instead of being stored in the files. +Existing plain-JSON config keeps working unchanged. See +**[readme/environment-variables.md](readme/environment-variables.md)**. + ## System Architecture ### Application File System diff --git a/composer.json b/composer.json index c213f99..536d956 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "symfony/property-access": "^7.1", "symfony/console": "^7.1", "symfony/process": "^7.1", + "symfony/dotenv": "^7.1", "zircote/swagger-php": "^6.1", "hybridauth/hybridauth": "^3.13", "swaggest/json-diff": "^3.11", diff --git a/readme/environment-variables.md b/readme/environment-variables.md new file mode 100644 index 0000000..9087163 --- /dev/null +++ b/readme/environment-variables.md @@ -0,0 +1,182 @@ +# Environment variables in config (`%env(...)%`) + +`gcgov/framework` can resolve **environment variables** inside your JSON config files +(`app/config/app.json` and `app/config/environment.json`) at load time. This lets you keep +secrets — Mongo URIs, Microsoft client secrets, SMTP/PayJunction credentials — **out of the +config files entirely** and inject them from the process environment, Docker/Kubernetes +secrets, or a local `.env` file. This is what makes the framework hostable in Docker (see the +app template's `DOCKER.md`). + +The syntax is intentionally **Symfony-compatible** (`%env(processor:VAR)%`), but the resolver +is a small standalone class in the framework +(`\gcgov\framework\services\environment\envVarResolver`) — it is **not** coupled to Symfony's +dependency-injection container. + +--- + +## Backwards compatibility + +**Existing config files keep working unchanged.** A config file that contains no `%env(` +substring takes a byte-for-byte identical path through the loader (including today's +malformed-JSON error behavior). You only opt in by writing `%env(...)%` somewhere in the file. + +> **BC edge case (documented):** because `%env(` is now meaningful, a config *value* that needs +> to contain the literal text `%env(...)%` can no longer be stored verbatim. There is no known +> usage of such a value. + +--- + +## Where it applies + +Resolution runs at the three points where the framework reads config JSON: + +| Source | Loader | +|--------|--------| +| `app/config/app.json` | `\gcgov\framework\config::getAppConfig()` | +| `app/config/environment.json` | `\gcgov\framework\config::getEnvironmentConfig()` | +| `environment-{variant}.json` | the `gf` CLI (`appContext::loadEnvironmentConfig()`) | + +Untyped config regions (`appDictionary`, plugin `clientParams`, etc.) are resolved too — the +resolver walks the whole decoded tree. + +A failed resolution (e.g. a required variable is missing) throws: +- a `configException` (HTTP 500) at request time, or +- a `cliException` from `gf`, + +each with a message naming the offending variable and the source file. + +--- + +## Syntax + +``` +%env(PROCESSOR:...:VAR_NAME)% +``` + +- The **last** `:`-delimited segment is the environment variable name + (`[A-Za-z_][A-Za-z0-9_]*`). +- Preceding segments form a **processor chain applied right-to-left** (Symfony order): + `%env(trim:file:DB_PASS_FILE)%` = `trim( file( env(DB_PASS_FILE) ) )`. + +### Typed vs. embedded + +- **Whole-value reference** — when the entire JSON string is a single `%env(...)%`, the + **typed** result replaces the value (int/bool/float/array/stdClass/string): + + ```jsonc + "SMTPPort": "%env(int:SMTP_PORT)%" // → 587 (an integer, not "587") + "uri": "%env(MONGO_URI)%" // → "mongodb+srv://…" (a string) + ``` + +- **Embedded reference** — when `%env(...)%` appears inside a larger string, its result is + substituted as a **string**. A non-scalar embedded result (e.g. `json:`) throws. + + ```jsonc + "baseUrl": "https://%env(SERVER_NAME)%/api" + ``` + +--- + +## Environment lookup precedence + +For each variable the resolver looks in, in order: + +1. `$_ENV` +2. `$_SERVER` — **excluding `HTTP_*` keys** (request headers can never satisfy an env + reference) +3. `getenv()` + +A variable that is *set but empty* resolves to `''`. A variable that is genuinely **unset** +triggers the `default:` fallback if present, otherwise an error. + +### `.env` files + +Before resolving, the framework loads (once per process, if present): + +``` +{app-root}/.env then {app-root}/.env.local +``` + +via `symfony/dotenv`. Precedence, highest wins: + +``` +real process environment > .env.local > .env +``` + +The **real environment always wins** — dotenv never overrides a variable already present in +the process environment. There is no `APP_ENV` cascade; environment selection stays with +`gf env ` copying the right `environment-{name}.json` into place. + +Keep `.env` / `.env.local` **out of version control** (the app template gitignores them and +ships a committed `.env.example`). + +--- + +## Processors + +| Processor | Effect | +|-----------|--------| +| `string` | Cast to string. | +| `bool` | Truthy → `true` (`1/true/yes/on`), else `false`. | +| `not` | Boolean negation of `bool`. | +| `int` | Cast to integer (errors on a non-numeric value). | +| `float` | Cast to float (errors on a non-numeric value). | +| `trim` | Trim surrounding whitespace. | +| `file` | **Read the file whose path is the variable's value** — the Docker/Kubernetes secrets pattern. | +| `base64` | Base64-decode (URL-safe tolerant; padding optional). | +| `json` | JSON-decode into an array/object/scalar. | +| `default` | Literal fallback when the variable is unset (see below). | + +Chains apply right-to-left. The canonical Docker-secret read: + +```jsonc +"uri": "%env(trim:file:MONGO_URI_FILE)%" +``` + +`MONGO_URI_FILE=/run/secrets/mongo_uri` → read that file → trim the trailing newline → use the +contents as the Mongo URI. + +--- + +## The `default` processor (deliberate deviation from Symfony) + +Unlike Symfony — where `default:` names a fallback **parameter** — here `default` provides a +**literal** fallback value. Rules: + +- It must be **innermost** (closest to the variable name). +- Its argument is **greedy**: everything between `default:` and the final `:VAR`, so **colons + are legal** in the fallback. +- The fallback applies **only when the variable is unset** (a set-but-empty variable wins). + +```jsonc +// dev-safe fallback that itself contains colons: +"uri": "%env(default:mongodb://mongodb:27017:MONGO_URI)%" + +// empty-string fallback: +"clientSecret": "%env(default::MICROSOFT_CLIENT_SECRET)%" + +// composed with another processor (default is still innermost): +"SMTPPort": "%env(int:default:587:SMTP_PORT)%" // → int 587 when SMTP_PORT is unset +``` + +Use `default:` in **local/dev** config for a smooth `docker compose up`; omit it in **prod** +config so a missing variable fails loudly, naming exactly what to set: + +```jsonc +// environment-prod.json — no defaults; fail fast: +"uri": "%env(MONGO_URI)%", +"clientSecret": "%env(MICROSOFT_CLIENT_SECRET)%" + +// …or, preferring file-based secrets: +"uri": "%env(trim:file:MONGO_URI_FILE)%" +``` + +--- + +## Why file-based secrets are preferred + +Process environment variables are visible to anyone who can run `docker inspect` on the +container, and can leak into logs and crash dumps. A **Docker/Swarm/Kubernetes secret** mounted +as a file at `/run/secrets/` and read with `%env(trim:file:_FILE)%` keeps the +secret value off the process environment entirely. See the app template's `DOCKER.md` for the +full deployment guidance. diff --git a/readme/gf.md b/readme/gf.md index f772fd7..f532942 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -293,5 +293,17 @@ Files an app can delete once migrated: `app/cli/local.bat`, `app/cli/local-debug `db/restore-live-to-local.ps1`, `update-production.ps1` — and `app/cli/index.php` once no scheduler entry references it (gf ships its own route runner). -Move any secrets that were hardcoded in those scripts into the environment variant config -files (`environment-{env}.json`), which the `db:*` commands read. +Reference any secrets that were hardcoded in those scripts via `%env(...)%` in the environment +variant config files (`environment-{env}.json`) — the `db:*` commands and the request lifecycle +both resolve them. Keep the actual values in the process environment, Docker/Kubernetes secrets, +or a gitignored `.env` file. See **[Environment variables in config](environment-variables.md)**. + +For example, instead of a plaintext URI in `environment-prod.json`: + +```jsonc +"uri": "%env(MONGO_URI)%" // fail loud if unset +"uri": "%env(trim:file:MONGO_URI_FILE)%" // …or read a Docker secret file +``` + +`.env` files (`{app-root}/.env`, then `.env.local`) are loaded automatically before config is +resolved; the real process environment always wins over them. diff --git a/src/cli/appContext.php b/src/cli/appContext.php index 66a3fd9..a19a945 100644 --- a/src/cli/appContext.php +++ b/src/cli/appContext.php @@ -165,8 +165,17 @@ public function loadEnvironmentConfig( string $variant = '' ): environmentConfig throw new cliException( 'Missing environment config file: ' . $file . '.' . $hint ); } + \gcgov\framework\services\environment\dotEnvLoader::loadOnce( $this->rootDir ); + + try { + $json = \gcgov\framework\services\environment\envVarResolver::resolveJson( (string)file_get_contents( $file ), $file ); + } + catch( \gcgov\framework\services\environment\environmentException $e ) { + throw new cliException( 'Failed to resolve environment variables in ' . $file . ': ' . $e->getMessage(), 0, $e ); + } + try { - return environmentConfig::jsonDeserialize( (string)file_get_contents( $file ) ); + return environmentConfig::jsonDeserialize( $json ); } catch( \andrewsauder\jsonDeserialize\exceptions\jsonDeserializeException $e ) { throw new cliException( 'Failed to parse ' . $file . ': ' . $e->getMessage(), 0, $e ); diff --git a/src/cli/tokenReplacer.php b/src/cli/tokenReplacer.php index 4620d73..35a8983 100644 --- a/src/cli/tokenReplacer.php +++ b/src/cli/tokenReplacer.php @@ -9,7 +9,7 @@ final class tokenReplacer { /** File extensions eligible for token replacement */ - public const array EXTENSIONS = [ 'ini', 'json', 'php', 'config', 'bat', 'ps1' ]; + public const array EXTENSIONS = [ 'ini', 'json', 'php', 'config', 'bat', 'ps1', 'conf', 'template', 'yml', 'yaml', 'example' ]; /** Directory names never descended into. Note srv/ is deliberately INCLUDED in replacement: * the scaffold's per-environment php.ini files (srv/app.{env}[-cli]/php.ini) carry tokens. */ diff --git a/src/config.php b/src/config.php index 197b511..e277ddf 100644 --- a/src/config.php +++ b/src/config.php @@ -146,7 +146,16 @@ private static function setAppConfig(): void { if( !file_exists( $appConfigFile ) ) { throw new \gcgov\framework\exceptions\configException( 'Missing app config file at ' . $appConfigFile ); } - self::$appConfig = appConfig::jsonDeserialize( file_get_contents( $appConfigFile ) ); + + \gcgov\framework\services\environment\dotEnvLoader::loadOnce( self::getRootDir() ); + try { + $json = \gcgov\framework\services\environment\envVarResolver::resolveJson( (string)file_get_contents( $appConfigFile ), $appConfigFile ); + } + catch( \gcgov\framework\services\environment\environmentException $e ) { + throw new \gcgov\framework\exceptions\configException( $e->getMessage(), 500, $e ); + } + + self::$appConfig = appConfig::jsonDeserialize( $json ); } @@ -172,7 +181,16 @@ private static function setEnvironmentConfig(): void { if( !file_exists( $environmentConfigFile ) ) { throw new \gcgov\framework\exceptions\configException( 'Missing environment config file at ' . $environmentConfigFile ); } - self::$environmentConfig = environmentConfig::jsonDeserialize( file_get_contents( $environmentConfigFile ) ); + + \gcgov\framework\services\environment\dotEnvLoader::loadOnce( self::getRootDir() ); + try { + $json = \gcgov\framework\services\environment\envVarResolver::resolveJson( (string)file_get_contents( $environmentConfigFile ), $environmentConfigFile ); + } + catch( \gcgov\framework\services\environment\environmentException $e ) { + throw new \gcgov\framework\exceptions\configException( $e->getMessage(), 500, $e ); + } + + self::$environmentConfig = environmentConfig::jsonDeserialize( $json ); } } diff --git a/src/services/environment/dotEnvLoader.php b/src/services/environment/dotEnvLoader.php new file mode 100644 index 0000000..2b8443f --- /dev/null +++ b/src/services/environment/dotEnvLoader.php @@ -0,0 +1,70 @@ + .env.local > .env. + * The real container/process environment always wins — Symfony's Dotenv never + * overrides variables that are already present in the environment. `usePutenv()` + * is enabled so that call sites reading through `getenv()` (e.g. `GF_PHP` in the + * gf CLI) also observe values loaded from .env files. + * + * There is deliberately no APP_ENV cascade: environment selection stays with + * gf's env-file copying (`gf env `), not with dotenv. + */ +final class dotEnvLoader { + + /** Root directories already processed, so loading is a no-op on repeat calls. */ + private static array $loadedRoots = []; + + + /** + * Load {root}/.env then {root}/.env.local when present. No-op when neither + * exists or when this root has already been loaded in the current process. + */ + public static function loadOnce( string $rootDir ): void { + $rootDir = rtrim( str_replace( '\\', '/', $rootDir ), '/' ); + + if( isset( self::$loadedRoots[ $rootDir ] ) ) { + return; + } + self::$loadedRoots[ $rootDir ] = true; + + $envFile = $rootDir . '/.env'; + if( !file_exists( $envFile ) ) { + // Nothing to load; still mark as processed so we don't re-stat every call. + return; + } + + $dotenv = new Dotenv(); + $dotenv->usePutenv(); + + // load() reads .env and, when present, .env.local — never overriding real + // environment variables that are already set. + $files = [ $envFile ]; + $localFile = $rootDir . '/.env.local'; + if( file_exists( $localFile ) ) { + $files[] = $localFile; + } + + $dotenv->load( ...$files ); + } + + + /** + * Reset the idempotency cache. Intended for test isolation only. + * + * @internal + */ + public static function resetForTesting(): void { + self::$loadedRoots = []; + } + +} diff --git a/src/services/environment/envVarResolver.php b/src/services/environment/envVarResolver.php new file mode 100644 index 0000000..179f7d7 --- /dev/null +++ b/src/services/environment/envVarResolver.php @@ -0,0 +1,312 @@ + $value ) { + $node->$key = self::resolveNode( $value, $sourceDescription ); + } + + return $node; + } + + if( is_array( $node ) ) { + return array_map( static fn( $value ) => self::resolveNode( $value, $sourceDescription ), $node ); + } + + if( is_string( $node ) ) { + return self::resolveString( $node, $sourceDescription ); + } + + return $node; + } + + + /** + * Resolve `%env(...)%` occurrences in a single string leaf. + * + * @return mixed Typed value when the whole string is one reference; a string otherwise. + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function resolveString( string $value, string $sourceDescription ): mixed { + if( !str_contains( $value, '%env(' ) ) { + return $value; + } + + // Whole-string reference → typed result. + if( preg_match( '/^%env\(([^)]+)\)%$/', $value, $matches )===1 ) { + return self::resolveExpression( $matches[ 1 ], $sourceDescription ); + } + + // Embedded reference(s) → string substitution. + $result = preg_replace_callback( '/%env\(([^)]+)\)%/', static function( array $matches ) use ( $sourceDescription ): string { + $resolved = self::resolveExpression( $matches[ 1 ], $sourceDescription ); + if( is_bool( $resolved ) ) { + return $resolved ? 'true' : 'false'; + } + if( $resolved===null ) { + return ''; + } + if( is_scalar( $resolved ) ) { + return (string)$resolved; + } + throw new environmentException( 'Cannot embed non-scalar environment value for %env(' . $matches[ 1 ] . ')% inside a larger string in ' . $sourceDescription . '. Reference it as the whole value ("%env(...)%") instead.' ); + }, $value ); + + return $result ?? $value; + } + + + /** + * Resolve one `%env(...)%` expression (the text between the parentheses). + * + * @return mixed + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function resolveExpression( string $expression, string $sourceDescription ): mixed { + $lastColon = strrpos( $expression, ':' ); + if( $lastColon===false ) { + $varName = $expression; + $processorSpec = ''; + } + else { + $varName = substr( $expression, $lastColon + 1 ); + $processorSpec = substr( $expression, 0, $lastColon ); + } + + if( preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $varName )!==1 ) { + throw new environmentException( 'Invalid environment variable reference "%env(' . $expression . ')%" in ' . $sourceDescription . ': "' . $varName . '" is not a valid variable name.' ); + } + + // Parse the processor chain left-to-right (outer → inner). `default` is greedy: + // it consumes the remainder of the spec as its literal fallback and is innermost. + $processors = []; + $default = null; + $remainingSpec = $processorSpec; + while( $remainingSpec!=='' ) { + $colon = strpos( $remainingSpec, ':' ); + $token = $colon===false ? $remainingSpec : substr( $remainingSpec, 0, $colon ); + $rest = $colon===false ? '' : substr( $remainingSpec, $colon + 1 ); + + if( $token==='default' ) { + $default = $rest; + $remainingSpec = ''; + break; + } + + $processors[] = $token; + $remainingSpec = $rest; + } + + // Environment lookup (with optional literal default fallback). + $raw = self::lookupEnv( $varName ); + if( $raw===null ) { + if( $default===null ) { + throw new environmentException( 'Required environment variable "' . $varName . '" is not set (referenced as "%env(' . $expression . ')%" in ' . $sourceDescription . '). Set it in the process environment, a Docker secret, or a .env file.' ); + } + $value = $default; + } + else { + $value = $raw; + } + + // Apply processors right-to-left (inner → outer). + foreach( array_reverse( $processors ) as $processor ) { + $value = self::applyProcessor( $processor, $value, $expression, $sourceDescription ); + } + + return $value; + } + + + /** + * @param mixed $value + * + * @return mixed + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function applyProcessor( string $processor, mixed $value, string $expression, string $sourceDescription ): mixed { + switch( $processor ) { + case 'string': + return (string)$value; + + case 'bool': + return self::toBool( $value ); + + case 'not': + return !self::toBool( $value ); + + case 'int': + if( !is_numeric( trim( (string)$value ) ) ) { + throw new environmentException( 'Cannot apply "int" to non-numeric value for "%env(' . $expression . ')%" in ' . $sourceDescription . '.' ); + } + + return (int)$value; + + case 'float': + if( !is_numeric( trim( (string)$value ) ) ) { + throw new environmentException( 'Cannot apply "float" to non-numeric value for "%env(' . $expression . ')%" in ' . $sourceDescription . '.' ); + } + + return (float)$value; + + case 'trim': + return trim( (string)$value ); + + case 'file': + $path = (string)$value; + if( !is_file( $path ) || !is_readable( $path ) ) { + throw new environmentException( 'Cannot apply "file" for "%env(' . $expression . ')%" in ' . $sourceDescription . ': file "' . $path . '" does not exist or is not readable.' ); + } + $contents = file_get_contents( $path ); + if( $contents===false ) { + throw new environmentException( 'Cannot apply "file" for "%env(' . $expression . ')%" in ' . $sourceDescription . ': failed reading "' . $path . '".' ); + } + + return $contents; + + case 'base64': + // URL-safe tolerant: accept the URL-safe alphabet and missing padding. + $normalized = strtr( (string)$value, '-_', '+/' ); + $padding = strlen( $normalized ) % 4; + if( $padding>0 ) { + $normalized .= str_repeat( '=', 4 - $padding ); + } + $decoded = base64_decode( $normalized, true ); + if( $decoded===false ) { + throw new environmentException( 'Cannot apply "base64" for "%env(' . $expression . ')%" in ' . $sourceDescription . ': value is not valid base64.' ); + } + + return $decoded; + + case 'json': + $decoded = json_decode( (string)$value, false ); + if( json_last_error()!==JSON_ERROR_NONE ) { + throw new environmentException( 'Cannot apply "json" for "%env(' . $expression . ')%" in ' . $sourceDescription . ': ' . json_last_error_msg() . '.' ); + } + + return $decoded; + + default: + throw new environmentException( 'Unknown environment processor "' . $processor . '" in "%env(' . $expression . ')%" (' . $sourceDescription . '). Supported: string, bool, not, int, float, trim, file, base64, json, default.' ); + } + } + + + /** + * @param mixed $value + */ + private static function toBool( mixed $value ): bool { + $bool = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); + if( $bool===null ) { + return (bool)$value; + } + + return $bool; + } + + + /** + * Look up an environment variable value. + * Precedence: $_ENV → $_SERVER (excluding HTTP_* request headers) → getenv(). + * Returns null only when the variable is genuinely unset (a set-but-empty + * variable resolves to ''). + */ + private static function lookupEnv( string $name ): ?string { + if( array_key_exists( $name, $_ENV ) ) { + return (string)$_ENV[ $name ]; + } + + if( !str_starts_with( $name, 'HTTP_' ) && array_key_exists( $name, $_SERVER ) ) { + return (string)$_SERVER[ $name ]; + } + + $value = getenv( $name ); + if( $value!==false ) { + return $value; + } + + return null; + } + +} diff --git a/src/services/environment/environmentException.php b/src/services/environment/environmentException.php new file mode 100644 index 0000000..d3dd71b --- /dev/null +++ b/src/services/environment/environmentException.php @@ -0,0 +1,18 @@ +loadEnvironmentConfig(); } + + public function testLoadEnvironmentConfigResolvesEnvVars(): void { + $_ENV[ 'TEST_MONGO_URI' ] = 'mongodb://resolved:27017/widgets'; + putenv( 'TEST_MONGO_URI=mongodb://resolved:27017/widgets' ); + try { + file_put_contents( $this->tempRootDir . '/app/config/environment-docker.json', json_encode( [ + 'type' => 'prod', + 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MONGO_URI)%' ] ], + ] ) ); + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); + $environmentConfig = $context->loadEnvironmentConfig( 'docker' ); + $this->assertSame( 'mongodb://resolved:27017/widgets', $environmentConfig->mongoDatabases[ 0 ]->uri ); + } + finally { + unset( $_ENV[ 'TEST_MONGO_URI' ] ); + putenv( 'TEST_MONGO_URI' ); + } + } + + + public function testLoadEnvironmentConfigThrowsCliExceptionWhenEnvVarMissing(): void { + unset( $_ENV[ 'TEST_MISSING_URI' ] ); + putenv( 'TEST_MISSING_URI' ); + file_put_contents( $this->tempRootDir . '/app/config/environment-docker.json', json_encode( [ + 'type' => 'prod', + 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MISSING_URI)%' ] ], + ] ) ); + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); + $this->expectException( cliException::class ); + $context->loadEnvironmentConfig( 'docker' ); + } + public function testGetEnvironmentVariantsListsVariantFiles(): void { touch( $this->tempRootDir . '/app/config/environment-local.json' ); touch( $this->tempRootDir . '/app/config/environment-prod.json' ); diff --git a/tests/Unit/Cli/TokenReplacerTest.php b/tests/Unit/Cli/TokenReplacerTest.php index 1d7a082..98780d3 100644 --- a/tests/Unit/Cli/TokenReplacerTest.php +++ b/tests/Unit/Cli/TokenReplacerTest.php @@ -85,6 +85,28 @@ public function testEmptyValuesAreSkipped(): void { $this->assertSame( '{"title":"{app_title}"}', file_get_contents( $this->tempRootDir . '/a.json' ) ); } + public function testDockerTemplateExtensionsAreEligible(): void { + file_put_contents( $this->tempRootDir . '/default.conf.template', 'server_name {app_server_name};' ); + file_put_contents( $this->tempRootDir . '/docker-compose.yml', 'image: {app_title}' ); + file_put_contents( $this->tempRootDir . '/config.yaml', 'title: {app_title}' ); + file_put_contents( $this->tempRootDir . '/.env.example', 'APP_TITLE={app_title}' ); + file_put_contents( $this->tempRootDir . '/nginx.conf', 'root {app_absolute_path};' ); + + $modified = tokenReplacer::replaceInTree( $this->tempRootDir, [ + '{app_server_name}' => 'api.example.com', + '{app_title}' => 'Widget API', + '{app_absolute_path}' => '/var/www/api', + ] ); + + $this->assertContains( str_replace( '\\', '/', $this->tempRootDir ) . '/default.conf.template', $modified ); + $this->assertContains( str_replace( '\\', '/', $this->tempRootDir ) . '/docker-compose.yml', $modified ); + $this->assertContains( str_replace( '\\', '/', $this->tempRootDir ) . '/config.yaml', $modified ); + $this->assertContains( str_replace( '\\', '/', $this->tempRootDir ) . '/.env.example', $modified ); + $this->assertContains( str_replace( '\\', '/', $this->tempRootDir ) . '/nginx.conf', $modified ); + $this->assertStringContainsString( 'server_name api.example.com;', (string)file_get_contents( $this->tempRootDir . '/default.conf.template' ) ); + } + + public function testFormatRelativeUrl(): void { $this->assertSame( '/api/', tokenReplacer::formatRelativeUrl( 'api' ) ); $this->assertSame( '/api/', tokenReplacer::formatRelativeUrl( '/api/' ) ); diff --git a/tests/Unit/Services/Environment/DotEnvLoaderTest.php b/tests/Unit/Services/Environment/DotEnvLoaderTest.php new file mode 100644 index 0000000..69258c9 --- /dev/null +++ b/tests/Unit/Services/Environment/DotEnvLoaderTest.php @@ -0,0 +1,105 @@ + */ + private array $envSnapshot = []; + + /** @var array */ + private array $serverSnapshot = []; + + private string $tempDir = ''; + + /** @var string[] */ + private array $introducedKeys = [ 'DOTENV_TEST_A', 'DOTENV_TEST_B', 'DOTENV_TEST_REAL' ]; + + + protected function setUp(): void { + $this->envSnapshot = $_ENV; + $this->serverSnapshot = $_SERVER; + $this->tempDir = sys_get_temp_dir() . '/gcgov-dotenv-test-' . uniqid(); + mkdir( $this->tempDir, 0777, true ); + dotEnvLoader::resetForTesting(); + foreach( $this->introducedKeys as $key ) { + putenv( $key ); + unset( $_ENV[ $key ], $_SERVER[ $key ] ); + } + } + + + protected function tearDown(): void { + foreach( $this->introducedKeys as $key ) { + putenv( $key ); + unset( $_ENV[ $key ], $_SERVER[ $key ] ); + } + $_ENV = $this->envSnapshot; + $_SERVER = $this->serverSnapshot; + dotEnvLoader::resetForTesting(); + + $this->deleteDirectory( $this->tempDir ); + } + + + public function testLoadsEnvFile(): void { + file_put_contents( $this->tempDir . '/.env', "DOTENV_TEST_A=from_env\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertSame( 'from_env', $_ENV[ 'DOTENV_TEST_A' ] ?? null ); + $this->assertSame( 'from_env', getenv( 'DOTENV_TEST_A' ) ); + } + + + public function testRealEnvironmentWins(): void { + putenv( 'DOTENV_TEST_REAL=real_value' ); + $_ENV[ 'DOTENV_TEST_REAL' ] = 'real_value'; + file_put_contents( $this->tempDir . '/.env', "DOTENV_TEST_REAL=dotenv_value\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertSame( 'real_value', $_ENV[ 'DOTENV_TEST_REAL' ] ); + } + + + public function testLocalOverridesBaseEnv(): void { + file_put_contents( $this->tempDir . '/.env', "DOTENV_TEST_B=base\n" ); + file_put_contents( $this->tempDir . '/.env.local', "DOTENV_TEST_B=local\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertSame( 'local', $_ENV[ 'DOTENV_TEST_B' ] ); + } + + + public function testIsIdempotent(): void { + file_put_contents( $this->tempDir . '/.env', "DOTENV_TEST_A=first\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + // Rewriting the file and reloading must NOT change the already-loaded value. + file_put_contents( $this->tempDir . '/.env', "DOTENV_TEST_A=second\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertSame( 'first', $_ENV[ 'DOTENV_TEST_A' ] ); + } + + + public function testNoOpWhenAbsent(): void { + // No .env file present — must not throw and must not set anything. + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertArrayNotHasKey( 'DOTENV_TEST_A', $_ENV ); + } + + + private function deleteDirectory( string $directory ): void { + if( !is_dir( $directory ) ) { + return; + } + $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); + foreach( $iterator as $file ) { + $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $directory ); + } + +} diff --git a/tests/Unit/Services/Environment/EnvVarResolverTest.php b/tests/Unit/Services/Environment/EnvVarResolverTest.php new file mode 100644 index 0000000..f1ac08e --- /dev/null +++ b/tests/Unit/Services/Environment/EnvVarResolverTest.php @@ -0,0 +1,230 @@ + */ + private array $envSnapshot = []; + + /** @var array */ + private array $serverSnapshot = []; + + private string $tempDir = ''; + + + protected function setUp(): void { + $this->envSnapshot = $_ENV; + $this->serverSnapshot = $_SERVER; + $this->tempDir = sys_get_temp_dir() . '/gcgov-envresolver-test-' . uniqid(); + mkdir( $this->tempDir, 0777, true ); + } + + + protected function tearDown(): void { + // Unset any variables the tests introduced before restoring snapshots. + foreach( array_keys( $_ENV ) as $key ) { + if( !array_key_exists( $key, $this->envSnapshot ) ) { + putenv( $key ); + } + } + $_ENV = $this->envSnapshot; + $_SERVER = $this->serverSnapshot; + + $this->deleteDirectory( $this->tempDir ); + } + + + private function setEnv( string $name, string $value ): void { + $_ENV[ $name ] = $value; + putenv( $name . '=' . $value ); + } + + + public function testFastPathReturnsIdenticalStringWhenNoEnvReference(): void { + $json = '{"type":"prod","serverName":"api.example.com","port":8080}'; + $this->assertSame( $json, envVarResolver::resolveJson( $json, 'test' ) ); + } + + + public function testFastPathPreservesMalformedJson(): void { + $json = '{ this is not valid json '; + $this->assertSame( $json, envVarResolver::resolveJson( $json, 'test' ) ); + } + + + public function testInvalidJsonWithEnvReferenceIsPassedThrough(): void { + // Contains %env( so it leaves the fast path, but is not decodable → raw string back. + $json = '{ "uri": "%env(MONGO_URI)%" '; + $this->assertSame( $json, envVarResolver::resolveJson( $json, 'test' ) ); + } + + + public function testWholeStringResolvesToTypedString(): void { + $this->setEnv( 'MONGO_URI', 'mongodb://db:27017' ); + $result = envVarResolver::resolveJson( '{"uri":"%env(MONGO_URI)%"}', 'test' ); + $this->assertInstanceOf( \stdClass::class, $result ); + $this->assertSame( 'mongodb://db:27017', $result->uri ); + } + + + public function testIntProcessorYieldsInteger(): void { + $this->setEnv( 'SMTP_PORT', '2525' ); + $result = envVarResolver::resolveJson( '{"port":"%env(int:SMTP_PORT)%"}', 'test' ); + $this->assertIsInt( $result->port ); + $this->assertSame( 2525, $result->port ); + } + + + public function testFloatProcessorYieldsFloat(): void { + $this->setEnv( 'RATE', '1.5' ); + $result = envVarResolver::resolveJson( '{"rate":"%env(float:RATE)%"}', 'test' ); + $this->assertIsFloat( $result->rate ); + $this->assertSame( 1.5, $result->rate ); + } + + + public function testBoolAndNotProcessors(): void { + $this->setEnv( 'FLAG', 'true' ); + $result = envVarResolver::resolveJson( '{"on":"%env(bool:FLAG)%","off":"%env(not:FLAG)%"}', 'test' ); + $this->assertTrue( $result->on ); + $this->assertFalse( $result->off ); + } + + + public function testTrimProcessor(): void { + $this->setEnv( 'PADDED', " spaced \n" ); + $result = envVarResolver::resolveJson( '{"v":"%env(trim:PADDED)%"}', 'test' ); + $this->assertSame( 'spaced', $result->v ); + } + + + public function testJsonProcessorYieldsStructure(): void { + $this->setEnv( 'ROLES', '["a","b"]' ); + $result = envVarResolver::resolveJson( '{"roles":"%env(json:ROLES)%"}', 'test' ); + $this->assertSame( [ 'a', 'b' ], $result->roles ); + } + + + public function testBase64ProcessorUrlSafeTolerant(): void { + // URL-safe base64 of "secret?" without padding + $this->setEnv( 'SECRET_B64', 'c2VjcmV0Pw' ); + $result = envVarResolver::resolveJson( '{"s":"%env(base64:SECRET_B64)%"}', 'test' ); + $this->assertSame( 'secret?', $result->s ); + } + + + public function testEmbeddedReferenceIsStringSubstituted(): void { + $this->setEnv( 'HOST', 'db.internal' ); + $this->setEnv( 'PORT', '27017' ); + $result = envVarResolver::resolveJson( '{"uri":"mongodb://%env(HOST)%:%env(PORT)%/app"}', 'test' ); + $this->assertSame( 'mongodb://db.internal:27017/app', $result->uri ); + } + + + public function testEmbeddedNonScalarThrows(): void { + $this->setEnv( 'ROLES', '["a"]' ); + $this->expectException( environmentException::class ); + envVarResolver::resolveJson( '{"v":"prefix-%env(json:ROLES)%"}', 'test' ); + } + + + public function testDefaultWithColonsInValue(): void { + // Var unset → greedy default containing colons is used. + $result = envVarResolver::resolveJson( '{"uri":"%env(default:mongodb://mongodb:27017:MONGO_URI)%"}', 'test' ); + $this->assertSame( 'mongodb://mongodb:27017', $result->uri ); + } + + + public function testDefaultEmptyValue(): void { + $result = envVarResolver::resolveJson( '{"secret":"%env(default::MICROSOFT_CLIENT_SECRET)%"}', 'test' ); + $this->assertSame( '', $result->secret ); + } + + + public function testDefaultIsIgnoredWhenVariableIsSet(): void { + $this->setEnv( 'MONGO_URI', 'mongodb://real:27017' ); + $result = envVarResolver::resolveJson( '{"uri":"%env(default:mongodb://fallback:27017:MONGO_URI)%"}', 'test' ); + $this->assertSame( 'mongodb://real:27017', $result->uri ); + } + + + public function testComposedIntDefault(): void { + $result = envVarResolver::resolveJson( '{"port":"%env(int:default:587:SMTP_PORT)%"}', 'test' ); + $this->assertIsInt( $result->port ); + $this->assertSame( 587, $result->port ); + } + + + public function testTrimFileChainReadsSecretFile(): void { + $secretFile = $this->tempDir . '/mongo_uri'; + file_put_contents( $secretFile, "mongodb://secret:27017\n" ); + $this->setEnv( 'MONGO_URI_FILE', $secretFile ); + $result = envVarResolver::resolveJson( '{"uri":"%env(trim:file:MONGO_URI_FILE)%"}', 'test' ); + $this->assertSame( 'mongodb://secret:27017', $result->uri ); + } + + + public function testMissingVariableMessageContainsNameAndSource(): void { + try { + envVarResolver::resolveJson( '{"uri":"%env(MONGO_URI)%"}', '/app/config/environment.json' ); + $this->fail( 'Expected environmentException' ); + } + catch( environmentException $e ) { + $this->assertStringContainsString( 'MONGO_URI', $e->getMessage() ); + $this->assertStringContainsString( '/app/config/environment.json', $e->getMessage() ); + } + } + + + public function testUnknownProcessorThrows(): void { + $this->setEnv( 'X', 'y' ); + $this->expectException( environmentException::class ); + envVarResolver::resolveJson( '{"v":"%env(bogus:X)%"}', 'test' ); + } + + + public function testNestedAppDictionaryResolution(): void { + $this->setEnv( 'CRON_URL', 'https://monitor.example.com/hook' ); + $this->setEnv( 'MAX_ITEMS', '25' ); + $json = '{"appDictionary":{"cronMonitorUrl":"%env(CRON_URL)%","limits":{"maxItems":"%env(int:MAX_ITEMS)%"}}}'; + $result = envVarResolver::resolveJson( $json, 'test' ); + $this->assertSame( 'https://monitor.example.com/hook', $result->appDictionary->cronMonitorUrl ); + $this->assertSame( 25, $result->appDictionary->limits->maxItems ); + } + + + public function testServerHttpKeysAreNotUsedForLookup(): void { + // A malicious request header must not satisfy an env reference. + $_SERVER[ 'HTTP_MONGO_URI' ] = 'mongodb://attacker'; + $this->expectException( environmentException::class ); + try { + envVarResolver::resolveJson( '{"uri":"%env(HTTP_MONGO_URI)%"}', 'test' ); + } + finally { + unset( $_SERVER[ 'HTTP_MONGO_URI' ] ); + } + } + + + private function deleteDirectory( string $directory ): void { + if( !is_dir( $directory ) ) { + return; + } + $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); + foreach( $iterator as $file ) { + $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $directory ); + } + +} From 827850e4b5a3c9283e369202ffa78caa685ca348 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 12:57:24 +0000 Subject: [PATCH 02/30] v7: remove committed environment-*.json variant dependency from gf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Environment selection is now environment-variable driven: apps commit a single app/config/environment.json parameterized with %env(...), and the process environment (container env, Docker secrets, .env) IS the environment. BREAKING — the file-copy activation machinery is removed. - envVarResolver::resolveJson() gains an overlayVars parameter: overlay > $_ENV > $_SERVER (non-HTTP_*) > getenv(); a variable missing from the overlay falls back to the ambient lookup. - dotEnvLoader::parseFile(): parse a dotenv file to an array without mutating the process environment (Symfony FormatException -> neutral environmentException). - appContext::loadEnvironmentConfig($variant): variant reads now resolve the committed environment.json with the gitignored app/config/{variant}.env overlay (new getEnvironmentOverlayPath / describeEnvironmentConfigSource helpers). The legacy environment-{variant}.json read path is removed; a leftover legacy file triggers an error pointing at the migration guide. getEnvironmentVariants() globs app/config/*.env (excludes *.env.example). - environmentFiles is deleted. gf env no longer copies files: bare `gf env` lists variants and validates the active environment; `gf env ` resolves and validates a variant overlay (exit 1 naming the first unresolvable variable). gf deploy drops the --env option and the activation step. - db:restore hardening for env-resolved config: --to=prod refused by variant NAME regardless of resolved type; new findIdenticalPairs() guard refuses a pair whose source and target resolve to the same uri+database (the incomplete-overlay signature); type guard message names the overlay source. db:run/db:restore help text updated. - gf setup prompts only for {token}s actually present in the tree (filterPromptsToPresentTokens/tokensForPromptKey), skips the Microsoft confirm when no Microsoft tokens remain, and no longer suggests `gf env local`. - Tests updated/replaced to pin the new model; docs rewritten (gf.md incl. "Migrating a v6 app to v7", environment-variables.md overlay section, README.md scaffold tree, CLAUDE.md). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JWacdhEDN9upUvYeagNKru --- CLAUDE.md | 24 ++-- README.md | 24 ++-- readme/environment-variables.md | 42 +++++-- readme/gf.md | 89 ++++++++++---- src/cli/appContext.php | 70 +++++++++-- src/cli/commands/dbRestoreCommand.php | 38 +++++- src/cli/commands/dbRunCommand.php | 2 +- src/cli/commands/deployCommand.php | 15 +-- src/cli/commands/envCommand.php | 62 +++++++--- src/cli/commands/setupCommand.php | 60 ++++++++- src/cli/environmentFiles.php | 63 ---------- src/cli/routeCatalog.php | 2 +- src/services/environment/dotEnvLoader.php | 34 ++++- src/services/environment/envVarResolver.php | 58 ++++++--- tests/Unit/Cli/AppContextTest.php | 116 ++++++++++++++++-- tests/Unit/Cli/CommandsTest.php | 60 ++++++++- tests/Unit/Cli/DbRestoreCommandTest.php | 24 ++++ tests/Unit/Cli/EnvironmentFilesTest.php | 79 ------------ .../Services/Environment/DotEnvLoaderTest.php | 24 ++++ .../Environment/EnvVarResolverTest.php | 41 +++++++ 20 files changed, 643 insertions(+), 284 deletions(-) delete mode 100644 src/cli/environmentFiles.php delete mode 100644 tests/Unit/Cli/EnvironmentFilesTest.php diff --git a/CLAUDE.md b/CLAUDE.md index f92250b..6149990 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -326,21 +326,24 @@ returning group keys, and tag constraints with `groups: [...]`. work without setup. Config DTOs are `jsonDeserialize`-hydrated from the two JSON files. ### Environment variables in config — `%env(...)%` -Both JSON files (and the gf CLI's `environment-{variant}.json`) support **Symfony-style -`%env(...)%` references**, resolved at load time by +Both JSON files support **Symfony-style `%env(...)%` references**, resolved at load time by `\gcgov\framework\services\environment\envVarResolver` (see `readme/environment-variables.md`). -This keeps secrets out of the committed/gitignored config and lets them come from the process +This keeps secrets out of the committed config and lets them come from the process environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hosting. -- **Fully backwards compatible**: a file with no `%env(` substring is loaded byte-for-byte as - before. You opt in by writing `%env(...)%`. +- A file with no `%env(` substring is loaded byte-for-byte as before. You opt in by writing `%env(...)%`. - Whole-value ref → typed result (`"%env(int:SMTP_PORT)%"` → `587`); embedded ref → string substitution. Processors (right-to-left): `string,bool,not,int,float,trim,file,base64,json,default`. - `file` reads the file at the variable's value (Docker secrets: `%env(trim:file:MONGO_URI_FILE)%`). - `default:` is a **literal** fallback (deviation from Symfony), must be innermost, greedy argument so colons are legal: `%env(default:mongodb://mongodb:27017:MONGO_URI)%`. - `.env` loading (via `symfony/dotenv`, `dotEnvLoader::loadOnce()`): `{root}/.env` then - `.env.local`; **real environment always wins** over both. No `APP_ENV` cascade — env - selection stays with `gf env `. + `.env.local`; **real environment always wins** over both. No `APP_ENV` cascade — an + environment IS the variable set the process is given; nothing is activated or copied (v7). +- gf variant reads (`db:restore --from=prod`, `db:run --env=prod`, `gf env prod`) resolve the + same committed `environment.json` with a gitignored `app/config/{variant}.env` **overlay** + (parsed via `dotEnvLoader::parseFile()`, precedence: overlay > real env > `.env.local` > + `.env` > `default:`). Overlays must define every environment-specific variable — missing ones + silently fall back to local values; validate with `gf env `. - Missing required var → `configException` (runtime) / `cliException` (gf), naming the variable. **`app.json`** → `\gcgov\framework\models\appConfig`: @@ -500,10 +503,13 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea `src/cli/chromeInstaller.php` (injectable Guzzle client — tests are network-free). - **Architecture** (`src/cli/`): `application` (command registration + provider discovery), `appContext` (app-root locator: composer autoload path first, then cwd walk-up; lazy config - access via `loadEnvironmentConfig($variant)` — never boots the request lifecycle), + access via `loadEnvironmentConfig($variant)` — resolves `environment.json`, applying the + `app/config/{variant}.env` overlay when a variant is named; never boots the request lifecycle), `routeCatalog` (CLI-route enumeration via `router::getMergedRoutes()`), `phpProcess`, - `environmentFiles`, `tokenReplacer`, `mongoTools`, `cliException` (user-facing errors), + `tokenReplacer`, `mongoTools`, `cliException` (user-facing errors), `internal/run-route.php` (child-process route runner; maps response status ≥400 → exit 1). + `gf env` validates config resolution (it stopped copying files in v7); `gf setup` prompts only + for `{token}`s present in the tree (`setupCommand::filterPromptsToPresentTokens`). - **Command tiers**: no context (list/help/completion — must work anywhere, including this repo); root-only (env, db:*, cert:*, deploy, setup — config JSON only, no `\app` boot); app-boot (cli, cli:list — `assertAppLoadable()`; `\app\app::_before()` is deliberately NOT called). diff --git a/README.md b/README.md index e501c52..8b71e03 100644 --- a/README.md +++ b/README.md @@ -85,20 +85,16 @@ automatically start with some extra folders and tools. │... ├── www │ │... -│ ├── web.config -│ ├── web-local.config -│ └── web-prod.config ├── app │ │... │ └── config -│ └── environment-local.json -│ └── environment-prod.json -├── scripts -│ ├── create-jwt-keys.ps1 -│ └── setup.ps1 +│ ├── app.json +│ ├── environment.json # committed; secrets/per-env values via %env(...) +│ └── prod.env.example # copy to prod.env (gitignored) for gf db:*/env variant reads +├── docker +│ └── nginx +│ └── default.conf.template ├── srv -│ ├── {env} -│ │ └── php.ini │ ├── tmp │ │ ├── files │ │ ├── opcache @@ -107,11 +103,11 @@ automatically start with some extra folders and tools. │ │ └── tmp │ └── jwtCertificates ├── db -│ ├── backup -│ ├── restore-live-to-local.ps1 │ └── local-createuser.js ├── logs -└── update-production.ps1 +├── .env.example # copy to .env (gitignored) for local development +├── Dockerfile +└── docker-compose.yml ``` ### Core Files and Application Namespacing @@ -227,7 +223,7 @@ gf cli:list # list the app's CLI routes gf cert:generate-auth # JWT signing keys (replaces create-jwt-keys.ps1) gf db:restore --from=prod # pull a source env's mongo dbs into the local env gf db:run db/script.js # run a mongosh script with config-managed credentials -gf env local # activate environment config file variants +gf env prod # validate that the prod.env overlay fully resolves environment.json gf setup # bootstrap a scaffolded app (replaces setup.ps1) gf deploy # tag-based deployment (replaces update-production.ps1) ``` diff --git a/readme/environment-variables.md b/readme/environment-variables.md index 9087163..ee52f84 100644 --- a/readme/environment-variables.md +++ b/readme/environment-variables.md @@ -34,7 +34,7 @@ Resolution runs at the three points where the framework reads config JSON: |--------|--------| | `app/config/app.json` | `\gcgov\framework\config::getAppConfig()` | | `app/config/environment.json` | `\gcgov\framework\config::getEnvironmentConfig()` | -| `environment-{variant}.json` | the `gf` CLI (`appContext::loadEnvironmentConfig()`) | +| `environment.json` + `app/config/{variant}.env` overlay | the `gf` CLI (`appContext::loadEnvironmentConfig($variant)`) — see "Per-variant overlay files" below | Untyped config regions (`appDictionary`, plugin `clientParams`, etc.) are resolved too — the resolver walks the whole decoded tree. @@ -104,8 +104,9 @@ real process environment > .env.local > .env ``` The **real environment always wins** — dotenv never overrides a variable already present in -the process environment. There is no `APP_ENV` cascade; environment selection stays with -`gf env ` copying the right `environment-{name}.json` into place. +the process environment. There is no `APP_ENV` cascade: an "environment" is simply the set of +variable values the process is given — a prod container gets prod values from its runtime +environment/secrets, a dev machine gets dev values from `.env`. Nothing is activated or copied. Keep `.env` / `.env.local` **out of version control** (the app template gitignores them and ships a committed `.env.example`). @@ -159,12 +160,15 @@ Unlike Symfony — where `default:` names a fallback **parameter** — here `def "SMTPPort": "%env(int:default:587:SMTP_PORT)%" // → int 587 when SMTP_PORT is unset ``` -Use `default:` in **local/dev** config for a smooth `docker compose up`; omit it in **prod** -config so a missing variable fails loudly, naming exactly what to set: +With a single committed `environment.json`, the split is per **value**, not per file: give +`default:` fallbacks only to non-secret dev conveniences (identity URLs, a local `type`), and +leave secrets and database coordinates as **hard references** so a misconfigured prod container +fails loudly, naming exactly what to set — dev covers them via `.env` (`cp .env.example .env`): ```jsonc -// environment-prod.json — no defaults; fail fast: -"uri": "%env(MONGO_URI)%", +// app/config/environment.json — one file for every environment: +"type": "%env(default:local:APP_TYPE)%", // dev-safe default; prod sets APP_TYPE=prod +"uri": "%env(MONGO_URI)%", // hard: fail fast when unset "clientSecret": "%env(MICROSOFT_CLIENT_SECRET)%" // …or, preferring file-based secrets: @@ -173,6 +177,30 @@ config so a missing variable fails loudly, naming exactly what to set: --- +## Per-variant overlay files (gf CLI) + +The gf CLI sometimes needs a **foreign** environment's values without activating anything — +`gf db:restore --from=prod` must resolve prod's Mongo URI while your shell holds local values. +That is what per-variant overlay files are for: a gitignored dotenv file +`app/config/{variant}.env` (e.g. `app/config/prod.env`; start from the app template's +`prod.env.example`). `appContext::loadEnvironmentConfig('prod')` resolves the committed +`environment.json` with that file's variables applied on top. Precedence for such a read: + +``` +{variant}.env overlay > real environment > .env.local > .env > default: fallback +``` + +Two things to know: + +- **An overlay must define every environment-specific variable.** A variable missing from the + overlay falls back to your *local* value silently. `gf env ` validates that an overlay + fully resolves `environment.json`, and `db:restore` refuses a pair whose source and target + resolve to the same database — but neither catches everything. +- Overlay files are parsed with `dotEnvLoader::parseFile()` — they are **never loaded into the + process environment** and never affect the running app; only the one gf resolution sees them. + +--- + ## Why file-based secrets are preferred Process environment variables are visible to anyone who can run `docker inspect` on the diff --git a/readme/gf.md b/readme/gf.md index f532942..680a208 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -27,7 +27,7 @@ spelling also works — `gf db restore` resolves to `db:restore` automatically. | `gf chrome:status` | — | Show whether chrome-headless-shell is installed and what version | | `gf db:restore` | `db/restore-live-to-local.ps1` | Copy a source environment's mongo databases into a target environment | | `gf db:run ` | ad-hoc `mongosh "" script.js` | Run a mongosh script using config-managed connections | -| `gf env ` | manual `Copy-Item` steps | Activate an environment's config file variants | +| `gf env []` | manual `Copy-Item` steps | List environment variants; validate that config resolves (active env or a `{env}.env` overlay) | | `gf setup` | `scripts/setup.ps1` | Bootstrap a freshly scaffolded application | | `gf deploy` | `update-production.ps1` | Tag-based production deployment | | `gf completion` / `gf completion:powershell` | — | Shell tab completion | @@ -147,12 +147,21 @@ installation exists. The `chrome-php/chrome` library is a framework dependency, ## Databases: `gf db:restore` and `gf db:run` -Connection strings come from the environment variant config files -(`app/config/environment-{env}.json` → `mongoDatabases[]`) — never hardcode credentials in -scripts again. +Connection strings come from `app/config/environment.json` (`mongoDatabases[]`) — never +hardcode credentials in scripts again. A variant name (`--from=prod`, `--env=prod`) resolves +that same `environment.json` with the variables from the gitignored overlay file +`app/config/{name}.env` applied on top of your local environment (see +[Environments](#environments-gf-env) below): + +```ini +# app/config/prod.env (gitignored; start from prod.env.example) +APP_TYPE=prod +MONGO_URI=mongodb+srv://user:pass@prod-cluster/ +MONGO_DATABASE=app +``` ``` -gf db:restore # dump prod -> restore into the active environment.json (--drop) +gf db:restore # dump prod -> restore into the active environment (--drop) gf db:restore --from=prod --to=local gf db:restore --db=AppsSchedule # only the named database(s) gf db:restore --keep-dump --dump-dir=db/backup @@ -160,7 +169,11 @@ gf db:restore --keep-dump --dump-dir=db/backup - Source/target databases are paired by database name (falling back to the two `default` entries); differing names are remapped with `--nsFrom/--nsTo`. -- Restoring **into** an environment whose `type` is `prod` is refused unless `--allow-prod`. +- Restoring **into** the variant named `prod`, or into an environment whose resolved `type` is + `prod`, is refused unless `--allow-prod`. +- A pair whose source and target resolve to the **same uri and database** is refused outright — + that almost always means an incomplete `{name}.env` overlay silently fell back to your local + values. Validate the overlay first with `gf env prod`. - Requires the [MongoDB Database Tools](https://www.mongodb.com/try/download/database-tools) (`mongodump`, `mongorestore`) on PATH. - The plan (with passwords redacted) is shown and confirmed before anything runs; `--yes` skips. @@ -177,14 +190,37 @@ Requires [mongosh](https://www.mongodb.com/try/download/shell) on PATH. ## Environments: `gf env` +Environment selection is **environment-variable driven**: the committed +`app/config/environment.json` references variables with `%env(...)%`, and whichever values the +process environment (container env, Docker secrets, or `{root}/.env`) supplies *are* the +environment. There is nothing to activate or copy. + +`gf env` is the validator for that model: + ``` -gf env local # environment-local.json -> environment.json, - # composer-local.json -> composer.json, - # www/web-local.config -> www/web.config -gf env prod --dry-run +gf env # list app/config/*.env variants + validate the ACTIVE environment +gf env prod # resolve environment.json with the app/config/prod.env overlay and validate it ``` -Missing variant files are skipped with a note; it is an error only if no variant exists at all. +`gf env ` prints the resolved summary (type, serverName, urls, databases with redacted +URIs) and exits non-zero naming the first unresolvable variable. Run it before trusting a +variant with `db:restore`/`db:run` — a variable missing from the overlay silently falls back to +your local value, so **an overlay file must define every environment-specific variable**. + +### Migrating a v6 app to v7 + +v6's committed `environment-{env}.json` variants and the `gf env` copy step are gone. To move +an app onto v7: + +1. Commit a single `app/config/environment.json` (remove it from `.gitignore`) with every + secret and every per-environment value referenced via `%env(...)%` — see + [environment-variables.md](environment-variables.md) and the app template's copy. +2. For each old variant, create a gitignored `app/config/{env}.env` holding that environment's + variable values (start from the template's `prod.env.example`); gitignore + `app/config/*.env`. Local values go in `{root}/.env` (from `.env.example`). +3. Delete `environment-{env}.json`, `composer-{env}.json`, and `www/web-{env}.config`; commit + `composer.json` (and a static `www/web.config`, if the app still runs on IIS). +4. Bump `gcgov/framework` to `^v7.0`; verify with `gf env` and `gf env prod`. --- @@ -208,15 +244,17 @@ there — offline machine, missing php-zip — only prints a warning and never f Cross-platform replacement for the per-app `update-production.ps1`: ``` -gf deploy # interactive tag picker, env=prod +gf deploy # interactive tag picker gf deploy --tag=v2.4.1 --yes # non-interactive -gf deploy --env=local --no-composer +gf deploy --no-composer ``` Steps: `git fetch/pull` → pick a tag (newest first, `--tags=N` to widen) → confirm → -`git checkout tags/` → `git submodule sync/update` → `gf env ` copy step → write +`git checkout tags/` → `git submodule sync/update` → write `version.json` (`{"version": "", "inherit": true}`) → `composer update`. -Any failing step aborts the deploy with that step's exit code. +Any failing step aborts the deploy with that step's exit code. Configuration is committed +(`environment.json` + `%env()` values from the server's environment), so there is no +config-activation step. --- @@ -230,7 +268,8 @@ Any failing step aborts the deploy with that step's exit code. ``` Completion is dynamic: `gf cli ` suggests the application's actual CLI routes (with -descriptions), `gf env ` suggests the environment variants present in `app/config/`. +descriptions), `gf env ` (and `db:restore --from=` etc.) suggests the variant overlay +files (`app/config/*.env`) present in the app. --- @@ -266,8 +305,8 @@ down gf itself (run with `-v` to see discovery errors). Useful helpers for custom commands (all in `\gcgov\framework\cli`): - `appContext::require()` / `appContext::locate()` — application root + config access -- `appContext->loadEnvironmentConfig($variant)` — parse an environment variant file -- `environmentFiles::apply($root, $env)` — the `gf env` copy step +- `appContext->loadEnvironmentConfig($variant)` — resolve `environment.json` (with the `{variant}.env` overlay when a variant is named) +- `dotEnvLoader::parseFile($path)` — parse a dotenv file to an array without touching the process env - `mongoTools::findBinary()/redactUri()/uriWithDatabase()` - `phpProcess::findPhpBinary()/requiredIniFlags()/xdebugFlags()` - throw `cliException` for user-facing errors @@ -286,19 +325,21 @@ Useful helpers for custom commands (all in `\gcgov\framework\cli`): | `db\restore-live-to-local.ps1` | `vendor/bin/gf db:restore --from=prod` | | `mongosh "mongodb://user:pass@..." db\fix.js` | `vendor/bin/gf db:run db/fix.js --env=prod` | | `update-production.ps1` | `vendor/bin/gf deploy` | -| `Copy-Item composer-local.json composer.json` (+ 2 more) | `vendor/bin/gf env local` | +| `Copy-Item composer-local.json composer.json` (+ 2 more) | nothing — config is committed and environment-variable driven (v7); `gf env` validates it | Files an app can delete once migrated: `app/cli/local.bat`, `app/cli/local-debug.bat`, `app/cli/prod.bat`, `scripts/setup.ps1`, `scripts/create-jwt-keys.ps1`, `db/restore-live-to-local.ps1`, `update-production.ps1` — and `app/cli/index.php` once no scheduler entry references it (gf ships its own route runner). -Reference any secrets that were hardcoded in those scripts via `%env(...)%` in the environment -variant config files (`environment-{env}.json`) — the `db:*` commands and the request lifecycle -both resolve them. Keep the actual values in the process environment, Docker/Kubernetes secrets, -or a gitignored `.env` file. See **[Environment variables in config](environment-variables.md)**. +Reference any secrets that were hardcoded in those scripts via `%env(...)%` in the committed +`app/config/environment.json` — the `db:*` commands and the request lifecycle both resolve +them. Keep the actual values in the process environment, Docker/Kubernetes secrets, or a +gitignored `.env` file (per-variant values for the `db:*` commands go in gitignored +`app/config/{env}.env` overlays). See **[Environment variables in config](environment-variables.md)** +and **[Migrating a v6 app to v7](#migrating-a-v6-app-to-v7)** above. -For example, instead of a plaintext URI in `environment-prod.json`: +For example, instead of a plaintext URI: ```jsonc "uri": "%env(MONGO_URI)%" // fail loud if unset diff --git a/src/cli/appContext.php b/src/cli/appContext.php index a19a945..63ab170 100644 --- a/src/cli/appContext.php +++ b/src/cli/appContext.php @@ -153,25 +153,53 @@ public function getServiceNamespaces(): array { /** - * Parse app/config/environment{-$variant}.json directly — no \app boot, no ext-mongodb. - * $variant '' loads the active environment.json. + * Parse app/config/environment.json directly — no \app boot, no ext-mongodb. + * + * $variant '' → resolve against the ambient environment ({root}/.env is loaded + * first; the real process environment wins). + * $variant 'name' → resolve the SAME environment.json with the variables from + * app/config/{name}.env applied as an overlay that takes precedence + * over the ambient environment — a foreign-environment read (used by + * db:restore/db:run/env) without activating anything. Variables + * missing from the overlay fall back to ambient values, so overlay + * files should define every environment-specific variable. * * @throws \gcgov\framework\cli\cliException */ public function loadEnvironmentConfig( string $variant = '' ): environmentConfig { - $file = $this->getEnvironmentConfigPath( $variant ); + $file = $this->getEnvironmentConfigPath(); + $legacyFile = $this->getConfigDir() . '/environment-' . $variant . '.json'; + $legacyHint = $variant!=='' && file_exists( $legacyFile ) + ? ' A legacy ' . basename( $legacyFile ) . ' exists — this framework version reads variant values from app/config/{name}.env overlay files instead; see readme/gf.md "Migrating a v6 app to v7".' + : ''; + if( !file_exists( $file ) ) { - $hint = $variant==='' ? ' Run `gf env ` to activate an environment first.' : ''; - throw new cliException( 'Missing environment config file: ' . $file . '.' . $hint ); + throw new cliException( 'Missing environment config file: ' . $file . '. Commit an environment.json that references environment variables with %env(...) and supply values via the process environment or a .env file.' . $legacyHint ); + } + + $overlayVars = []; + $source = $file; + if( $variant!=='' ) { + $overlayPath = $this->getEnvironmentOverlayPath( $variant ); + if( !file_exists( $overlayPath ) ) { + throw new cliException( 'Missing environment overlay file: ' . $overlayPath . '. Create it with the "' . $variant . '" environment\'s variable values (see app/config/prod.env.example in the app template).' . $legacyHint ); + } + try { + $overlayVars = \gcgov\framework\services\environment\dotEnvLoader::parseFile( $overlayPath ); + } + catch( \gcgov\framework\services\environment\environmentException $e ) { + throw new cliException( $e->getMessage(), 0, $e ); + } + $source = $this->describeEnvironmentConfigSource( $variant ); } \gcgov\framework\services\environment\dotEnvLoader::loadOnce( $this->rootDir ); try { - $json = \gcgov\framework\services\environment\envVarResolver::resolveJson( (string)file_get_contents( $file ), $file ); + $json = \gcgov\framework\services\environment\envVarResolver::resolveJson( (string)file_get_contents( $file ), $source, $overlayVars ); } catch( \gcgov\framework\services\environment\environmentException $e ) { - throw new cliException( 'Failed to resolve environment variables in ' . $file . ': ' . $e->getMessage(), 0, $e ); + throw new cliException( 'Failed to resolve environment variables in ' . $source . ': ' . $e->getMessage(), 0, $e ); } try { @@ -183,22 +211,38 @@ public function loadEnvironmentConfig( string $variant = '' ): environmentConfig } - public function getEnvironmentConfigPath( string $variant = '' ): string { - $suffix = $variant==='' ? '' : '-' . $variant; + public function getEnvironmentConfigPath(): string { + return $this->getConfigDir() . '/environment.json'; + } + + + /** The per-variant overlay env file read by loadEnvironmentConfig($variant). */ + public function getEnvironmentOverlayPath( string $variant ): string { + return $this->getConfigDir() . '/' . $variant . '.env'; + } + + + /** Human-readable description of where a variant's config comes from, for error/guard messages. */ + public function describeEnvironmentConfigSource( string $variant = '' ): string { + if( $variant==='' ) { + return $this->getEnvironmentConfigPath(); + } - return $this->getConfigDir() . '/environment' . $suffix . '.json'; + return $this->getEnvironmentConfigPath() . ' (overlay: ' . $this->getEnvironmentOverlayPath( $variant ) . ')'; } /** - * Environment variant names available in app/config (environment-{name}.json). + * Environment variant names available in app/config ({name}.env overlay files). + * glob's `*` does not match a leading dot, and `*.env` does not match `*.env.example`, + * so a stray `.env` or the committed example file never appear as variants. * * @return string[] */ public function getEnvironmentVariants(): array { $variants = []; - foreach( glob( $this->getConfigDir() . '/environment-*.json' ) ?: [] as $file ) { - $variants[] = substr( basename( $file, '.json' ), strlen( 'environment-' ) ); + foreach( glob( $this->getConfigDir() . '/*.env' ) ?: [] as $file ) { + $variants[] = basename( $file, '.env' ); } sort( $variants ); diff --git a/src/cli/commands/dbRestoreCommand.php b/src/cli/commands/dbRestoreCommand.php index 32484f7..df2932f 100644 --- a/src/cli/commands/dbRestoreCommand.php +++ b/src/cli/commands/dbRestoreCommand.php @@ -19,14 +19,14 @@ final class dbRestoreCommand extends Command { protected function configure(): void { - $this->addOption( 'from', null, InputOption::VALUE_REQUIRED, 'Source environment variant (reads app/config/environment-{from}.json)', 'prod', envCommand::suggestEnvironments( ... ) ); - $this->addOption( 'to', null, InputOption::VALUE_REQUIRED, 'Target environment variant. Omit to use the active app/config/environment.json.', '', envCommand::suggestEnvironments( ... ) ); + $this->addOption( 'from', null, InputOption::VALUE_REQUIRED, 'Source environment variant (resolves app/config/environment.json with the app/config/{from}.env overlay)', 'prod', envCommand::suggestEnvironments( ... ) ); + $this->addOption( 'to', null, InputOption::VALUE_REQUIRED, 'Target environment variant (resolved with the app/config/{to}.env overlay). Omit to use the active environment.', '', envCommand::suggestEnvironments( ... ) ); $this->addOption( 'db', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Restrict to the named database(s). Repeatable. Default: every database in the source config.' ); $this->addOption( 'dump-dir', null, InputOption::VALUE_REQUIRED, 'Directory to write the mongodump output to. Default: srv/tmp/mongodump-{timestamp}.' ); $this->addOption( 'keep-dump', null, InputOption::VALUE_NONE, 'Keep the dump directory after a successful restore' ); $this->addOption( 'yes', 'y', InputOption::VALUE_NONE, 'Skip the confirmation prompt' ); $this->addOption( 'allow-prod', null, InputOption::VALUE_NONE, 'Allow restoring INTO an environment whose type is "prod" (refused otherwise)' ); - $this->setHelp( 'Cross-platform replacement for the per-app restore-live-to-local.ps1: connection strings come from the environment variant config files instead of being hardcoded. Requires the MongoDB Database Tools (mongodump/mongorestore) on PATH.' ); + $this->setHelp( 'Cross-platform replacement for the per-app restore-live-to-local.ps1: connection strings come from app/config/environment.json, resolved per variant with the app/config/{name}.env overlay files, instead of being hardcoded. Validate an overlay first with `gf env `. Requires the MongoDB Database Tools (mongodump/mongorestore) on PATH.' ); } @@ -43,14 +43,25 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $sourceConfig = $context->loadEnvironmentConfig( $fromVariant ); $targetConfig = $context->loadEnvironmentConfig( $toVariant ); + // Guard by variant NAME first: the resolved `type` comes from an env var, so an + // incomplete overlay (e.g. prod.env missing APP_TYPE) must not defeat the refusal. + if( $toVariant==='prod' && !$input->getOption( 'allow-prod' ) ) { + throw new cliException( 'Refusing to restore into the environment variant named "prod". Pass --allow-prod if you really mean it.' ); + } if( $targetConfig->type==='prod' && !$input->getOption( 'allow-prod' ) ) { - throw new cliException( 'Refusing to restore into an environment with type "prod" (' . $context->getEnvironmentConfigPath( $toVariant ) . '). Pass --allow-prod if you really mean it.' ); + throw new cliException( 'Refusing to restore into an environment with type "prod" (' . $context->describeEnvironmentConfigSource( $toVariant ) . '). Pass --allow-prod if you really mean it.' ); } $pairs = self::pairDatabases( $sourceConfig->mongoDatabases, $targetConfig->mongoDatabases, $input->getOption( 'db' ) ); if( count( $pairs[ 'matched' ] )===0 ) { throw new cliException( 'No database pairs to restore. Source config databases: ' . implode( ', ', array_map( fn( mongoDatabase $db ) => $db->database, $sourceConfig->mongoDatabases ) ) ); } + + $identicalPairs = self::findIdenticalPairs( $pairs[ 'matched' ] ); + if( count( $identicalPairs )>0 ) { + [ $sourceDb ] = $identicalPairs[ 0 ]; + throw new cliException( 'Source and target resolve to the same database (' . $sourceDb->database . ' @ ' . mongoTools::redactUri( $sourceDb->uri ) . '). If you used a {variant}.env overlay, it is probably incomplete — every environment-specific variable must be set in it (missing ones silently fall back to your local values). Validate with `gf env ' . $fromVariant . '`.' ); + } foreach( $pairs[ 'unmatched' ] as $unmatchedName ) { $io->warning( 'Source database "' . $unmatchedName . '" has no matching database in the target config — skipped.' ); } @@ -152,6 +163,25 @@ public static function pairDatabases( array $sourceDatabases, array $targetDatab } + /** + * Pairs whose source and target are the SAME database (same normalized uri AND same + * database name) — dumping and restoring onto itself is never useful and usually means + * an incomplete {variant}.env overlay fell back to the local environment's values. + * Same-cluster clones under a different database name stay legal. + * + * @param array $matchedPairs + * + * @return array + */ + public static function findIdenticalPairs( array $matchedPairs ): array { + return array_values( array_filter( $matchedPairs, function( array $pair ): bool { + [ $sourceDb, $targetDb ] = $pair; + + return rtrim( $sourceDb->uri, '/' )===rtrim( $targetDb->uri, '/' ) && $sourceDb->database===$targetDb->database; + } ) ); + } + + /** * @return string[] */ diff --git a/src/cli/commands/dbRunCommand.php b/src/cli/commands/dbRunCommand.php index 12ee826..84a8453 100644 --- a/src/cli/commands/dbRunCommand.php +++ b/src/cli/commands/dbRunCommand.php @@ -19,7 +19,7 @@ final class dbRunCommand extends Command { protected function configure(): void { $this->addArgument( 'script', InputArgument::REQUIRED, 'Path to the .js script to execute with mongosh' ); $this->addArgument( 'mongoshArgs', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Extra arguments passed through to mongosh (prefix with --)' ); - $this->addOption( 'env', null, InputOption::VALUE_REQUIRED, 'Environment variant to read the connection from (reads app/config/environment-{env}.json). Omit to use the active environment.json.', '', envCommand::suggestEnvironments( ... ) ); + $this->addOption( 'env', null, InputOption::VALUE_REQUIRED, 'Environment variant to read the connection from (resolves app/config/environment.json with the app/config/{env}.env overlay). Omit to use the active environment.', '', envCommand::suggestEnvironments( ... ) ); $this->addOption( 'db', null, InputOption::VALUE_REQUIRED, 'Database name from the mongoDatabases config to run against. Default: the entry marked default (or the only entry).' ); $this->setHelp( 'Replaces per-script mongosh invocations with hardcoded connection strings, e.g.: gf db:run db/create-admin.js --env=local. Everything after -- is forwarded to mongosh.' ); } diff --git a/src/cli/commands/deployCommand.php b/src/cli/commands/deployCommand.php index e470d59..e6efbfd 100644 --- a/src/cli/commands/deployCommand.php +++ b/src/cli/commands/deployCommand.php @@ -4,7 +4,6 @@ use gcgov\framework\cli\appContext; use gcgov\framework\cli\cliException; -use gcgov\framework\cli\environmentFiles; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -14,11 +13,10 @@ use Symfony\Component\Process\ExecutableFinder; use Symfony\Component\Process\Process; -#[AsCommand( name: 'deploy', description: 'Deploy the application: pull, check out a release tag, activate the environment config, write version.json, composer update (replaces update-production.ps1)' )] +#[AsCommand( name: 'deploy', description: 'Deploy the application: pull, check out a release tag, write version.json, composer update (replaces update-production.ps1)' )] final class deployCommand extends Command { protected function configure(): void { - $this->addOption( 'env', null, InputOption::VALUE_REQUIRED, 'Environment whose config variants to activate after checkout', 'prod', envCommand::suggestEnvironments( ... ) ); $this->addOption( 'tag', null, InputOption::VALUE_REQUIRED, 'Tag to deploy. Omit to pick interactively from the most recent tags.' ); $this->addOption( 'tags', null, InputOption::VALUE_REQUIRED, 'How many recent tags to offer in the interactive picker', '15' ); $this->addOption( 'no-composer', null, InputOption::VALUE_NONE, 'Skip the composer update step' ); @@ -48,8 +46,6 @@ protected function execute( InputInterface $input, OutputInterface $output ): in } } - $environment = (string)$input->getOption( 'env' ); - $io->section( 'Fetching' ); $this->runStep( [ $gitBinary, 'fetch', '--all', '--tags', '--prune' ], $context->rootDir, $output ); $this->runStep( [ $gitBinary, 'pull' ], $context->rootDir, $output ); @@ -69,7 +65,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $io->warning( "The working tree has uncommitted changes:\n" . $dirtyFiles ); } - if( !$input->getOption( 'yes' ) && !$io->confirm( 'Deploy tag ' . $tag . ' with environment "' . $environment . '"?', false ) ) { + if( !$input->getOption( 'yes' ) && !$io->confirm( 'Deploy tag ' . $tag . '?', false ) ) { $io->text( 'Aborted. No changes made.' ); return Command::FAILURE; @@ -83,11 +79,6 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $this->runStep( [ $gitBinary, 'submodule', 'sync', '--recursive' ], $context->rootDir, $output ); $this->runStep( [ $gitBinary, 'submodule', 'update', '--init', '--recursive' ], $context->rootDir, $output ); - $io->section( 'Activating environment "' . $environment . '"' ); - foreach( environmentFiles::apply( $context->rootDir, $environment ) as $result ) { - $io->text( $result[ 'status' ] . ( str_starts_with( $result[ 'status' ], 'skipped' ) ? '' : ': ' . $result[ 'source' ] . ' -> ' . $result[ 'target' ] ) ); - } - file_put_contents( $context->rootDir . '/version.json', json_encode( [ 'version' => $tag, 'inherit' => true ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); $io->text( 'Wrote version.json (version ' . $tag . ')' ); @@ -96,7 +87,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $this->runStep( [ $composerBinary, 'update', '--no-interaction' ], $context->rootDir, $output ); } - $io->success( 'Deployed ' . $tag . ' (' . $environment . ').' ); + $io->success( 'Deployed ' . $tag . '.' ); return Command::SUCCESS; } diff --git a/src/cli/commands/envCommand.php b/src/cli/commands/envCommand.php index 21dbb6e..60c6166 100644 --- a/src/cli/commands/envCommand.php +++ b/src/cli/commands/envCommand.php @@ -3,38 +3,70 @@ namespace gcgov\framework\cli\commands; use gcgov\framework\cli\appContext; -use gcgov\framework\cli\environmentFiles; +use gcgov\framework\cli\cliException; +use gcgov\framework\cli\mongoTools; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; -#[AsCommand( name: 'env', description: 'Activate an environment: copy environment-{env}.json, composer-{env}.json, and www/web-{env}.config to their canonical names' )] +#[AsCommand( name: 'env', description: 'List environment variants and validate that a variant\'s app/config/{name}.env overlay fully resolves app/config/environment.json' )] final class envCommand extends Command { protected function configure(): void { - $this->addArgument( 'environment', InputArgument::REQUIRED, 'Environment name, e.g. local or prod', null, self::suggestEnvironments( ... ) ); - $this->addOption( 'dry-run', null, InputOption::VALUE_NONE, 'Show what would be copied without changing any files' ); + $this->addArgument( 'environment', InputArgument::OPTIONAL, 'Variant to validate (app/config/{name}.env). Omit to list variants and check the active environment.', null, self::suggestEnvironments( ... ) ); + $this->setHelp( 'Environment selection is environment-variable driven: app/config/environment.json references variables with %env(...), and the process environment / {root}/.env supplies the values. This command validates that resolution. `gf env ` resolves environment.json with the app/config/{name}.env overlay applied — use it to prove an overlay (e.g. prod.env, used by db:restore/db:run) defines every variable it needs before relying on it.' ); } protected function execute( InputInterface $input, OutputInterface $output ): int { - $context = appContext::require(); - $environment = $input->getArgument( 'environment' ); + $context = appContext::require(); + $io = new SymfonyStyle( $input, $output ); - $results = environmentFiles::apply( $context->rootDir, $environment, (bool)$input->getOption( 'dry-run' ) ); + $environment = (string)( $input->getArgument( 'environment' ) ?? '' ); - foreach( $results as $result ) { - if( str_starts_with( $result[ 'status' ], 'skipped' ) ) { - $output->writeln( '' . $result[ 'status' ] . '' ); - } - else { - $output->writeln( '' . $result[ 'status' ] . ': ' . $result[ 'source' ] . ' -> ' . $result[ 'target' ] ); - } + if( $environment==='' ) { + $variants = $context->getEnvironmentVariants(); + $io->text( count( $variants )===0 + ? 'No variant overlay files found in app/config (create app/config/{name}.env — see prod.env.example in the app template).' + : 'Variant overlay files in app/config: ' . implode( ', ', array_map( fn( string $v ) => $v . '.env', $variants ) ) ); + + $io->section( 'Active environment (app/config/environment.json + ambient environment)' ); + + return $this->validate( $context, '', $io ); + } + + $io->section( 'Variant "' . $environment . '" (' . $context->describeEnvironmentConfigSource( $environment ) . ')' ); + + return $this->validate( $context, $environment, $io ); + } + + + private function validate( appContext $context, string $variant, SymfonyStyle $io ): int { + try { + $environmentConfig = $context->loadEnvironmentConfig( $variant ); + } + catch( cliException $e ) { + $io->error( $e->getMessage() ); + + return Command::FAILURE; + } + + $io->text( 'type: ' . $environmentConfig->type ); + if( $environmentConfig->serverName!=='' ) { + $io->text( 'serverName: ' . $environmentConfig->serverName ); + } + if( $environmentConfig->rootUrl!=='' ) { + $io->text( 'rootUrl: ' . $environmentConfig->rootUrl . ' basePath: ' . $environmentConfig->getBasePath() ); } + foreach( $environmentConfig->mongoDatabases as $mongoDatabase ) { + $io->text( 'mongo: ' . $mongoDatabase->database . ' @ ' . mongoTools::redactUri( $mongoDatabase->uri ) . ( $mongoDatabase->default ? ' (default)' : '' ) ); + } + + $io->success( 'Resolved successfully — every %env(...) reference has a value.' ); return Command::SUCCESS; } diff --git a/src/cli/commands/setupCommand.php b/src/cli/commands/setupCommand.php index 4cd0163..a43480b 100644 --- a/src/cli/commands/setupCommand.php +++ b/src/cli/commands/setupCommand.php @@ -73,9 +73,15 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $io->title( 'gcgov/framework application setup' ); $io->text( [ 'Application root: ' . $context->rootDir, 'To skip replacing a value, press enter.', '' ] ); - $prompts = self::PROMPTS; - if( $io->confirm( 'Do you want to define Microsoft Azure App ids during set up?', false ) ) { - $prompts = array_merge( $prompts, self::MICROSOFT_PROMPTS ); + // Only prompt for values whose {token} actually appears in the project tree, so + // templates that no longer carry a token (e.g. the prod_* config set) stop asking for it. + $prompts = self::filterPromptsToPresentTokens( self::PROMPTS, $context->rootDir ); + $microsoftPrompts = self::filterPromptsToPresentTokens( self::MICROSOFT_PROMPTS, $context->rootDir ); + if( count( $microsoftPrompts )>0 && $io->confirm( 'Do you want to define Microsoft Azure App ids during set up?', false ) ) { + $prompts = array_merge( $prompts, $microsoftPrompts ); + } + if( count( $prompts )===0 ) { + $io->text( 'No {placeholder} tokens found in the project — it appears to be already set up.' ); } $inputs = []; @@ -84,7 +90,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in } // review/edit loop - while( true ) { + while( count( $prompts )>0 ) { $io->section( 'Review' ); $index = 1; $keysByIndex = []; @@ -130,12 +136,56 @@ protected function execute( InputInterface $input, OutputInterface $output ): in } } - $io->success( 'Setup complete. Next: `gf env local`, then `gf cert:generate-auth`.' ); + $io->success( 'Setup complete. Next: `cp .env.example .env`, then `gf cert:generate-auth`.' ); return Command::SUCCESS; } + /** + * The {token}s a prompt key feeds. Most keys map 1:1; the base-path prompts also + * produce the derived relative-url token (see buildReplacementTable()). + * + * @return string[] + */ + public static function tokensForPromptKey( string $key ): array { + return match ( $key ) { + 'app_base_path' => [ '{app_base_path}', '{app_relative_url}' ], + 'prod_app_base_path' => [ '{prod_app_base_path}', '{prod_app_relative_url}' ], + default => [ '{' . $key . '}' ], + }; + } + + + /** + * Keep only the prompts whose token(s) actually appear somewhere in the project's + * token-eligible files, so setup never asks for values it cannot place. + * + * @param array $prompts prompt key => label + * + * @return array + */ + public static function filterPromptsToPresentTokens( array $prompts, string $rootDir ): array { + $haystack = ''; + foreach( tokenReplacer::findEligibleFiles( $rootDir ) as $filePath ) { + $contents = file_get_contents( $filePath ); + if( $contents!==false ) { + $haystack .= $contents; + } + } + + return array_filter( $prompts, function( string $key ) use ( $haystack ): bool { + foreach( self::tokensForPromptKey( $key ) as $token ) { + if( str_contains( $haystack, $token ) ) { + return true; + } + } + + return false; + }, ARRAY_FILTER_USE_KEY ); + } + + /** * @param array $inputs * diff --git a/src/cli/environmentFiles.php b/src/cli/environmentFiles.php deleted file mode 100644 index 83bdcbe..0000000 --- a/src/cli/environmentFiles.php +++ /dev/null @@ -1,63 +0,0 @@ - canonical file pairs, relative to the application root. - * - * @return array - */ - public static function filePairs( string $environment ): array { - return [ - 'app/config/environment-' . $environment . '.json' => 'app/config/environment.json', - 'composer-' . $environment . '.json' => 'composer.json', - 'www/web-' . $environment . '.config' => 'www/web.config', - ]; - } - - - /** - * Copy every existing variant file for $environment to its canonical name. - * Missing variant files are skipped (not every app has every pair). - * - * @return array - * @throws \gcgov\framework\cli\cliException When no variant file exists at all or a copy fails - */ - public static function apply( string $rootDir, string $environment, bool $dryRun = false ): array { - $rootDir = rtrim( str_replace( '\\', '/', $rootDir ), '/' ); - $results = []; - $copied = 0; - - foreach( self::filePairs( $environment ) as $source => $target ) { - $sourcePath = $rootDir . '/' . $source; - $targetPath = $rootDir . '/' . $target; - - if( !file_exists( $sourcePath ) ) { - $results[] = [ 'source' => $source, 'target' => $target, 'status' => 'skipped (no ' . $source . ')' ]; - continue; - } - - if( !$dryRun ) { - if( !copy( $sourcePath, $targetPath ) ) { - throw new cliException( 'Failed to copy ' . $sourcePath . ' to ' . $targetPath ); - } - } - - $results[] = [ 'source' => $source, 'target' => $target, 'status' => $dryRun ? 'would copy' : 'copied' ]; - $copied++; - } - - if( $copied===0 ) { - throw new cliException( 'No environment variant files found for environment "' . $environment . '" in ' . $rootDir . '. Expected at least one of: ' . implode( ', ', array_keys( self::filePairs( $environment ) ) ) ); - } - - return $results; - } - -} diff --git a/src/cli/routeCatalog.php b/src/cli/routeCatalog.php index e6b4071..cd1eb77 100644 --- a/src/cli/routeCatalog.php +++ b/src/cli/routeCatalog.php @@ -40,7 +40,7 @@ public static function getAllRoutes( appContext $context ): array { return \gcgov\framework\router::getMergedRoutes( $context->getServiceNamespaces() ); } catch( \gcgov\framework\exceptions\configException $e ) { - throw new cliException( 'Could not load routes: ' . $e->getMessage() . ' Run `gf env ` to activate an environment first.', 0, $e ); + throw new cliException( 'Could not load routes: ' . $e->getMessage() . ' Ensure app/config/environment.json exists and every %env(...) it references has a value (validate with `gf env`).', 0, $e ); } catch( \gcgov\framework\exceptions\routeException $e ) { throw new cliException( 'Could not load routes: ' . $e->getMessage(), 0, $e ); diff --git a/src/services/environment/dotEnvLoader.php b/src/services/environment/dotEnvLoader.php index 2b8443f..8099c9a 100644 --- a/src/services/environment/dotEnvLoader.php +++ b/src/services/environment/dotEnvLoader.php @@ -16,8 +16,11 @@ * is enabled so that call sites reading through `getenv()` (e.g. `GF_PHP` in the * gf CLI) also observe values loaded from .env files. * - * There is deliberately no APP_ENV cascade: environment selection stays with - * gf's env-file copying (`gf env `), not with dotenv. + * There is deliberately no APP_ENV cascade: environment selection is simply + * which variables the process environment (or .env) supplies. The gf CLI reads + * a *foreign* environment's values via per-variant overlay files + * (app/config/{name}.env, parsed with parseFile() — never loaded into the + * process environment). */ final class dotEnvLoader { @@ -58,6 +61,33 @@ public static function loadOnce( string $rootDir ): void { } + /** + * Parse a dotenv-format file into an array WITHOUT mutating the process + * environment. Used by the gf CLI to build the overlay for foreign-environment + * reads (e.g. app/config/prod.env for `db:restore --from=prod`). + * + * @return array + * @throws \gcgov\framework\services\environment\environmentException + */ + public static function parseFile( string $path ): array { + if( !is_file( $path ) || !is_readable( $path ) ) { + throw new environmentException( 'Environment file "' . $path . '" does not exist or is not readable.' ); + } + + $contents = file_get_contents( $path ); + if( $contents===false ) { + throw new environmentException( 'Failed reading environment file "' . $path . '".' ); + } + + try { + return ( new Dotenv() )->parse( $contents, $path ); + } + catch( \Symfony\Component\Dotenv\Exception\FormatException $e ) { + throw new environmentException( 'Invalid syntax in environment file "' . $path . '": ' . $e->getMessage(), 0, $e ); + } + } + + /** * Reset the idempotency cache. Intended for test isolation only. * diff --git a/src/services/environment/envVarResolver.php b/src/services/environment/envVarResolver.php index 179f7d7..2625d6a 100644 --- a/src/services/environment/envVarResolver.php +++ b/src/services/environment/envVarResolver.php @@ -46,13 +46,22 @@ final class envVarResolver { /** * Resolve every `%env(...)%` reference in $json. * - * @param string $json Raw config JSON. - * @param string $sourceDescription Human-readable source (e.g. the file path) for error messages. + * @param string $json Raw config JSON. + * @param string $sourceDescription Human-readable source (e.g. the file path) for error messages. + * @param array $overlayVars Variables that take precedence over the ambient + * environment during this resolution. Used by the gf CLI + * to resolve a *foreign* environment's config (e.g. + * `app/config/prod.env` for `db:restore --from=prod`) — + * an explicit variant request must beat the local + * environment. A variable missing from the overlay falls + * back to the ambient lookup, so an incomplete overlay + * silently picks up local values — overlay files should + * define every environment-specific variable. * * @return string|\stdClass The original string (fast path / undecodable), or the resolved object tree. * @throws \gcgov\framework\services\environment\environmentException */ - public static function resolveJson( string $json, string $sourceDescription ): string|\stdClass { + public static function resolveJson( string $json, string $sourceDescription, array $overlayVars = [] ): string|\stdClass { // Fast path: configs that do not opt in take a byte-identical route, preserving // full backwards compatibility (including today's malformed-JSON error behavior). if( !str_contains( $json, '%env(' ) ) { @@ -66,7 +75,7 @@ public static function resolveJson( string $json, string $sourceDescription ): s return $json; } - $resolved = self::resolveNode( $decoded, $sourceDescription ); + $resolved = self::resolveNode( $decoded, $sourceDescription, $overlayVars ); return $resolved instanceof \stdClass ? $resolved : $json; } @@ -75,27 +84,28 @@ public static function resolveJson( string $json, string $sourceDescription ): s /** * Recursively resolve string leaves within the decoded tree. * - * @param mixed $node - * @param string $sourceDescription + * @param mixed $node + * @param string $sourceDescription + * @param array $overlayVars * * @return mixed * @throws \gcgov\framework\services\environment\environmentException */ - private static function resolveNode( mixed $node, string $sourceDescription ): mixed { + private static function resolveNode( mixed $node, string $sourceDescription, array $overlayVars ): mixed { if( $node instanceof \stdClass ) { foreach( get_object_vars( $node ) as $key => $value ) { - $node->$key = self::resolveNode( $value, $sourceDescription ); + $node->$key = self::resolveNode( $value, $sourceDescription, $overlayVars ); } return $node; } if( is_array( $node ) ) { - return array_map( static fn( $value ) => self::resolveNode( $value, $sourceDescription ), $node ); + return array_map( static fn( $value ) => self::resolveNode( $value, $sourceDescription, $overlayVars ), $node ); } if( is_string( $node ) ) { - return self::resolveString( $node, $sourceDescription ); + return self::resolveString( $node, $sourceDescription, $overlayVars ); } return $node; @@ -105,22 +115,24 @@ private static function resolveNode( mixed $node, string $sourceDescription ): m /** * Resolve `%env(...)%` occurrences in a single string leaf. * + * @param array $overlayVars + * * @return mixed Typed value when the whole string is one reference; a string otherwise. * @throws \gcgov\framework\services\environment\environmentException */ - private static function resolveString( string $value, string $sourceDescription ): mixed { + private static function resolveString( string $value, string $sourceDescription, array $overlayVars ): mixed { if( !str_contains( $value, '%env(' ) ) { return $value; } // Whole-string reference → typed result. if( preg_match( '/^%env\(([^)]+)\)%$/', $value, $matches )===1 ) { - return self::resolveExpression( $matches[ 1 ], $sourceDescription ); + return self::resolveExpression( $matches[ 1 ], $sourceDescription, $overlayVars ); } // Embedded reference(s) → string substitution. - $result = preg_replace_callback( '/%env\(([^)]+)\)%/', static function( array $matches ) use ( $sourceDescription ): string { - $resolved = self::resolveExpression( $matches[ 1 ], $sourceDescription ); + $result = preg_replace_callback( '/%env\(([^)]+)\)%/', static function( array $matches ) use ( $sourceDescription, $overlayVars ): string { + $resolved = self::resolveExpression( $matches[ 1 ], $sourceDescription, $overlayVars ); if( is_bool( $resolved ) ) { return $resolved ? 'true' : 'false'; } @@ -140,10 +152,12 @@ private static function resolveString( string $value, string $sourceDescription /** * Resolve one `%env(...)%` expression (the text between the parentheses). * + * @param array $overlayVars + * * @return mixed * @throws \gcgov\framework\services\environment\environmentException */ - private static function resolveExpression( string $expression, string $sourceDescription ): mixed { + private static function resolveExpression( string $expression, string $sourceDescription, array $overlayVars = [] ): mixed { $lastColon = strrpos( $expression, ':' ); if( $lastColon===false ) { $varName = $expression; @@ -179,7 +193,7 @@ private static function resolveExpression( string $expression, string $sourceDes } // Environment lookup (with optional literal default fallback). - $raw = self::lookupEnv( $varName ); + $raw = self::lookupEnv( $varName, $overlayVars ); if( $raw===null ) { if( $default===null ) { throw new environmentException( 'Required environment variable "' . $varName . '" is not set (referenced as "%env(' . $expression . ')%" in ' . $sourceDescription . '). Set it in the process environment, a Docker secret, or a .env file.' ); @@ -288,11 +302,17 @@ private static function toBool( mixed $value ): bool { /** * Look up an environment variable value. - * Precedence: $_ENV → $_SERVER (excluding HTTP_* request headers) → getenv(). + * Precedence: overlay → $_ENV → $_SERVER (excluding HTTP_* request headers) → getenv(). * Returns null only when the variable is genuinely unset (a set-but-empty - * variable resolves to ''). + * variable — overlay included — resolves to '', which also suppresses `default:`). + * + * @param array $overlayVars */ - private static function lookupEnv( string $name ): ?string { + private static function lookupEnv( string $name, array $overlayVars = [] ): ?string { + if( array_key_exists( $name, $overlayVars ) ) { + return (string)$overlayVars[ $name ]; + } + if( array_key_exists( $name, $_ENV ) ) { return (string)$_ENV[ $name ]; } diff --git a/tests/Unit/Cli/AppContextTest.php b/tests/Unit/Cli/AppContextTest.php index 657116a..c2fd5ce 100644 --- a/tests/Unit/Cli/AppContextTest.php +++ b/tests/Unit/Cli/AppContextTest.php @@ -95,14 +95,14 @@ public function testDirectoryAccessors(): void { $this->assertSame( $root . '/vendor/autoload.php', $context->getVendorAutoloadPath() ); } - public function testLoadEnvironmentConfigParsesVariantFile(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-prod.json', json_encode( [ + public function testLoadEnvironmentConfigParsesActiveFile(): void { + file_put_contents( $this->tempRootDir . '/app/config/environment.json', json_encode( [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => 'mongodb://u:p@h:27017/widgets' ] ], ] ) ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); - $environmentConfig = $context->loadEnvironmentConfig( 'prod' ); + $environmentConfig = $context->loadEnvironmentConfig(); $this->assertSame( 'prod', $environmentConfig->type ); $this->assertCount( 1, $environmentConfig->mongoDatabases ); $this->assertSame( 'widgets', $environmentConfig->mongoDatabases[0]->database ); @@ -120,13 +120,13 @@ public function testLoadEnvironmentConfigResolvesEnvVars(): void { $_ENV[ 'TEST_MONGO_URI' ] = 'mongodb://resolved:27017/widgets'; putenv( 'TEST_MONGO_URI=mongodb://resolved:27017/widgets' ); try { - file_put_contents( $this->tempRootDir . '/app/config/environment-docker.json', json_encode( [ + file_put_contents( $this->tempRootDir . '/app/config/environment.json', json_encode( [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MONGO_URI)%' ] ], ] ) ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); - $environmentConfig = $context->loadEnvironmentConfig( 'docker' ); + $environmentConfig = $context->loadEnvironmentConfig(); $this->assertSame( 'mongodb://resolved:27017/widgets', $environmentConfig->mongoDatabases[ 0 ]->uri ); } finally { @@ -139,23 +139,117 @@ public function testLoadEnvironmentConfigResolvesEnvVars(): void { public function testLoadEnvironmentConfigThrowsCliExceptionWhenEnvVarMissing(): void { unset( $_ENV[ 'TEST_MISSING_URI' ] ); putenv( 'TEST_MISSING_URI' ); - file_put_contents( $this->tempRootDir . '/app/config/environment-docker.json', json_encode( [ + file_put_contents( $this->tempRootDir . '/app/config/environment.json', json_encode( [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MISSING_URI)%' ] ], ] ) ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); $this->expectException( cliException::class ); - $context->loadEnvironmentConfig( 'docker' ); + $context->loadEnvironmentConfig(); } - public function testGetEnvironmentVariantsListsVariantFiles(): void { - touch( $this->tempRootDir . '/app/config/environment-local.json' ); - touch( $this->tempRootDir . '/app/config/environment-prod.json' ); + + public function testLoadEnvironmentConfigVariantAppliesOverlay(): void { + // Ambient value must LOSE to the overlay for an explicit variant read. + $_ENV[ 'TEST_MONGO_URI' ] = 'mongodb://local:27017'; + putenv( 'TEST_MONGO_URI=mongodb://local:27017' ); + try { + file_put_contents( $this->tempRootDir . '/app/config/environment.json', json_encode( [ + 'type' => '%env(default:local:TEST_APP_TYPE)%', + 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MONGO_URI)%' ] ], + ] ) ); + file_put_contents( $this->tempRootDir . '/app/config/prod.env', "TEST_APP_TYPE=prod\nTEST_MONGO_URI=mongodb://prod:27017\n" ); + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); + + $prodConfig = $context->loadEnvironmentConfig( 'prod' ); + $this->assertSame( 'prod', $prodConfig->type ); + $this->assertSame( 'mongodb://prod:27017', $prodConfig->mongoDatabases[ 0 ]->uri ); + + $activeConfig = $context->loadEnvironmentConfig(); + $this->assertSame( 'local', $activeConfig->type ); + $this->assertSame( 'mongodb://local:27017', $activeConfig->mongoDatabases[ 0 ]->uri ); + } + finally { + unset( $_ENV[ 'TEST_MONGO_URI' ] ); + putenv( 'TEST_MONGO_URI' ); + } + } + + + public function testLoadEnvironmentConfigVariantAmbientFillsOverlayGaps(): void { + $_ENV[ 'TEST_MONGO_DB' ] = 'localDb'; + putenv( 'TEST_MONGO_DB=localDb' ); + try { + file_put_contents( $this->tempRootDir . '/app/config/environment.json', json_encode( [ + 'type' => 'local', + 'mongoDatabases' => [ [ 'default' => true, 'database' => '%env(TEST_MONGO_DB)%', 'uri' => '%env(TEST_MONGO_URI)%' ] ], + ] ) ); + file_put_contents( $this->tempRootDir . '/app/config/prod.env', "TEST_MONGO_URI=mongodb://prod:27017\n" ); + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); + + $prodConfig = $context->loadEnvironmentConfig( 'prod' ); + $this->assertSame( 'mongodb://prod:27017', $prodConfig->mongoDatabases[ 0 ]->uri ); + // TEST_MONGO_DB not in the overlay -> ambient value fills the gap + $this->assertSame( 'localDb', $prodConfig->mongoDatabases[ 0 ]->database ); + } + finally { + unset( $_ENV[ 'TEST_MONGO_DB' ] ); + putenv( 'TEST_MONGO_DB' ); + } + } + + + public function testLoadEnvironmentConfigVariantThrowsWhenOverlayMissing(): void { + file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"local"}' ); + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); + try { + $context->loadEnvironmentConfig( 'prod' ); + $this->fail( 'Expected cliException' ); + } + catch( cliException $e ) { + $this->assertStringContainsString( 'prod.env', $e->getMessage() ); + } + } + + + public function testLoadEnvironmentConfigVariantMentionsMigrationWhenLegacyFileExists(): void { + file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"local"}' ); + file_put_contents( $this->tempRootDir . '/app/config/environment-prod.json', '{"type":"prod"}' ); + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); + try { + $context->loadEnvironmentConfig( 'prod' ); + $this->fail( 'Expected cliException' ); + } + catch( cliException $e ) { + $this->assertStringContainsString( 'environment-prod.json', $e->getMessage() ); + $this->assertStringContainsString( 'Migrating a v6 app to v7', $e->getMessage() ); + } + } + + + public function testDescribeEnvironmentConfigSource(): void { + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); + $root = str_replace( '\\', '/', $this->tempRootDir ); + $this->assertSame( $root . '/app/config/environment.json', $context->describeEnvironmentConfigSource() ); + $this->assertSame( $root . '/app/config/environment.json (overlay: ' . $root . '/app/config/prod.env)', $context->describeEnvironmentConfigSource( 'prod' ) ); + } + + + public function testGetEnvironmentVariantsListsOverlayFiles(): void { + touch( $this->tempRootDir . '/app/config/prod.env' ); + touch( $this->tempRootDir . '/app/config/staging.env' ); + touch( $this->tempRootDir . '/app/config/prod.env.example' ); touch( $this->tempRootDir . '/app/config/environment.json' ); + touch( $this->tempRootDir . '/app/config/environment-local.json' ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); - $this->assertSame( [ 'local', 'prod' ], $context->getEnvironmentVariants() ); + $this->assertSame( [ 'prod', 'staging' ], $context->getEnvironmentVariants() ); } private function deleteDirectory( string $directory ): void { diff --git a/tests/Unit/Cli/CommandsTest.php b/tests/Unit/Cli/CommandsTest.php index bca048b..598e57a 100644 --- a/tests/Unit/Cli/CommandsTest.php +++ b/tests/Unit/Cli/CommandsTest.php @@ -58,15 +58,65 @@ public function testCliListShowsCliRoutesWithDescriptions(): void { $this->assertStringNotContainsString( '/widget', $display ); } - public function testEnvCommandCopiesVariantFiles(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-local.json', '{"type":"local"}' ); + public function testEnvCommandValidatesVariantOverlay(): void { + file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"%env(default:local:TEST_ENVCMD_TYPE)%","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_URI)%"}]}' ); + file_put_contents( $this->tempRootDir . '/app/config/prod.env', "TEST_ENVCMD_TYPE=prod\nTEST_ENVCMD_URI=mongodb://user:secret@prod:27017\n" ); $commandTester = new CommandTester( new envCommand() ); - $exitCode = $commandTester->execute( [ 'environment' => 'local' ] ); + $exitCode = $commandTester->execute( [ 'environment' => 'prod' ] ); $this->assertSame( 0, $exitCode ); - $this->assertSame( '{"type":"local"}', file_get_contents( $this->tempRootDir . '/app/config/environment.json' ) ); - $this->assertStringContainsString( 'copied', $commandTester->getDisplay() ); + $display = $commandTester->getDisplay(); + $this->assertStringContainsString( 'type: prod', $display ); + $this->assertStringContainsString( 'widgets', $display ); + $this->assertStringNotContainsString( 'secret', $display, 'mongo uri credentials must be redacted' ); + $this->assertStringContainsString( 'Resolved successfully', $display ); + } + + public function testEnvCommandFailsNamingTheMissingVariable(): void { + file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"prod","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_MISSING_URI)%"}]}' ); + file_put_contents( $this->tempRootDir . '/app/config/prod.env', "IRRELEVANT=1\n" ); + + $commandTester = new CommandTester( new envCommand() ); + $exitCode = $commandTester->execute( [ 'environment' => 'prod' ] ); + + $this->assertSame( 1, $exitCode ); + $this->assertStringContainsString( 'TEST_ENVCMD_MISSING_URI', $commandTester->getDisplay() ); + } + + public function testEnvCommandBareListsVariantsAndChecksActiveEnvironment(): void { + file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"local"}' ); + touch( $this->tempRootDir . '/app/config/prod.env' ); + touch( $this->tempRootDir . '/app/config/staging.env' ); + + $commandTester = new CommandTester( new envCommand() ); + $exitCode = $commandTester->execute( [] ); + + $this->assertSame( 0, $exitCode ); + $display = $commandTester->getDisplay(); + $this->assertStringContainsString( 'prod.env', $display ); + $this->assertStringContainsString( 'staging.env', $display ); + $this->assertStringContainsString( 'Resolved successfully', $display ); + } + + public function testSetupPromptFilteringKeepsOnlyPresentTokens(): void { + file_put_contents( $this->tempRootDir . '/app/config/app.json', '{"title":"{app_title}"}' ); + file_put_contents( $this->tempRootDir . '/app/router.php', ' 'Title', + 'app_base_path' => 'Base path', // present via derived {app_relative_url} + 'prod_app_root_url' => 'PROD root url', // absent + 'prod_app_base_path' => 'PROD base path', // absent + ], $this->tempRootDir ); + + $this->assertSame( [ 'app_title', 'app_base_path' ], array_keys( $filtered ) ); + } + + public function testTokensForPromptKeyMapsBasePathToBothTokens(): void { + $this->assertSame( [ '{app_base_path}', '{app_relative_url}' ], setupCommand::tokensForPromptKey( 'app_base_path' ) ); + $this->assertSame( [ '{prod_app_base_path}', '{prod_app_relative_url}' ], setupCommand::tokensForPromptKey( 'prod_app_base_path' ) ); + $this->assertSame( [ '{app_title}' ], setupCommand::tokensForPromptKey( 'app_title' ) ); } public function testCertGenerateAuthCreatesKeypairsAndGuidsJson(): void { diff --git a/tests/Unit/Cli/DbRestoreCommandTest.php b/tests/Unit/Cli/DbRestoreCommandTest.php index 4166f9d..7d852e9 100644 --- a/tests/Unit/Cli/DbRestoreCommandTest.php +++ b/tests/Unit/Cli/DbRestoreCommandTest.php @@ -21,6 +21,30 @@ private function makeDatabase( string $database, string $uri, bool $default = fa return $mongoDatabase; } + public function testFindIdenticalPairsFlagsSameUriAndDatabase(): void { + $pairs = [ + [ $this->makeDatabase( 'widgets', 'mongodb://host:27017/' ), $this->makeDatabase( 'widgets', 'mongodb://host:27017' ) ], + [ $this->makeDatabase( 'audit', 'mongodb://prod:27017' ), $this->makeDatabase( 'audit', 'mongodb://local:27017' ) ], + ]; + + $identical = dbRestoreCommand::findIdenticalPairs( $pairs ); + + $this->assertCount( 1, $identical, 'trailing-slash uri difference must still count as identical' ); + $this->assertSame( 'widgets', $identical[ 0 ][ 0 ]->database ); + } + + public function testFindIdenticalPairsAllowsSameClusterDifferentDatabase(): void { + $pairs = [ + [ $this->makeDatabase( 'appProd', 'mongodb://host:27017' ), $this->makeDatabase( 'appLocal', 'mongodb://host:27017' ) ], + ]; + + $this->assertSame( [], dbRestoreCommand::findIdenticalPairs( $pairs ) ); + } + + public function testFindIdenticalPairsEmptyInput(): void { + $this->assertSame( [], dbRestoreCommand::findIdenticalPairs( [] ) ); + } + public function testPairDatabasesMatchesByName(): void { $source = [ $this->makeDatabase( 'widgets', 'mongodb://prod/widgets' ), $this->makeDatabase( 'audit', 'mongodb://prod/audit' ) ]; $target = [ $this->makeDatabase( 'audit', 'mongodb://local/audit' ), $this->makeDatabase( 'widgets', 'mongodb://local/widgets' ) ]; diff --git a/tests/Unit/Cli/EnvironmentFilesTest.php b/tests/Unit/Cli/EnvironmentFilesTest.php deleted file mode 100644 index d0a1173..0000000 --- a/tests/Unit/Cli/EnvironmentFilesTest.php +++ /dev/null @@ -1,79 +0,0 @@ -tempRootDir = sys_get_temp_dir() . '/gcgov-envfiles-test-' . uniqid(); - mkdir( $this->tempRootDir . '/app/config', 0777, true ); - mkdir( $this->tempRootDir . '/www', 0777, true ); - } - - protected function tearDown(): void { - $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $this->tempRootDir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); - foreach( $iterator as $file ) { - $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); - } - rmdir( $this->tempRootDir ); - } - - public function testAppliesAllThreeVariantFiles(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-local.json', '{"type":"local"}' ); - file_put_contents( $this->tempRootDir . '/composer-local.json', '{"name":"local"}' ); - file_put_contents( $this->tempRootDir . '/www/web-local.config', '' ); - - $results = environmentFiles::apply( $this->tempRootDir, 'local' ); - - $this->assertCount( 3, $results ); - $this->assertSame( '{"type":"local"}', file_get_contents( $this->tempRootDir . '/app/config/environment.json' ) ); - $this->assertSame( '{"name":"local"}', file_get_contents( $this->tempRootDir . '/composer.json' ) ); - $this->assertSame( '', file_get_contents( $this->tempRootDir . '/www/web.config' ) ); - } - - public function testMissingVariantsAreSkippedGracefully(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-prod.json', '{"type":"prod"}' ); - - $results = environmentFiles::apply( $this->tempRootDir, 'prod' ); - - $statuses = array_column( $results, 'status' ); - $this->assertSame( 'copied', $statuses[0] ); - $this->assertStringStartsWith( 'skipped', $statuses[1] ); - $this->assertStringStartsWith( 'skipped', $statuses[2] ); - $this->assertFileDoesNotExist( $this->tempRootDir . '/composer.json' ); - } - - public function testThrowsWhenNoVariantFileExists(): void { - $this->expectException( cliException::class ); - environmentFiles::apply( $this->tempRootDir, 'staging' ); - } - - public function testDryRunDoesNotWrite(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-local.json', '{"type":"local"}' ); - - $results = environmentFiles::apply( $this->tempRootDir, 'local', true ); - - $this->assertSame( 'would copy', $results[0][ 'status' ] ); - $this->assertFileDoesNotExist( $this->tempRootDir . '/app/config/environment.json' ); - } - - public function testExistingCanonicalFilesAreOverwritten(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment-prod.json', '{"type":"prod"}' ); - file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"local"}' ); - - environmentFiles::apply( $this->tempRootDir, 'prod' ); - - $this->assertSame( '{"type":"prod"}', file_get_contents( $this->tempRootDir . '/app/config/environment.json' ) ); - } - -} diff --git a/tests/Unit/Services/Environment/DotEnvLoaderTest.php b/tests/Unit/Services/Environment/DotEnvLoaderTest.php index 69258c9..86c844a 100644 --- a/tests/Unit/Services/Environment/DotEnvLoaderTest.php +++ b/tests/Unit/Services/Environment/DotEnvLoaderTest.php @@ -91,6 +91,30 @@ public function testNoOpWhenAbsent(): void { } + public function testParseFileReturnsMapWithoutMutatingEnvironment(): void { + file_put_contents( $this->tempDir . '/prod.env', "DOTENV_TEST_A=prod_value\nDOTENV_TEST_B=other\n" ); + + $parsed = dotEnvLoader::parseFile( $this->tempDir . '/prod.env' ); + + $this->assertSame( [ 'DOTENV_TEST_A' => 'prod_value', 'DOTENV_TEST_B' => 'other' ], $parsed ); + $this->assertArrayNotHasKey( 'DOTENV_TEST_A', $_ENV ); + $this->assertFalse( getenv( 'DOTENV_TEST_A' ) ); + } + + + public function testParseFileThrowsWhenMissing(): void { + $this->expectException( \gcgov\framework\services\environment\environmentException::class ); + dotEnvLoader::parseFile( $this->tempDir . '/does-not-exist.env' ); + } + + + public function testParseFileThrowsOnMalformedContent(): void { + file_put_contents( $this->tempDir . '/bad.env', "NOT A VALID LINE ===\n" ); + $this->expectException( \gcgov\framework\services\environment\environmentException::class ); + dotEnvLoader::parseFile( $this->tempDir . '/bad.env' ); + } + + private function deleteDirectory( string $directory ): void { if( !is_dir( $directory ) ) { return; diff --git a/tests/Unit/Services/Environment/EnvVarResolverTest.php b/tests/Unit/Services/Environment/EnvVarResolverTest.php index f1ac08e..db8c1cf 100644 --- a/tests/Unit/Services/Environment/EnvVarResolverTest.php +++ b/tests/Unit/Services/Environment/EnvVarResolverTest.php @@ -203,6 +203,47 @@ public function testNestedAppDictionaryResolution(): void { } + public function testOverlayBeatsAmbientEnvironment(): void { + $this->setEnv( 'MONGO_URI', 'mongodb://local:27017' ); + $result = envVarResolver::resolveJson( '{"uri":"%env(MONGO_URI)%"}', 'test', [ 'MONGO_URI' => 'mongodb://prod:27017' ] ); + $this->assertSame( 'mongodb://prod:27017', $result->uri ); + } + + + public function testOverlayMissFallsBackToAmbient(): void { + $this->setEnv( 'MONGO_URI', 'mongodb://local:27017' ); + $result = envVarResolver::resolveJson( '{"uri":"%env(MONGO_URI)%","db":"%env(MONGO_DATABASE)%"}', 'test', [ 'MONGO_DATABASE' => 'prodDb' ] ); + $this->assertSame( 'mongodb://local:27017', $result->uri ); + $this->assertSame( 'prodDb', $result->db ); + } + + + public function testOverlayValueSuppressesDefault(): void { + $result = envVarResolver::resolveJson( '{"uri":"%env(default:mongodb://fallback:27017:MONGO_URI)%"}', 'test', [ 'MONGO_URI' => 'mongodb://overlay:27017' ] ); + $this->assertSame( 'mongodb://overlay:27017', $result->uri ); + } + + + public function testEmptyOverlayValueResolvesToEmptyStringAndSuppressesDefault(): void { + $result = envVarResolver::resolveJson( '{"secret":"%env(default:fallback:CLIENT_SECRET)%"}', 'test', [ 'CLIENT_SECRET' => '' ] ); + $this->assertSame( '', $result->secret ); + } + + + public function testEmptyOverlayArrayIsIdenticalToTwoArgCall(): void { + $this->setEnv( 'MONGO_URI', 'mongodb://ambient:27017' ); + $json = '{"uri":"%env(MONGO_URI)%","port":"%env(int:default:587:SMTP_PORT)%"}'; + $this->assertEquals( envVarResolver::resolveJson( $json, 'test' ), envVarResolver::resolveJson( $json, 'test', [] ) ); + } + + + public function testOverlayWorksWithProcessorsAndEmbeddedRefs(): void { + $result = envVarResolver::resolveJson( '{"port":"%env(int:SMTP_PORT)%","url":"https://%env(HOSTNAME_X)%/api"}', 'test', [ 'SMTP_PORT' => '2525', 'HOSTNAME_X' => 'prod.example.com' ] ); + $this->assertSame( 2525, $result->port ); + $this->assertSame( 'https://prod.example.com/api', $result->url ); + } + + public function testServerHttpKeysAreNotUsedForLookup(): void { // A malicious request header must not satisfy an env reference. $_SERVER[ 'HTTP_MONGO_URI' ] = 'mongodb://attacker'; From f3138cf50ddc79b654ded54850f886bf57b5b979 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 13:18:16 +0000 Subject: [PATCH 03/30] v7: unified root config.json; flatten config API; root-level overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge app.json + environment.json into a single config.json at the APPLICATION ROOT and make \gcgov\framework\config the one configuration API. Per-variant overlay files move to the root as well ({root}/prod.env) — verified collision-free: docker compose reads only .env, glob('*.env') excludes dotfiles and *.env.example, .gitignore/.dockerignore patterns scope cleanly, and the web root is /www so root files are never served. - New \gcgov\framework\models\unifiedConfig merges every field and helper of the deleted appConfig + environmentConfig models (app, email, settings + type, urls, mongo/sql databases, microsoft, jwtAuth, payjunction, logging, appDictionary; getRootUrl/getBaseUrl/getBasePath/ isLocal/getDefaultSqlDatabase/getSqlDatabaseByName). - \gcgov\framework\config loads {root}/config.json once (dotenv + %env() resolution as before; getConfigFilePath()) and exposes everything directly: getApp, getEmail, getSettings, getType, isLocal, getServerName, getRootUrl, getBaseUrl, getBasePath, getCookieUrl, getPhpPath, getLogging, getMongoDatabases, getSqlDatabases, getDefaultSqlDatabase, getSqlDatabaseByName, getMicrosoft, getJwtAuth, getPayjunction, getAppDictionary. getAppConfig()/getEnvironmentConfig() and getConfigDir() are removed (BREAKING; plugin routers change config::getEnvironmentConfig()->getBasePath() -> config::getBasePath()). - Every internal call site rewritten to the flattened accessors (renderer, router, jwtAuth, log, microsoft, mongodb dispatcher/_meta/ auth user/tools, pdodb). - gf CLI: appContext::loadConfig($variant) resolves {root}/config.json (variant overlays at {root}/{variant}.env; getConfigPath/ getVariantOverlayPath/describeConfigSource); variant discovery globs {root}/*.env; legacy split-config files (app/config/app.json, environment{-variant}.json) are detected and produce a migration hint; phpProcess/cliCommand/env/db:restore/db:run/routeCatalog updated. - Tests updated to the unified model + root paths; docs (CLAUDE.md, README.md, readme/gf.md incl. the expanded v6->v7 migration guide, environment-variables.md, mongodb.md) rewritten for the single-file layout. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JWacdhEDN9upUvYeagNKru --- CLAUDE.md | 58 ++--- README.md | 27 +-- readme/environment-variables.md | 21 +- readme/gf.md | 53 +++-- readme/mongodb.md | 2 +- src/cli/appContext.php | 73 +++--- src/cli/commands/cliCommand.php | 6 +- src/cli/commands/dbRestoreCommand.php | 14 +- src/cli/commands/dbRunCommand.php | 4 +- src/cli/commands/envCommand.php | 14 +- src/cli/internal/run-route.php | 2 +- src/cli/mongoTools.php | 2 +- src/cli/phpProcess.php | 12 +- src/cli/routeCatalog.php | 2 +- src/config.php | 212 +++++++++++++----- src/models/appConfig.php | 25 --- ...nvironmentConfig.php => unifiedConfig.php} | 44 ++-- src/renderer.php | 14 +- src/router.php | 32 +-- src/services/jwtAuth/jwtAuth.php | 12 +- src/services/log.php | 2 +- src/services/microsoft/auth.php | 8 +- src/services/microsoft/files.php | 20 +- src/services/microsoft/mail.php | 2 +- src/services/mongodb/dispatcher.php | 6 +- src/services/mongodb/models/_meta.php | 4 +- src/services/mongodb/models/auth/user.php | 6 +- src/services/mongodb/tools/log.php | 6 +- src/services/mongodb/tools/mdb.php | 4 +- src/services/pdodb/pdodb.php | 6 +- tests/Unit/Cli/AppContextTest.php | 70 +++--- tests/Unit/Cli/CommandsTest.php | 14 +- tests/Unit/ConfigTest.php | 34 ++- .../Models/Config/AppConfigModelsTest.php | 12 +- ...ntConfigTest.php => UnifiedConfigTest.php} | 26 +-- .../Services/Chrome/ChromeServiceTest.php | 2 +- .../Services/MongoDB/Tools/MongoLogTest.php | 10 +- tests/Unit/Services/PdoDb/PdodbTest.php | 6 +- tests/bootstrap.php | 6 +- 39 files changed, 499 insertions(+), 374 deletions(-) delete mode 100644 src/models/appConfig.php rename src/models/{environmentConfig.php => unifiedConfig.php} (62%) rename tests/Unit/Models/{EnvironmentConfigTest.php => UnifiedConfigTest.php} (83%) diff --git a/CLAUDE.md b/CLAUDE.md index 6149990..aa348e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ src/ ├── framework.php # entry point: runApp() drives the whole lifecycle ├── router.php # framework router: merges service + app routes, runs auth guards ├── renderer.php # invokes the matched controller, serializes the response -├── config.php # static config + path resolver (app dir, srv dir, app.json, environment.json) +├── config.php # static config + path resolver (app dir, srv dir, the unified {root}/config.json) ├── cli/ # the gf command line tool (§16): application, appContext, commands/* ├── interfaces/ # contracts an app must implement (app, router, render, controller, auth\user, ...) ├── models/ # route, routeHandler, controller*Response, authUser, config/* DTOs, customConstraints @@ -64,16 +64,18 @@ An app that runs a full request lifecycle must supply, in its `/app` directory: | `/app/renderer.php` | `\app\renderer` | `\gcgov\framework\interfaces\render` | | `/app/controllers/*.php` | e.g. `\app\controllers\widget` | `\gcgov\framework\interfaces\controller` | -Required config files (missing either throws `configException` at request time): -- `/app/config/app.json` -- `/app/config/environment.json` +Required config file (missing it throws `configException` at request time): +- `/config.json` — the unified configuration at the application ROOT (v7 merge of the former + `app/config/app.json` + `app/config/environment.json`), with secrets/per-env values via `%env(...)%`. -Typical app tree (scaffolding template adds more — `srv/`, `db/`, `scripts/`, `www/web.config`, etc.): +Typical app tree (scaffolding template adds more — `srv/`, `db/`, `docker/`, `Dockerfile`, etc.): ``` /api +├── config.json # unified config (committed; %env(...) refs) +├── {env}.env # gitignored per-variant overlays for gf db:*/env (e.g. prod.env) +├── .env # gitignored local values (from .env.example) ├── app/{app,router,renderer,constants}.php │ ├── cli/index.php # CLI entry -│ ├── config/{app,environment}.json │ ├── controllers/{name}.php │ └── models/{name}.php └── www/index.php # HTTP entry (web root) @@ -147,7 +149,7 @@ $routes[] = new route('POST', 'structure/{_id}', '\app\controllers\structure', $routes[] = new route('CLI', '/cli/cleanup', '\app\controllers\cli\import','cleanup',false); ``` If the app is not served at the domain root, prepend a base path (commonly -`config::getEnvironmentConfig()->getBasePath()`, which is what plugin routers use). +`config::getBasePath()`, which is what plugin routers use). ### Authentication guard flow (`framework\router::route()`) For a matched route with `authentication === true`: @@ -311,7 +313,7 @@ returning group keys, and tag constraints with `groups: [...]`. `getFile()`, `deleteFile()`. Pair with `controllerFileResponse` to serve them. ### Auditing & encryption (config-driven, per database) -- **Audit**: enable per-DB in `environment.json` (`audit`, `auditForward`, optional separate audit DB). Writes +- **Audit**: enable per-DB in `config.json` (`audit`, `auditForward`, optional separate audit DB). Writes JSON-patch diffs of changes. - **Queryable encryption**: optional `encryption` block per DB (GCP KMS). Encrypted collections must be created explicitly: `(new mdb($collection))->createEncryptedCollection($collection)`; rotate with `->rotateKeys()`. @@ -321,12 +323,18 @@ returning group keys, and tag constraints with `groups: [...]`. ## 8. Config -`\gcgov\framework\config` is a static accessor. Paths are derived by reflecting `\app\app`'s file location, so -`config::getAppDir()`, `getRootDir()`, `getConfigDir()`, `getModelsDir()`, `getSrvDir()`, `getTempDir()` all -work without setup. Config DTOs are `jsonDeserialize`-hydrated from the two JSON files. +`\gcgov\framework\config` is the single static configuration API. Paths are derived by reflecting +`\app\app`'s file location, so `config::getAppDir()`, `getRootDir()`, `getModelsDir()`, `getSrvDir()`, +`getTempDir()`, `getConfigFilePath()` all work without setup. Configuration values come from the +**unified `{root}/config.json`** (hydrated once into `\gcgov\framework\models\unifiedConfig`) and are +exposed **directly on `config`** — there are no separate appConfig/environmentConfig objects (v7): +`config::getApp()` (title/guid), `getEmail()`, `getSettings()`, `getType()`, `isLocal()`, +`getServerName()`, `getRootUrl()`, `getBaseUrl()`, `getBasePath()`, `getCookieUrl()`, `getPhpPath()`, +`getLogging()`, `getMongoDatabases()`, `getSqlDatabases()`, `getDefaultSqlDatabase()`, +`getSqlDatabaseByName($name)`, `getMicrosoft()`, `getJwtAuth()`, `getPayjunction()`, `getAppDictionary()`. ### Environment variables in config — `%env(...)%` -Both JSON files support **Symfony-style `%env(...)%` references**, resolved at load time by +config.json supports **Symfony-style `%env(...)%` references**, resolved at load time by `\gcgov\framework\services\environment\envVarResolver` (see `readme/environment-variables.md`). This keeps secrets out of the committed config and lets them come from the process environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hosting. @@ -340,25 +348,19 @@ environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hostin `.env.local`; **real environment always wins** over both. No `APP_ENV` cascade — an environment IS the variable set the process is given; nothing is activated or copied (v7). - gf variant reads (`db:restore --from=prod`, `db:run --env=prod`, `gf env prod`) resolve the - same committed `environment.json` with a gitignored `app/config/{variant}.env` **overlay** + same committed `config.json` with a gitignored `{root}/{variant}.env` **overlay** (parsed via `dotEnvLoader::parseFile()`, precedence: overlay > real env > `.env.local` > `.env` > `default:`). Overlays must define every environment-specific variable — missing ones silently fall back to local values; validate with `gf env `. - Missing required var → `configException` (runtime) / `cliException` (gf), naming the variable. -**`app.json`** → `\gcgov\framework\models\appConfig`: +**`{root}/config.json`** → `\gcgov\framework\models\unifiedConfig` (one file, all sections): ```jsonc { "app": { "title": "...", "guid": "..." }, "email": { "fromAddress": "", "fromName": "", "useSMTP": false, "SMTPHost": "", "SMTPPort": 587, "...": "" }, - "settings": { "useSession": false, "forceMfaForPasswordUsers": false } -} -``` -**`environment.json`** → `\gcgov\framework\models\environmentConfig` (accessor helpers: `getRootUrl()`, -`getBaseUrl()`, `getBasePath()`, `isLocal()`, `getDefaultSqlDatabase()`, `getSqlDatabaseByName()`): -```jsonc -{ - "type": "local|prod", "serverName": "", "rootUrl": "", "basePath": "", "baseUrl": "", "cookieUrl": "", + "settings": { "useSession": false, "forceMfaForPasswordUsers": false }, + "type": "local|prod", "serverName": "", "rootUrl": "", "basePath": "", "cookieUrl": "", "logging": { "lifecycle": false, "renderer": false }, // lifecycle=true logs the whole request pipeline "mongoDatabases": [ { "default": true, "database": "", "uri": "mongodb+srv://...", "logging": true, "audit": false, "include_meta": true, "encryption": { /* optional */ } } ], @@ -431,7 +433,7 @@ List routes with `gf cli:list`; debug with `gf cli /path --debug`. `#[excludeFromTypemapWhenThisClassNotRoot]`. - There's no auth without an auth plugin; and auth plugins register a **global guard** over every `authentication:true` route in the app. -- Set `logging.lifecycle=true` in `environment.json` to trace the entire pipeline when debugging routing/auth. +- Set `logging.lifecycle=true` in `config.json` to trace the entire pipeline when debugging routing/auth. --- @@ -457,10 +459,10 @@ at a time (oauth-server OR auth-ms-front). - `composer.json`: `"type": "framework-service"`, PSR-4 `gcgov\framework\services\\ → src/`. - Provide `src/router.php` = `\gcgov\framework\services\\router implements \gcgov\framework\interfaces\router` with `getRoutes()`, `authentication()`, static `_before()/_after()`. Prefix routes with - `config::getEnvironmentConfig()->getBasePath()`. + `config::getBasePath()`. - Controllers live under `\gcgov\framework\services\\controllers\…` and implement `controller`. - Config via a singleton (`getInstance()`) the app tweaks in `app::_before()` (see oauth-server's `oauthConfig`), - and/or `environment.json.appDictionary`. + and/or `config.json` `appDictionary` (via `config::getAppDictionary()`). - Return `false` from a plugin `authentication()` only to deny; return `true` to allow. - Optional: contribute gf commands with `src/cli/commandProvider.php` = `\gcgov\framework\services\\cli\commandProvider implements \gcgov\framework\cli\commandProvider` @@ -503,8 +505,8 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea `src/cli/chromeInstaller.php` (injectable Guzzle client — tests are network-free). - **Architecture** (`src/cli/`): `application` (command registration + provider discovery), `appContext` (app-root locator: composer autoload path first, then cwd walk-up; lazy config - access via `loadEnvironmentConfig($variant)` — resolves `environment.json`, applying the - `app/config/{variant}.env` overlay when a variant is named; never boots the request lifecycle), + access via `loadConfig($variant)` — resolves the root `config.json`, applying the + `{root}/{variant}.env` overlay when a variant is named; never boots the request lifecycle), `routeCatalog` (CLI-route enumeration via `router::getMergedRoutes()`), `phpProcess`, `tokenReplacer`, `mongoTools`, `cliException` (user-facing errors), `internal/run-route.php` (child-process route runner; maps response status ≥400 → exit 1). @@ -514,7 +516,7 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea root-only (env, db:*, cert:*, deploy, setup — config JSON only, no `\app` boot); app-boot (cli, cli:list — `assertAppLoadable()`; `\app\app::_before()` is deliberately NOT called). - **`gf cli `** always spawns a fresh PHP child process (Xdebug flags need fresh INI; - isolates `exit()`; interpreter picked via `--php` > `GF_PHP` > environment.json `phpPath` > current). + isolates `exit()`; interpreter picked via `--php` > `GF_PHP` > config.json `phpPath` > current). The interpreter must be the CLI binary — `php-cgi`/`php-fpm`/`php-win` are swapped for the `php`/`php.exe` beside them, else rejected; the child always gets `-dregister_argc_argv=1`, and `internal/run-route.php` assumes neither `$argv` nor `STDERR` exists until it has checked. diff --git a/README.md b/README.md index 8b71e03..fd7cbb3 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,12 @@ Controllers should implement `\gcgov\framework\interfaces\controller`. Required configuration files: -* `/app/config/app.json` -* `/app/config/environment.json` +* `/config.json` — the unified configuration at the application root (merged app + environment sections, + secrets and per-environment values referenced via `%env(...)%`) -If either file is missing, the framework throws a config exception during request handling. +If it is missing, the framework throws a config exception during request handling. -Both config files support **Symfony-style `%env(...)%` environment-variable references**, so +config.json supports **Symfony-style `%env(...)%` environment-variable references**, so secrets (Mongo URIs, client secrets, SMTP credentials) can be injected from the process environment, Docker/Kubernetes secrets, or a `.env` file instead of being stored in the files. Existing plain-JSON config keeps working unchanged. See @@ -56,19 +56,14 @@ All apps utilizing the framework for an entire lifecycle should use this file st ``` /api +├── config.json ├── app │ ├── app.php │ ├── constants.php │ ├── renderer.php │ ├── router.php │ ├── cli -│ │ ├── index.php -│ │ ├── local.bat -│ │ ├── local-debug.bat -│ │ └── prod.bat -│ ├── config -│ │ ├── app.json -│ │ └── environment.json +│ │ └── index.php │ ├── controllers │ │ └── {controller.php} │ └── models @@ -83,14 +78,12 @@ automatically start with some extra folders and tools. ``` /api │... +├── config.json # committed unified config; secrets/per-env values via %env(...) +├── prod.env.example # copy to prod.env (gitignored) for gf db:*/env variant reads ├── www │ │... ├── app │ │... -│ └── config -│ ├── app.json -│ ├── environment.json # committed; secrets/per-env values via %env(...) -│ └── prod.env.example # copy to prod.env (gitignored) for gf db:*/env variant reads ├── docker │ └── nginx │ └── default.conf.template @@ -223,7 +216,7 @@ gf cli:list # list the app's CLI routes gf cert:generate-auth # JWT signing keys (replaces create-jwt-keys.ps1) gf db:restore --from=prod # pull a source env's mongo dbs into the local env gf db:run db/script.js # run a mongosh script with config-managed credentials -gf env prod # validate that the prod.env overlay fully resolves environment.json +gf env prod # validate that the prod.env overlay fully resolves config.json gf setup # bootstrap a scaffolded app (replaces setup.ps1) gf deploy # tag-based deployment (replaces update-production.ps1) ``` @@ -342,7 +335,7 @@ For full reference, configuration options, attributes, and detailed examples, se ### PDODB -Initiate PDO connections using SQL connection details in app/config/environment.json. It is only a small wrapper around +Initiate PDO connections using SQL connection details in config.json (`sqlDatabases`). It is only a small wrapper around the native PDO class. Read user connection: `new gcgov\framework\services\pdodb\pdodb(true, $databaseName)` diff --git a/readme/environment-variables.md b/readme/environment-variables.md index ee52f84..9e2078a 100644 --- a/readme/environment-variables.md +++ b/readme/environment-variables.md @@ -1,7 +1,7 @@ # Environment variables in config (`%env(...)%`) `gcgov/framework` can resolve **environment variables** inside your JSON config files -(`app/config/app.json` and `app/config/environment.json`) at load time. This lets you keep +(the unified `{root}/config.json`) at load time. This lets you keep secrets — Mongo URIs, Microsoft client secrets, SMTP/PayJunction credentials — **out of the config files entirely** and inject them from the process environment, Docker/Kubernetes secrets, or a local `.env` file. This is what makes the framework hostable in Docker (see the @@ -28,13 +28,12 @@ malformed-JSON error behavior). You only opt in by writing `%env(...)%` somewher ## Where it applies -Resolution runs at the three points where the framework reads config JSON: +Resolution runs at the two points where the framework reads the unified config JSON: | Source | Loader | |--------|--------| -| `app/config/app.json` | `\gcgov\framework\config::getAppConfig()` | -| `app/config/environment.json` | `\gcgov\framework\config::getEnvironmentConfig()` | -| `environment.json` + `app/config/{variant}.env` overlay | the `gf` CLI (`appContext::loadEnvironmentConfig($variant)`) — see "Per-variant overlay files" below | +| `{root}/config.json` | `\gcgov\framework\config` static accessors (`config::getBasePath()`, `getMongoDatabases()`, `getEmail()`, …) | +| `config.json` + `{root}/{variant}.env` overlay | the `gf` CLI (`appContext::loadConfig($variant)`) — see "Per-variant overlay files" below | Untyped config regions (`appDictionary`, plugin `clientParams`, etc.) are resolved too — the resolver walks the whole decoded tree. @@ -160,13 +159,13 @@ Unlike Symfony — where `default:` names a fallback **parameter** — here `def "SMTPPort": "%env(int:default:587:SMTP_PORT)%" // → int 587 when SMTP_PORT is unset ``` -With a single committed `environment.json`, the split is per **value**, not per file: give +With the single committed `config.json`, the split is per **value**, not per file: give `default:` fallbacks only to non-secret dev conveniences (identity URLs, a local `type`), and leave secrets and database coordinates as **hard references** so a misconfigured prod container fails loudly, naming exactly what to set — dev covers them via `.env` (`cp .env.example .env`): ```jsonc -// app/config/environment.json — one file for every environment: +// config.json — one file for every environment: "type": "%env(default:local:APP_TYPE)%", // dev-safe default; prod sets APP_TYPE=prod "uri": "%env(MONGO_URI)%", // hard: fail fast when unset "clientSecret": "%env(MICROSOFT_CLIENT_SECRET)%" @@ -182,9 +181,9 @@ fails loudly, naming exactly what to set — dev covers them via `.env` (`cp .en The gf CLI sometimes needs a **foreign** environment's values without activating anything — `gf db:restore --from=prod` must resolve prod's Mongo URI while your shell holds local values. That is what per-variant overlay files are for: a gitignored dotenv file -`app/config/{variant}.env` (e.g. `app/config/prod.env`; start from the app template's -`prod.env.example`). `appContext::loadEnvironmentConfig('prod')` resolves the committed -`environment.json` with that file's variables applied on top. Precedence for such a read: +`{root}/{variant}.env` (e.g. `prod.env`; start from the app template’s +`prod.env.example`). `appContext::loadConfig('prod')` resolves the committed +`config.json` with that file’s variables applied on top. Precedence for such a read: ``` {variant}.env overlay > real environment > .env.local > .env > default: fallback @@ -194,7 +193,7 @@ Two things to know: - **An overlay must define every environment-specific variable.** A variable missing from the overlay falls back to your *local* value silently. `gf env ` validates that an overlay - fully resolves `environment.json`, and `db:restore` refuses a pair whose source and target + fully resolves `config.json`, and `db:restore` refuses a pair whose source and target resolve to the same database — but neither catches everything. - Overlay files are parsed with `dotEnvLoader::parseFile()` — they are **never loaded into the process environment** and never affect the running app; only the one gf resolution sees them. diff --git a/readme/gf.md b/readme/gf.md index 680a208..c807337 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -52,7 +52,7 @@ gf cli /cli/generate-shifts --debug # run with Xdebug (replaces local-debug. - **Exit codes**: `0` on success, `1` when the response status is 400+ — so Task Scheduler / cron can detect failures. (The legacy `.bat` entry always exited 0.) - **Interpreter selection** (first match wins): `--php=`, the `GF_PHP` - environment variable, `phpPath` in `environment.json`, the PHP running gf. Any of these may + environment variable, `phpPath` in `config.json`, the PHP running gf. Any of these may include trailing arguments after the binary, e.g. `C:\path\php.exe -c C:\path\php.ini` (quote a binary or argument that contains spaces). - **It must be the CLI binary.** `php-cgi.exe` (what an IIS FastCGI handler mapping points at), @@ -147,14 +147,14 @@ installation exists. The `chrome-php/chrome` library is a framework dependency, ## Databases: `gf db:restore` and `gf db:run` -Connection strings come from `app/config/environment.json` (`mongoDatabases[]`) — never +Connection strings come from the unified `{root}/config.json` (`mongoDatabases[]`) — never hardcode credentials in scripts again. A variant name (`--from=prod`, `--env=prod`) resolves -that same `environment.json` with the variables from the gitignored overlay file -`app/config/{name}.env` applied on top of your local environment (see +that same `config.json` with the variables from the gitignored overlay file +`{root}/{name}.env` applied on top of your local environment (see [Environments](#environments-gf-env) below): ```ini -# app/config/prod.env (gitignored; start from prod.env.example) +# {root}/prod.env (gitignored; start from prod.env.example) APP_TYPE=prod MONGO_URI=mongodb+srv://user:pass@prod-cluster/ MONGO_DATABASE=app @@ -179,7 +179,7 @@ gf db:restore --keep-dump --dump-dir=db/backup - The plan (with passwords redacted) is shown and confirmed before anything runs; `--yes` skips. ``` -gf db:run db/create-admin.js # against the active environment.json default db +gf db:run db/create-admin.js # against the active config.json default db gf db:run db/migrate.js --env=prod --db=AppsSchedule gf db:run db/seed.js -- --quiet # everything after -- goes to mongosh ``` @@ -191,15 +191,15 @@ Requires [mongosh](https://www.mongodb.com/try/download/shell) on PATH. ## Environments: `gf env` Environment selection is **environment-variable driven**: the committed -`app/config/environment.json` references variables with `%env(...)%`, and whichever values the +the root `config.json` references variables with `%env(...)%`, and whichever values the process environment (container env, Docker secrets, or `{root}/.env`) supplies *are* the environment. There is nothing to activate or copy. `gf env` is the validator for that model: ``` -gf env # list app/config/*.env variants + validate the ACTIVE environment -gf env prod # resolve environment.json with the app/config/prod.env overlay and validate it +gf env # list {root}/*.env variants + validate the ACTIVE environment +gf env prod # resolve config.json with the prod.env overlay and validate it ``` `gf env ` prints the resolved summary (type, serverName, urls, databases with redacted @@ -209,18 +209,23 @@ your local value, so **an overlay file must define every environment-specific va ### Migrating a v6 app to v7 -v6's committed `environment-{env}.json` variants and the `gf env` copy step are gone. To move -an app onto v7: - -1. Commit a single `app/config/environment.json` (remove it from `.gitignore`) with every - secret and every per-environment value referenced via `%env(...)%` — see - [environment-variables.md](environment-variables.md) and the app template's copy. -2. For each old variant, create a gitignored `app/config/{env}.env` holding that environment's - variable values (start from the template's `prod.env.example`); gitignore - `app/config/*.env`. Local values go in `{root}/.env` (from `.env.example`). +v6's split `app/config/app.json` + `environment-{env}.json` files and the `gf env` copy step +are gone. To move an app onto v7: + +1. Commit a single **`config.json` at the application root**: merge the contents of the old + `app/config/app.json` (`app`, `email`, `settings` sections) and `app/config/environment.json` + (everything else) into one JSON object, with every secret and every per-environment value + referenced via `%env(...)%` — see [environment-variables.md](environment-variables.md) and + the app template's copy. Then delete the `app/config/` directory. +2. For each old variant, create a gitignored **`{env}.env` at the application root** holding + that environment's variable values (start from the template's `prod.env.example`); gitignore + `/*.env`. Local values go in `{root}/.env` (from `.env.example`). 3. Delete `environment-{env}.json`, `composer-{env}.json`, and `www/web-{env}.config`; commit `composer.json` (and a static `www/web.config`, if the app still runs on IIS). -4. Bump `gcgov/framework` to `^v7.0`; verify with `gf env` and `gf env prod`. +4. Replace `config::getAppConfig()` / `config::getEnvironmentConfig()` calls in app code with + the flattened accessors (`config::getBasePath()`, `config::getSettings()`, + `config::getMongoDatabases()`, …). +5. Bump `gcgov/framework` to `^v7.0`; verify with `gf env` and `gf env prod`. --- @@ -253,7 +258,7 @@ Steps: `git fetch/pull` → pick a tag (newest first, `--tags=N` to widen) → c `git checkout tags/` → `git submodule sync/update` → write `version.json` (`{"version": "", "inherit": true}`) → `composer update`. Any failing step aborts the deploy with that step's exit code. Configuration is committed -(`environment.json` + `%env()` values from the server's environment), so there is no +(`config.json` + `%env()` values from the server's environment), so there is no config-activation step. --- @@ -269,7 +274,7 @@ config-activation step. Completion is dynamic: `gf cli ` suggests the application's actual CLI routes (with descriptions), `gf env ` (and `db:restore --from=` etc.) suggests the variant overlay -files (`app/config/*.env`) present in the app. +files (`{root}/*.env`) present in the app. --- @@ -305,7 +310,7 @@ down gf itself (run with `-v` to see discovery errors). Useful helpers for custom commands (all in `\gcgov\framework\cli`): - `appContext::require()` / `appContext::locate()` — application root + config access -- `appContext->loadEnvironmentConfig($variant)` — resolve `environment.json` (with the `{variant}.env` overlay when a variant is named) +- `appContext->loadConfig($variant)` — resolve the root `config.json` (with the `{variant}.env` overlay when a variant is named) - `dotEnvLoader::parseFile($path)` — parse a dotenv file to an array without touching the process env - `mongoTools::findBinary()/redactUri()/uriWithDatabase()` - `phpProcess::findPhpBinary()/requiredIniFlags()/xdebugFlags()` @@ -333,10 +338,10 @@ Files an app can delete once migrated: `app/cli/local.bat`, `app/cli/local-debug scheduler entry references it (gf ships its own route runner). Reference any secrets that were hardcoded in those scripts via `%env(...)%` in the committed -`app/config/environment.json` — the `db:*` commands and the request lifecycle both resolve +the root `config.json` — the `db:*` commands and the request lifecycle both resolve them. Keep the actual values in the process environment, Docker/Kubernetes secrets, or a gitignored `.env` file (per-variant values for the `db:*` commands go in gitignored -`app/config/{env}.env` overlays). See **[Environment variables in config](environment-variables.md)** +`{root}/{env}.env` overlays). See **[Environment variables in config](environment-variables.md)** and **[Migrating a v6 app to v7](#migrating-a-v6-app-to-v7)** above. For example, instead of a plaintext URI: diff --git a/readme/mongodb.md b/readme/mongodb.md index 5c8d21b..f999fd4 100644 --- a/readme/mongodb.md +++ b/readme/mongodb.md @@ -8,7 +8,7 @@ You will primarily interact with this service through extended classes that mode will extend `\gcgov\framework\services\mongodb\model` or `\gcgov\framework\services\mongodb\embedded`. ## Config -`environment.json` +`{root}/config.json` (`mongoDatabases` section) ```json { "...": "...", diff --git a/src/cli/appContext.php b/src/cli/appContext.php index 63ab170..d8d6cfd 100644 --- a/src/cli/appContext.php +++ b/src/cli/appContext.php @@ -2,7 +2,7 @@ namespace gcgov\framework\cli; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; /** * Locates the consuming application's root directory and provides lazy access @@ -115,6 +115,12 @@ public function getConfigDir(): string { } + /** The unified {root}/config.json read by loadConfig(). */ + public function getConfigPath(): string { + return $this->rootDir . '/config.json'; + } + + public function getSrvDir(): string { return $this->rootDir . '/srv'; } @@ -153,12 +159,12 @@ public function getServiceNamespaces(): array { /** - * Parse app/config/environment.json directly — no \app boot, no ext-mongodb. + * Parse the unified {root}/config.json directly — no \app boot, no ext-mongodb. * * $variant '' → resolve against the ambient environment ({root}/.env is loaded * first; the real process environment wins). - * $variant 'name' → resolve the SAME environment.json with the variables from - * app/config/{name}.env applied as an overlay that takes precedence + * $variant 'name' → resolve the SAME config.json with the variables from + * {root}/{name}.env applied as an overlay that takes precedence * over the ambient environment — a foreign-environment read (used by * db:restore/db:run/env) without activating anything. Variables * missing from the overlay fall back to ambient values, so overlay @@ -166,23 +172,20 @@ public function getServiceNamespaces(): array { * * @throws \gcgov\framework\cli\cliException */ - public function loadEnvironmentConfig( string $variant = '' ): environmentConfig { - $file = $this->getEnvironmentConfigPath(); - $legacyFile = $this->getConfigDir() . '/environment-' . $variant . '.json'; - $legacyHint = $variant!=='' && file_exists( $legacyFile ) - ? ' A legacy ' . basename( $legacyFile ) . ' exists — this framework version reads variant values from app/config/{name}.env overlay files instead; see readme/gf.md "Migrating a v6 app to v7".' - : ''; + public function loadConfig( string $variant = '' ): unifiedConfig { + $file = $this->getConfigPath(); + $legacyHint = $this->legacyConfigHint( $variant ); if( !file_exists( $file ) ) { - throw new cliException( 'Missing environment config file: ' . $file . '. Commit an environment.json that references environment variables with %env(...) and supply values via the process environment or a .env file.' . $legacyHint ); + throw new cliException( 'Missing config file: ' . $file . '. Commit a config.json at the application root that references environment variables with %env(...) and supply values via the process environment or a .env file.' . $legacyHint ); } $overlayVars = []; $source = $file; if( $variant!=='' ) { - $overlayPath = $this->getEnvironmentOverlayPath( $variant ); + $overlayPath = $this->getVariantOverlayPath( $variant ); if( !file_exists( $overlayPath ) ) { - throw new cliException( 'Missing environment overlay file: ' . $overlayPath . '. Create it with the "' . $variant . '" environment\'s variable values (see app/config/prod.env.example in the app template).' . $legacyHint ); + throw new cliException( 'Missing environment overlay file: ' . $overlayPath . '. Create it with the "' . $variant . '" environment\'s variable values (see prod.env.example in the app template).' . $legacyHint ); } try { $overlayVars = \gcgov\framework\services\environment\dotEnvLoader::parseFile( $overlayPath ); @@ -190,7 +193,7 @@ public function loadEnvironmentConfig( string $variant = '' ): environmentConfig catch( \gcgov\framework\services\environment\environmentException $e ) { throw new cliException( $e->getMessage(), 0, $e ); } - $source = $this->describeEnvironmentConfigSource( $variant ); + $source = $this->describeConfigSource( $variant ); } \gcgov\framework\services\environment\dotEnvLoader::loadOnce( $this->rootDir ); @@ -203,7 +206,7 @@ public function loadEnvironmentConfig( string $variant = '' ): environmentConfig } try { - return environmentConfig::jsonDeserialize( $json ); + return unifiedConfig::jsonDeserialize( $json ); } catch( \andrewsauder\jsonDeserialize\exceptions\jsonDeserializeException $e ) { throw new cliException( 'Failed to parse ' . $file . ': ' . $e->getMessage(), 0, $e ); @@ -211,37 +214,55 @@ public function loadEnvironmentConfig( string $variant = '' ): environmentConfig } - public function getEnvironmentConfigPath(): string { - return $this->getConfigDir() . '/environment.json'; + /** + * Migration hint when pre-v7 config files are present (split app/config/app.json + + * environment{-variant}.json instead of the unified root config.json). + */ + private function legacyConfigHint( string $variant ): string { + $legacyFiles = [ + $this->getConfigDir() . '/environment.json', + $this->getConfigDir() . '/app.json', + ]; + if( $variant!=='' ) { + $legacyFiles[] = $this->getConfigDir() . '/environment-' . $variant . '.json'; + $legacyFiles[] = $this->getConfigDir() . '/' . $variant . '.env'; + } + foreach( $legacyFiles as $legacyFile ) { + if( file_exists( $legacyFile ) ) { + return ' A legacy app/config/' . basename( $legacyFile ) . ' exists — this framework version reads a single {root}/config.json (with {root}/{name}.env overlay files for variants); see readme/gf.md "Migrating a v6 app to v7".'; + } + } + + return ''; } - /** The per-variant overlay env file read by loadEnvironmentConfig($variant). */ - public function getEnvironmentOverlayPath( string $variant ): string { - return $this->getConfigDir() . '/' . $variant . '.env'; + /** The per-variant overlay env file read by loadConfig($variant). */ + public function getVariantOverlayPath( string $variant ): string { + return $this->rootDir . '/' . $variant . '.env'; } /** Human-readable description of where a variant's config comes from, for error/guard messages. */ - public function describeEnvironmentConfigSource( string $variant = '' ): string { + public function describeConfigSource( string $variant = '' ): string { if( $variant==='' ) { - return $this->getEnvironmentConfigPath(); + return $this->getConfigPath(); } - return $this->getEnvironmentConfigPath() . ' (overlay: ' . $this->getEnvironmentOverlayPath( $variant ) . ')'; + return $this->getConfigPath() . ' (overlay: ' . $this->getVariantOverlayPath( $variant ) . ')'; } /** - * Environment variant names available in app/config ({name}.env overlay files). + * Environment variant names available at the application root ({name}.env overlay files). * glob's `*` does not match a leading dot, and `*.env` does not match `*.env.example`, - * so a stray `.env` or the committed example file never appear as variants. + * so `.env`, `.env.local`, and the committed example file never appear as variants. * * @return string[] */ public function getEnvironmentVariants(): array { $variants = []; - foreach( glob( $this->getConfigDir() . '/*.env' ) ?: [] as $file ) { + foreach( glob( $this->rootDir . '/*.env' ) ?: [] as $file ) { $variants[] = basename( $file, '.env' ); } sort( $variants ); diff --git a/src/cli/commands/cliCommand.php b/src/cli/commands/cliCommand.php index 6f07d7c..d6b6f61 100644 --- a/src/cli/commands/cliCommand.php +++ b/src/cli/commands/cliCommand.php @@ -45,15 +45,15 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $context = appContext::require(); $context->assertAppLoadable(); - $environmentConfig = null; + $unifiedConfig = null; try { - $environmentConfig = $context->loadEnvironmentConfig(); + $unifiedConfig = $context->loadConfig(); } catch( cliException ) { // environment.json missing — the child process will report it through the framework lifecycle } - $commandLine = array_merge( phpProcess::findPhpBinary( $input->getOption( 'php' ), $environmentConfig ), phpProcess::requiredIniFlags() ); + $commandLine = array_merge( phpProcess::findPhpBinary( $input->getOption( 'php' ), $unifiedConfig ), phpProcess::requiredIniFlags() ); if( $input->getOption( 'debug' ) ) { $commandLine = array_merge( $commandLine, phpProcess::xdebugFlags( (string)$input->getOption( 'debug-host' ), (int)$input->getOption( 'debug-port' ) ) ); diff --git a/src/cli/commands/dbRestoreCommand.php b/src/cli/commands/dbRestoreCommand.php index df2932f..3d4de82 100644 --- a/src/cli/commands/dbRestoreCommand.php +++ b/src/cli/commands/dbRestoreCommand.php @@ -19,14 +19,14 @@ final class dbRestoreCommand extends Command { protected function configure(): void { - $this->addOption( 'from', null, InputOption::VALUE_REQUIRED, 'Source environment variant (resolves app/config/environment.json with the app/config/{from}.env overlay)', 'prod', envCommand::suggestEnvironments( ... ) ); - $this->addOption( 'to', null, InputOption::VALUE_REQUIRED, 'Target environment variant (resolved with the app/config/{to}.env overlay). Omit to use the active environment.', '', envCommand::suggestEnvironments( ... ) ); + $this->addOption( 'from', null, InputOption::VALUE_REQUIRED, 'Source environment variant (resolves the root config.json with the {from}.env overlay)', 'prod', envCommand::suggestEnvironments( ... ) ); + $this->addOption( 'to', null, InputOption::VALUE_REQUIRED, 'Target environment variant (resolved with the {to}.env overlay). Omit to use the active environment.', '', envCommand::suggestEnvironments( ... ) ); $this->addOption( 'db', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Restrict to the named database(s). Repeatable. Default: every database in the source config.' ); $this->addOption( 'dump-dir', null, InputOption::VALUE_REQUIRED, 'Directory to write the mongodump output to. Default: srv/tmp/mongodump-{timestamp}.' ); $this->addOption( 'keep-dump', null, InputOption::VALUE_NONE, 'Keep the dump directory after a successful restore' ); $this->addOption( 'yes', 'y', InputOption::VALUE_NONE, 'Skip the confirmation prompt' ); $this->addOption( 'allow-prod', null, InputOption::VALUE_NONE, 'Allow restoring INTO an environment whose type is "prod" (refused otherwise)' ); - $this->setHelp( 'Cross-platform replacement for the per-app restore-live-to-local.ps1: connection strings come from app/config/environment.json, resolved per variant with the app/config/{name}.env overlay files, instead of being hardcoded. Validate an overlay first with `gf env `. Requires the MongoDB Database Tools (mongodump/mongorestore) on PATH.' ); + $this->setHelp( 'Cross-platform replacement for the per-app restore-live-to-local.ps1: connection strings come from the root config.json, resolved per variant with the root-level {name}.env overlay files, instead of being hardcoded. Validate an overlay first with `gf env `. Requires the MongoDB Database Tools (mongodump/mongorestore) on PATH.' ); } @@ -40,8 +40,8 @@ protected function execute( InputInterface $input, OutputInterface $output ): in throw new cliException( '--from requires an environment variant name (e.g. --from=prod)' ); } - $sourceConfig = $context->loadEnvironmentConfig( $fromVariant ); - $targetConfig = $context->loadEnvironmentConfig( $toVariant ); + $sourceConfig = $context->loadConfig( $fromVariant ); + $targetConfig = $context->loadConfig( $toVariant ); // Guard by variant NAME first: the resolved `type` comes from an env var, so an // incomplete overlay (e.g. prod.env missing APP_TYPE) must not defeat the refusal. @@ -49,7 +49,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in throw new cliException( 'Refusing to restore into the environment variant named "prod". Pass --allow-prod if you really mean it.' ); } if( $targetConfig->type==='prod' && !$input->getOption( 'allow-prod' ) ) { - throw new cliException( 'Refusing to restore into an environment with type "prod" (' . $context->describeEnvironmentConfigSource( $toVariant ) . '). Pass --allow-prod if you really mean it.' ); + throw new cliException( 'Refusing to restore into an environment with type "prod" (' . $context->describeConfigSource( $toVariant ) . '). Pass --allow-prod if you really mean it.' ); } $pairs = self::pairDatabases( $sourceConfig->mongoDatabases, $targetConfig->mongoDatabases, $input->getOption( 'db' ) ); @@ -70,7 +70,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $mongodumpBinary = mongoTools::findBinary( 'mongodump' ); $mongorestoreBinary = mongoTools::findBinary( 'mongorestore' ); - $io->section( 'Restore plan (' . $fromVariant . ' -> ' . ( $toVariant===''?'active environment.json':$toVariant ) . ')' ); + $io->section( 'Restore plan (' . $fromVariant . ' -> ' . ( $toVariant===''?'active config.json':$toVariant ) . ')' ); foreach( $pairs[ 'matched' ] as [ $sourceDb, $targetDb ] ) { $io->text( ' ' . $sourceDb->database . ' @ ' . mongoTools::redactUri( $sourceDb->uri ) . ' -> ' . $targetDb->database . ' @ ' . mongoTools::redactUri( $targetDb->uri ) . ' (--drop)' ); } diff --git a/src/cli/commands/dbRunCommand.php b/src/cli/commands/dbRunCommand.php index 84a8453..1af6a26 100644 --- a/src/cli/commands/dbRunCommand.php +++ b/src/cli/commands/dbRunCommand.php @@ -19,7 +19,7 @@ final class dbRunCommand extends Command { protected function configure(): void { $this->addArgument( 'script', InputArgument::REQUIRED, 'Path to the .js script to execute with mongosh' ); $this->addArgument( 'mongoshArgs', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Extra arguments passed through to mongosh (prefix with --)' ); - $this->addOption( 'env', null, InputOption::VALUE_REQUIRED, 'Environment variant to read the connection from (resolves app/config/environment.json with the app/config/{env}.env overlay). Omit to use the active environment.', '', envCommand::suggestEnvironments( ... ) ); + $this->addOption( 'env', null, InputOption::VALUE_REQUIRED, 'Environment variant to read the connection from (resolves the root config.json with the {env}.env overlay). Omit to use the active environment.', '', envCommand::suggestEnvironments( ... ) ); $this->addOption( 'db', null, InputOption::VALUE_REQUIRED, 'Database name from the mongoDatabases config to run against. Default: the entry marked default (or the only entry).' ); $this->setHelp( 'Replaces per-script mongosh invocations with hardcoded connection strings, e.g.: gf db:run db/create-admin.js --env=local. Everything after -- is forwarded to mongosh.' ); } @@ -33,7 +33,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in throw new cliException( 'Script not found: ' . $scriptPath ); } - $environmentConfig = $context->loadEnvironmentConfig( (string)$input->getOption( 'env' ) ); + $environmentConfig = $context->loadConfig( (string)$input->getOption( 'env' ) ); $databaseName = (string)( $input->getOption( 'db' ) ?? '' ); $mongoDatabase = null; diff --git a/src/cli/commands/envCommand.php b/src/cli/commands/envCommand.php index 60c6166..f7e1a74 100644 --- a/src/cli/commands/envCommand.php +++ b/src/cli/commands/envCommand.php @@ -17,8 +17,8 @@ final class envCommand extends Command { protected function configure(): void { - $this->addArgument( 'environment', InputArgument::OPTIONAL, 'Variant to validate (app/config/{name}.env). Omit to list variants and check the active environment.', null, self::suggestEnvironments( ... ) ); - $this->setHelp( 'Environment selection is environment-variable driven: app/config/environment.json references variables with %env(...), and the process environment / {root}/.env supplies the values. This command validates that resolution. `gf env ` resolves environment.json with the app/config/{name}.env overlay applied — use it to prove an overlay (e.g. prod.env, used by db:restore/db:run) defines every variable it needs before relying on it.' ); + $this->addArgument( 'environment', InputArgument::OPTIONAL, 'Variant to validate ({root}/{name}.env). Omit to list variants and check the active environment.', null, self::suggestEnvironments( ... ) ); + $this->setHelp( 'Environment selection is environment-variable driven: the root config.json references variables with %env(...), and the process environment / {root}/.env supplies the values. This command validates that resolution. `gf env ` resolves config.json with the {root}/{name}.env overlay applied — use it to prove an overlay (e.g. prod.env, used by db:restore/db:run) defines every variable it needs before relying on it.' ); } @@ -31,15 +31,15 @@ protected function execute( InputInterface $input, OutputInterface $output ): in if( $environment==='' ) { $variants = $context->getEnvironmentVariants(); $io->text( count( $variants )===0 - ? 'No variant overlay files found in app/config (create app/config/{name}.env — see prod.env.example in the app template).' - : 'Variant overlay files in app/config: ' . implode( ', ', array_map( fn( string $v ) => $v . '.env', $variants ) ) ); + ? 'No variant overlay files found at the application root (create {name}.env — see prod.env.example in the app template).' + : 'Variant overlay files: ' . implode( ', ', array_map( fn( string $v ) => $v . '.env', $variants ) ) ); - $io->section( 'Active environment (app/config/environment.json + ambient environment)' ); + $io->section( 'Active environment (config.json + ambient environment)' ); return $this->validate( $context, '', $io ); } - $io->section( 'Variant "' . $environment . '" (' . $context->describeEnvironmentConfigSource( $environment ) . ')' ); + $io->section( 'Variant "' . $environment . '" (' . $context->describeConfigSource( $environment ) . ')' ); return $this->validate( $context, $environment, $io ); } @@ -47,7 +47,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in private function validate( appContext $context, string $variant, SymfonyStyle $io ): int { try { - $environmentConfig = $context->loadEnvironmentConfig( $variant ); + $environmentConfig = $context->loadConfig( $variant ); } catch( cliException $e ) { $io->error( $e->getMessage() ); diff --git a/src/cli/internal/run-route.php b/src/cli/internal/run-route.php index 0e4a5c9..d12904c 100644 --- a/src/cli/internal/run-route.php +++ b/src/cli/internal/run-route.php @@ -32,7 +32,7 @@ }; if( PHP_SAPI!=='cli' ) { - $gfWriteError( 'application CLI routes must run under the PHP CLI binary, but this process is running the "' . PHP_SAPI . '" SAPI. Point gf at php/php.exe instead of php-cgi/php-fpm with `gf cli --php=`, the GF_PHP environment variable, or "phpPath" in app/config/environment.json.' ); + $gfWriteError( 'application CLI routes must run under the PHP CLI binary, but this process is running the "' . PHP_SAPI . '" SAPI. Point gf at php/php.exe instead of php-cgi/php-fpm with `gf cli --php=`, the GF_PHP environment variable, or "phpPath" in config.json.' ); exit( 2 ); } diff --git a/src/cli/mongoTools.php b/src/cli/mongoTools.php index 676a892..8df7280 100644 --- a/src/cli/mongoTools.php +++ b/src/cli/mongoTools.php @@ -6,7 +6,7 @@ /** * Shared helpers for the gf db:* commands. These commands read connection info - * from app/config/environment{-variant}.json and shell out to the MongoDB + * from the resolved config.json and shell out to the MongoDB * command line tools — no ext-mongodb required. */ final class mongoTools { diff --git a/src/cli/phpProcess.php b/src/cli/phpProcess.php index 8ce1787..73256c6 100644 --- a/src/cli/phpProcess.php +++ b/src/cli/phpProcess.php @@ -2,7 +2,7 @@ namespace gcgov\framework\cli; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; use Symfony\Component\Process\PhpExecutableFinder; /** @@ -25,7 +25,7 @@ final class phpProcess { * Priority: * 1. --php option * 2. GF_PHP environment variable - * 3. environmentConfig->phpPath (a directory per the setup convention — php/php.exe appended; + * 3. config.json phpPath (a directory per the setup convention — php/php.exe appended; * a full binary path, optionally followed by CLI arguments, is also accepted) * 4. Symfony PhpExecutableFinder / PHP_BINARY (the interpreter running gf) * @@ -40,7 +40,7 @@ final class phpProcess { * @return string[] Command array — first element is the binary, remaining elements are arguments. * @throws \gcgov\framework\cli\cliException */ - public static function findPhpBinary( ?string $optionValue = null, ?environmentConfig $environmentConfig = null ): array { + public static function findPhpBinary( ?string $optionValue = null, ?unifiedConfig $unifiedConfig = null ): array { $candidates = []; if( $optionValue!==null && $optionValue!=='' ) { @@ -52,8 +52,8 @@ public static function findPhpBinary( ?string $optionValue = null, ?environmentC $candidates[ $envValue ] = 'GF_PHP environment variable'; } - if( $environmentConfig!==null && $environmentConfig->phpPath!=='' ) { - $candidates[ $environmentConfig->phpPath ] = 'environment.json phpPath'; + if( $unifiedConfig!==null && $unifiedConfig->phpPath!=='' ) { + $candidates[ $unifiedConfig->phpPath ] = 'config.json phpPath'; } foreach( $candidates as $candidate => $sourceDescription ) { @@ -109,7 +109,7 @@ private static function requireCliBinary( array $command, string $sourceDescript return $command; } - throw new cliException( 'PHP binary from ' . $sourceDescription . ' is not the CLI interpreter: ' . $binary . '. gf runs application code through the PHP CLI binary (php/php.exe) — php-cgi, php-fpm, and php-win cannot run CLI routes ($argv and STDERR are unavailable there). No CLI binary was found beside it, so point --php, GF_PHP, or environment.json phpPath at php.exe or the directory containing it.' ); + throw new cliException( 'PHP binary from ' . $sourceDescription . ' is not the CLI interpreter: ' . $binary . '. gf runs application code through the PHP CLI binary (php/php.exe) — php-cgi, php-fpm, and php-win cannot run CLI routes ($argv and STDERR are unavailable there). No CLI binary was found beside it, so point --php, GF_PHP, or config.json phpPath at php.exe or the directory containing it.' ); } diff --git a/src/cli/routeCatalog.php b/src/cli/routeCatalog.php index cd1eb77..b07e559 100644 --- a/src/cli/routeCatalog.php +++ b/src/cli/routeCatalog.php @@ -40,7 +40,7 @@ public static function getAllRoutes( appContext $context ): array { return \gcgov\framework\router::getMergedRoutes( $context->getServiceNamespaces() ); } catch( \gcgov\framework\exceptions\configException $e ) { - throw new cliException( 'Could not load routes: ' . $e->getMessage() . ' Ensure app/config/environment.json exists and every %env(...) it references has a value (validate with `gf env`).', 0, $e ); + throw new cliException( 'Could not load routes: ' . $e->getMessage() . ' Ensure {root}/config.json exists and every %env(...) it references has a value (validate with `gf env`).', 0, $e ); } catch( \gcgov\framework\exceptions\routeException $e ) { throw new cliException( 'Could not load routes: ' . $e->getMessage(), 0, $e ); diff --git a/src/config.php b/src/config.php index e277ddf..546bb11 100644 --- a/src/config.php +++ b/src/config.php @@ -3,27 +3,39 @@ namespace gcgov\framework; -use gcgov\framework\models\appConfig; -use gcgov\framework\models\environmentConfig; - - +use gcgov\framework\models\config\app\app; +use gcgov\framework\models\config\app\email; +use gcgov\framework\models\config\app\settings; +use gcgov\framework\models\config\environment\jwtAuth; +use gcgov\framework\models\config\environment\logging; +use gcgov\framework\models\config\environment\microsoft; +use gcgov\framework\models\config\environment\payjunction; +use gcgov\framework\models\config\environment\sqlDatabase; +use gcgov\framework\models\unifiedConfig; + + +/** + * Static configuration access for the application. + * + * Paths are derived by reflecting \app\app's file location. Configuration values come + * from the single {root}/config.json (the v7 merge of the former app/config/app.json + * and app/config/environment.json), resolved with %env(...) environment-variable + * references, and are exposed directly on this class — e.g. config::getBasePath(), + * config::getMongoDatabases(), config::getEmail(). + */ final class config { private static string $rootDir = ''; private static string $appDir = ''; - private static string $configDir = ''; - private static string $modelsDir = ''; private static string $servicesDir = ''; private static string $srvDir = ''; - private static appConfig $appConfig; - - private static environmentConfig $environmentConfig; + private static unifiedConfig $unifiedConfig; public static function getTempDir(): string { @@ -81,21 +93,6 @@ private static function setModelsDir(): void { } - - public static function getConfigDir(): string { - if( self::$configDir==='' ) { - self::setConfigDir(); - } - - return self::$configDir; - } - - - private static function setConfigDir(): void { - self::$configDir = self::getAppDir() . '/config/'; - } - - public static function getServicesDir(): string { if( self::$servicesDir==='' ) { self::setServicesDir(); @@ -125,72 +122,173 @@ private static function setSrvDir(): void { /** - * @return \gcgov\framework\models\appConfig + * The absolute path of the unified config file. + */ + public static function getConfigFilePath(): string { + return self::getRootDir() . '/config.json'; + } + + + /** * @throws \gcgov\framework\exceptions\configException */ - public static function getAppConfig(): appConfig { - if( !isset( self::$appConfig ) ) { - self::setAppConfig(); + private static function unifiedConfig(): unifiedConfig { + if( !isset( self::$unifiedConfig ) ) { + self::setUnifiedConfig(); } - return self::$appConfig; + return self::$unifiedConfig; } /** * @throws \gcgov\framework\exceptions\configException */ - private static function setAppConfig(): void { - $appDir = self::getAppDir(); - $appConfigFile = $appDir . '/config/app.json'; - if( !file_exists( $appConfigFile ) ) { - throw new \gcgov\framework\exceptions\configException( 'Missing app config file at ' . $appConfigFile ); + private static function setUnifiedConfig(): void { + $configFile = self::getConfigFilePath(); + if( !file_exists( $configFile ) ) { + throw new \gcgov\framework\exceptions\configException( 'Missing config file at ' . $configFile ); } \gcgov\framework\services\environment\dotEnvLoader::loadOnce( self::getRootDir() ); try { - $json = \gcgov\framework\services\environment\envVarResolver::resolveJson( (string)file_get_contents( $appConfigFile ), $appConfigFile ); + $json = \gcgov\framework\services\environment\envVarResolver::resolveJson( (string)file_get_contents( $configFile ), $configFile ); } catch( \gcgov\framework\services\environment\environmentException $e ) { throw new \gcgov\framework\exceptions\configException( $e->getMessage(), 500, $e ); } - self::$appConfig = appConfig::jsonDeserialize( $json ); + self::$unifiedConfig = unifiedConfig::jsonDeserialize( $json ); + } + + + // --- application identity (formerly app.json) --- + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getApp(): app { + return self::unifiedConfig()->app; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getEmail(): email { + return self::unifiedConfig()->email; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getSettings(): settings { + return self::unifiedConfig()->settings; + } + + + // --- environment (formerly environment.json) --- + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getType(): string { + return self::unifiedConfig()->type; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function isLocal(): bool { + return self::unifiedConfig()->isLocal(); + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getServerName(): string { + return self::unifiedConfig()->serverName; + } + + + /** Normalized (no trailing slash). @throws \gcgov\framework\exceptions\configException */ + public static function getRootUrl(): string { + return self::unifiedConfig()->getRootUrl(); + } + + + /** {rootUrl}/{basePath}. @throws \gcgov\framework\exceptions\configException */ + public static function getBaseUrl(): string { + return self::unifiedConfig()->getBaseUrl(); + } + + + /** Normalized '/api' style ('/' at domain root). @throws \gcgov\framework\exceptions\configException */ + public static function getBasePath(): string { + return self::unifiedConfig()->getBasePath(); + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getCookieUrl(): string { + return self::unifiedConfig()->cookieUrl; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getPhpPath(): string { + return self::unifiedConfig()->phpPath; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getLogging(): logging { + return self::unifiedConfig()->logging; } /** - * @return \gcgov\framework\models\environmentConfig + * @return \gcgov\framework\models\config\environment\mongoDatabase[] * @throws \gcgov\framework\exceptions\configException */ - public static function getEnvironmentConfig(): environmentConfig { - if( !isset( self::$environmentConfig ) ) { - self::setEnvironmentConfig(); - } - - return self::$environmentConfig; + public static function getMongoDatabases(): array { + return self::unifiedConfig()->mongoDatabases; } /** + * @return \gcgov\framework\models\config\environment\sqlDatabase[] * @throws \gcgov\framework\exceptions\configException */ - private static function setEnvironmentConfig(): void { - $appDir = self::getAppDir(); - $environmentConfigFile = $appDir . '/config/environment.json'; - if( !file_exists( $environmentConfigFile ) ) { - throw new \gcgov\framework\exceptions\configException( 'Missing environment config file at ' . $environmentConfigFile ); - } + public static function getSqlDatabases(): array { + return self::unifiedConfig()->sqlDatabases; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getDefaultSqlDatabase(): ?sqlDatabase { + return self::unifiedConfig()->getDefaultSqlDatabase(); + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getSqlDatabaseByName( string $name ): ?sqlDatabase { + return self::unifiedConfig()->getSqlDatabaseByName( $name ); + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getMicrosoft(): microsoft { + return self::unifiedConfig()->microsoft; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getJwtAuth(): jwtAuth { + return self::unifiedConfig()->jwtAuth; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getPayjunction(): payjunction { + return self::unifiedConfig()->payjunction; + } - \gcgov\framework\services\environment\dotEnvLoader::loadOnce( self::getRootDir() ); - try { - $json = \gcgov\framework\services\environment\envVarResolver::resolveJson( (string)file_get_contents( $environmentConfigFile ), $environmentConfigFile ); - } - catch( \gcgov\framework\services\environment\environmentException $e ) { - throw new \gcgov\framework\exceptions\configException( $e->getMessage(), 500, $e ); - } - self::$environmentConfig = environmentConfig::jsonDeserialize( $json ); + /** @throws \gcgov\framework\exceptions\configException */ + public static function getAppDictionary(): array { + return self::unifiedConfig()->appDictionary; } } diff --git a/src/models/appConfig.php b/src/models/appConfig.php deleted file mode 100644 index 075c343..0000000 --- a/src/models/appConfig.php +++ /dev/null @@ -1,25 +0,0 @@ -app = new app(); + $this->email = new email(); + $this->settings = new settings(); $this->microsoft = new microsoft(); $this->jwtAuth = new jwtAuth(); $this->payjunction = new payjunction(); - $this->logging = new logging(); + $this->logging = new logging(); } protected function _afterJsonDeserialize(): void { @@ -56,17 +81,10 @@ protected function _afterJsonDeserialize(): void { // constructor, leaving typed-non-nullable properties uninitialized. // Use reflection so we can ask the engine about init state without // PHPStan narrowing the check away. - if( !( new \ReflectionProperty( $this, 'microsoft' ) )->isInitialized( $this ) ) { - $this->microsoft = new microsoft(); - } - if( !( new \ReflectionProperty( $this, 'jwtAuth' ) )->isInitialized( $this ) ) { - $this->jwtAuth = new jwtAuth(); - } - if( !( new \ReflectionProperty( $this, 'payjunction' ) )->isInitialized( $this ) ) { - $this->payjunction = new payjunction(); - } - if( !( new \ReflectionProperty( $this, 'logging' ) )->isInitialized( $this ) ) { - $this->logging = new logging(); + foreach( [ 'app' => app::class, 'email' => email::class, 'settings' => settings::class, 'microsoft' => microsoft::class, 'jwtAuth' => jwtAuth::class, 'payjunction' => payjunction::class, 'logging' => logging::class ] as $property => $class ) { + if( !( new \ReflectionProperty( $this, $property ) )->isInitialized( $this ) ) { + $this->$property = new $class(); + } } } diff --git a/src/renderer.php b/src/renderer.php index 9920ab9..75ada1f 100644 --- a/src/renderer.php +++ b/src/renderer.php @@ -82,7 +82,7 @@ private function getContentFromController( routeHandler $routeHandler ): control } catch( modelException $e ) { \error_log( $e ); - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::debug( 'Renderer', $e->getMessage(), $e->getTrace() ); } @@ -90,7 +90,7 @@ private function getContentFromController( routeHandler $routeHandler ): control } catch( controllerException $e ) { \error_log( $e ); - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::debug( 'Renderer', $e->getMessage(), $e->getTrace() ); } @@ -98,7 +98,7 @@ private function getContentFromController( routeHandler $routeHandler ): control } catch( \Exception|\Error|\ErrorException $e ) { \error_log( $e ); - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::error( 'Renderer', $e->getMessage(), [ $e ] ); } @@ -107,7 +107,7 @@ private function getContentFromController( routeHandler $routeHandler ): control } catch( \ReflectionException $e ) { error_log( $e ); - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::error( 'Renderer', $e->getMessage(), [ $e ] ); } @@ -128,7 +128,7 @@ private function processControllerDataResponse( \gcgov\framework\interfaces\_con } } else { - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::warning( 'Renderer', 'Cannot set content-type header or additional headers. Headers already sent in ' . $filename . ' on line ' . $lineNumber ); } } @@ -190,7 +190,7 @@ private function processControllerFileResponse( \gcgov\framework\interfaces\_con } } else { - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::warning( 'Renderer', 'Cannot set content-type header or additional headers. Headers already sent in ' . $fileBasename . ' on line ' . $lineNumber ); } } @@ -240,7 +240,7 @@ private function processControllerFileBase64EncodedContentResponse( \gcgov\frame } } else { - if(config::getEnvironmentConfig()->logging->renderer) { + if(config::getLogging()->renderer) { \gcgov\framework\services\log::warning( 'Renderer', 'Cannot set content-type header or additional headers. Headers already sent in ' . $fileBasename . ' on line ' . $lineNumber ); } } diff --git a/src/router.php b/src/router.php index 9fd98bf..101d969 100644 --- a/src/router.php +++ b/src/router.php @@ -19,14 +19,14 @@ final class router { * @throws \gcgov\framework\exceptions\routeException */ public function __construct( array $serviceNamespaces ) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- constructing framework\router' ); log::debug( 'Framework Lifecycle', '-Router- check for routers in services' ); } foreach($serviceNamespaces as $serviceNamespace) { try { $reflectionClassOfServiceRouter = new ReflectionClass( $serviceNamespace . '\router' ); - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- instantiate ' . $serviceNamespace . '\router' ); } $serviceRouter = $reflectionClassOfServiceRouter->newInstance(); @@ -41,7 +41,7 @@ public function __construct( array $serviceNamespaces ) { } } - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- create \app\router' ); } $this->appRouter = new \app\router(); @@ -53,7 +53,7 @@ public function __construct( array $serviceNamespaces ) { * @throws \gcgov\framework\exceptions\routeException */ public function route(): \gcgov\framework\models\routeHandler { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- running framework\router route()' ); } @@ -67,7 +67,7 @@ public function route(): \gcgov\framework\models\routeHandler { } } ); - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- determine route' ); } $routeInfo = $routeDispatcher->dispatch( $this->getHttpMethod(), $this->getUri() ); @@ -79,7 +79,7 @@ public function route(): \gcgov\framework\models\routeHandler { // ... 405 Method Not Allowed throw new \gcgov\framework\exceptions\routeException ( 'Method Not Allowed', 405 ); case \FastRoute\Dispatcher::FOUND: - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- found matching route' ); } //build route handler to return to the framework renderer @@ -87,22 +87,22 @@ public function route(): \gcgov\framework\models\routeHandler { $routeHandler = $routeInfo[ 1 ]; $routeHandler->arguments = $routeInfo[ 2 ]; - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- running framework authentication' ); } if( !$routeHandler->authentication ) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- no authentication required for route' ); } return $routeHandler; } - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- run app\router authentication()' ); } $appAllowRoute = $this->appRouter->authentication( $routeHandler ); if( !$appAllowRoute ) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- app\router authentication() returned false; raising route exception' ); } throw new \gcgov\framework\exceptions\routeException ( 'Authentication failed', 401 ); @@ -113,16 +113,16 @@ public function route(): \gcgov\framework\models\routeHandler { $runServiceRouting = $this->appRouter->getRunFrameworkServiceRouteAuthentication( $routeHandler ); } if($runServiceRouting) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- run service routers authentication()' ); } foreach($this->serviceRouters as $serviceRouter) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- run framework\services\\' . get_class( $serviceRouter ) . '\router authentication()' ); } $serviceAllowRoute = $serviceRouter->authentication( $routeHandler ); if(!$serviceAllowRoute) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- framework\services\\' . get_class( $serviceRouter ) . '\router authentication() returned false; raising route exception' ); } throw new \gcgov\framework\exceptions\routeException ( 'Authentication failed', 401 ); @@ -130,7 +130,7 @@ public function route(): \gcgov\framework\models\routeHandler { } } - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- return route handler to framework\framework' ); } //return rendered @@ -165,14 +165,14 @@ private function getRoutes(): array { $routes = []; foreach($this->serviceRouters as $serviceRouter) { - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- get service routes' ); } $serviceRoutes = $serviceRouter->getRoutes(); $routes = array_merge( $routes, $serviceRoutes ); } - if(config::getEnvironmentConfig()->logging->lifecycle) { + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- get app routes' ); } $appRoutes = $this->appRouter->getRoutes(); diff --git a/src/services/jwtAuth/jwtAuth.php b/src/services/jwtAuth/jwtAuth.php index 4c11660..f542962 100644 --- a/src/services/jwtAuth/jwtAuth.php +++ b/src/services/jwtAuth/jwtAuth.php @@ -37,13 +37,13 @@ public function __construct( ?string $guid = null ) { $this->keyPath = dirname( __FILE__ ) . '/jwtCertificates/'; } - //env config - $envConfig = config::getEnvironmentConfig(); - if( !isset( $envConfig->jwtAuth ) || empty( $envConfig->jwtAuth->tokenIssuedBy ) || empty( $envConfig->jwtAuth->tokenPermittedFor ) ) { - throw new configException( 'Missing "auth" section of /app/config/environment.json' ); + //jwt config + $jwtAuthConfig = config::getJwtAuth(); + if( empty( $jwtAuthConfig->tokenIssuedBy ) || empty( $jwtAuthConfig->tokenPermittedFor ) ) { + throw new configException( 'Missing "jwtAuth" section of /config.json' ); } - $this->issuedBy = $envConfig->jwtAuth->tokenIssuedBy; - $this->permittedFor = $envConfig->jwtAuth->tokenPermittedFor; + $this->issuedBy = $jwtAuthConfig->tokenIssuedBy; + $this->permittedFor = $jwtAuthConfig->tokenPermittedFor; //guid config if( !file_exists( $this->keyPath . 'guids.json' ) ) { diff --git a/src/services/log.php b/src/services/log.php index e280636..edd3217 100644 --- a/src/services/log.php +++ b/src/services/log.php @@ -65,7 +65,7 @@ private static function getLogger( string $channel = '' ): Logger { if( empty( $channel ) ) { try { - $channel = \gcgov\framework\config::getAppConfig()->app->title; + $channel = \gcgov\framework\config::getApp()->title; if( $channel === '' ) { $channel = 'app'; } diff --git a/src/services/microsoft/auth.php b/src/services/microsoft/auth.php index 1ae095b..a5789d7 100644 --- a/src/services/microsoft/auth.php +++ b/src/services/microsoft/auth.php @@ -15,7 +15,7 @@ class auth { public function __construct() { - $this->provider = new \TheNetworg\OAuth2\Client\Provider\Azure( (array) config::getEnvironmentConfig()->microsoft ); + $this->provider = new \TheNetworg\OAuth2\Client\Provider\Azure( (array) config::getMicrosoft() ); } @@ -86,11 +86,11 @@ public function getApplicationAccessToken() : string { //get application access token try { $guzzle = new \GuzzleHttp\Client(); - $url = 'https://login.microsoftonline.com/' . config::getEnvironmentConfig()->microsoft->tenant . '/oauth2/token?api-version=1.0'; + $url = 'https://login.microsoftonline.com/' . config::getMicrosoft()->tenant . '/oauth2/token?api-version=1.0'; $token = json_decode( $guzzle->post( $url, [ 'form_params' => [ - 'client_id' => config::getEnvironmentConfig()->microsoft->clientId, - 'client_secret' => config::getEnvironmentConfig()->microsoft->clientSecret, + 'client_id' => config::getMicrosoft()->clientId, + 'client_secret' => config::getMicrosoft()->clientSecret, 'resource' => 'https://graph.microsoft.com/', 'grant_type' => 'client_credentials', ], diff --git a/src/services/microsoft/files.php b/src/services/microsoft/files.php index 813aaed..332bfb2 100644 --- a/src/services/microsoft/files.php +++ b/src/services/microsoft/files.php @@ -69,7 +69,7 @@ public function getFile( array $microsoftPathParts ): \Microsoft\Graph\Model\Dri $graph->setAccessToken( $accessToken ); /** @var \Microsoft\Graph\Model\DriveItem $driveItem */ - $driveItem = $graph->createRequest( "GET", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . '/root:/' . $this->rootBasePath . implode( '/', $microsoftPathParts ) ) + $driveItem = $graph->createRequest( "GET", "/drives/" . config::getMicrosoft()->driveId . '/root:/' . $this->rootBasePath . implode( '/', $microsoftPathParts ) ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); return $driveItem; @@ -99,7 +99,7 @@ public function getFileById( string $itemId ): \Microsoft\Graph\Model\DriveItem $graph->setAccessToken( $accessToken ); /** @var \Microsoft\Graph\Model\DriveItem $driveItem */ - $driveItem = $graph->createRequest( "GET", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . '/items/' . $itemId ) + $driveItem = $graph->createRequest( "GET", "/drives/" . config::getMicrosoft()->driveId . '/items/' . $itemId ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); return $driveItem; @@ -126,7 +126,7 @@ public function moveItem( string $itemIdToMove, string $newParentDirItemId ) : \ $graph->setAccessToken( $accessToken ); /** @var \Microsoft\Graph\Model\DriveItem $driveItem */ - $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . "/items/" . $itemIdToMove ) + $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getMicrosoft()->driveId . "/items/" . $itemIdToMove ) ->attachBody( [ 'parentReference'=>[ 'id'=>$newParentDirItemId ]] ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); @@ -154,7 +154,7 @@ public function renameItem( string $itemIdToRename, string $newName ) : \Microso $graph->setAccessToken( $accessToken ); /** @var \Microsoft\Graph\Model\DriveItem $driveItem */ - $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . "/items/" . $itemIdToRename ) + $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getMicrosoft()->driveId . "/items/" . $itemIdToRename ) ->attachBody( [ 'name'=>$newName ] ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); @@ -189,8 +189,8 @@ public function downloadFile( string $itemId, string $pathToTempSave ): string { $graph->setAccessToken( $accessToken ); /** @var \Microsoft\Graph\Model\DriveItem $driveItem */ - $driveItem = $graph->createRequest( "GET", '/drives/' . config::getEnvironmentConfig()->microsoft->driveId . '/items/' . $itemId )->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); - $graph->createRequest( "GET", '/drives/' . config::getEnvironmentConfig()->microsoft->driveId . '/items/' . $itemId . '/content' )->download( $pathToTempSave . '/' . $itemId . '/' . $driveItem->getName() ); + $driveItem = $graph->createRequest( "GET", '/drives/' . config::getMicrosoft()->driveId . '/items/' . $itemId )->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); + $graph->createRequest( "GET", '/drives/' . config::getMicrosoft()->driveId . '/items/' . $itemId . '/content' )->download( $pathToTempSave . '/' . $itemId . '/' . $driveItem->getName() ); return $pathToTempSave . '/' . $itemId . '/' . $driveItem->getName(); } @@ -226,7 +226,7 @@ public function upload( string $serverFullFilePath, string $fileName, array $upl $graph = new \Microsoft\Graph\Graph(); $graph->setAccessToken( $accessToken ); - $fileEndpoint = "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . '/root:/' . $this->rootBasePath . implode( '/', $uploadPathParts ) . '/' . $fileName; + $fileEndpoint = "/drives/" . config::getMicrosoft()->driveId . '/root:/' . $this->rootBasePath . implode( '/', $uploadPathParts ) . '/' . $fileName; $fileSize = filesize( $serverFullFilePath ); @@ -240,7 +240,7 @@ public function upload( string $serverFullFilePath, string $fileName, array $upl if(!empty($fileDescription)) { try { - $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . '/items/' . $driveItem->getId() ) + $driveItem = $graph->createRequest( "PATCH", "/drives/" . config::getMicrosoft()->driveId . '/items/' . $driveItem->getId() ) ->attachBody( [ "description" => $fileDescription ] ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); } @@ -327,7 +327,7 @@ public function delete( string $itemId ) { $graph = new \Microsoft\Graph\Graph(); $graph->setAccessToken( $accessToken ); - $itemEndpoint = "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . "/items/" . $itemId; + $itemEndpoint = "/drives/" . config::getMicrosoft()->driveId . "/items/" . $itemId; try { $deleteRequest = $graph->createRequest( "DELETE", $itemEndpoint )->execute(); @@ -373,7 +373,7 @@ private function getMicrosoftDriveItems( $accessToken, string $path ): array { //get all the project folders /** @var \Microsoft\Graph\Model\DriveItem[] $driveItems */ - $driveItems = $graph->createRequest( "GET", "/drives/" . config::getEnvironmentConfig()->microsoft->driveId . '/root:/' . $this->rootBasePath . $path . ":/children" ) + $driveItems = $graph->createRequest( "GET", "/drives/" . config::getMicrosoft()->driveId . '/root:/' . $this->rootBasePath . $path . ":/children" ) ->setReturnType( \Microsoft\Graph\Model\DriveItem::class )->execute(); foreach( $driveItems as $i => $driveItem ) { diff --git a/src/services/microsoft/mail.php b/src/services/microsoft/mail.php index 8035b0b..939ce4d 100644 --- a/src/services/microsoft/mail.php +++ b/src/services/microsoft/mail.php @@ -65,7 +65,7 @@ public function send( string|array $to, string $subject, string $content, string } if( $from == '' ) { - $from = config::getEnvironmentConfig()->microsoft->fromAddress; + $from = config::getMicrosoft()->fromAddress; } $mailBody = [ diff --git a/src/services/mongodb/dispatcher.php b/src/services/mongodb/dispatcher.php index 13f1330..619d3ab 100644 --- a/src/services/mongodb/dispatcher.php +++ b/src/services/mongodb/dispatcher.php @@ -397,7 +397,7 @@ public static function _insertEmbedded( object $objectToInsert, ?\MongoDB\Driver * @throws \gcgov\framework\exceptions\modelException */ protected static function _runMongoActions( array $mongoActions, string $logChannel, ?\MongoDB\Driver\Session $mongoDbSession = null ): array { - $logging = config::getEnvironmentConfig()->type=='local'; + $logging = config::isLocal(); $sessionParent = false; @@ -435,7 +435,7 @@ protected static function _runMongoActions( array $mongoActions, string $logChan $updateInsertDeleteResults[] = new updateDeleteResult( $result ); //create index files in local environment - if( \gcgov\framework\config::getEnvironmentConfig()->isLocal() ) { + if( \gcgov\framework\config::isLocal() ) { foreach( $queries as $operations ) { foreach( $operations as $operationType => $filterUpdateOptions ) { $index = []; @@ -471,7 +471,7 @@ protected static function _runMongoActions( array $mongoActions, string $logChan } } - if( \gcgov\framework\config::getEnvironmentConfig()->isLocal() ) { + if( \gcgov\framework\config::isLocal() ) { if( count( self::$_indexesToCreate )>0 ) { $filename = \gcgov\framework\config::getTempDir() . '/create-indexes-' . microtime() . '.js'; diff --git a/src/services/mongodb/models/_meta.php b/src/services/mongodb/models/_meta.php index 184baa9..3803dd5 100644 --- a/src/services/mongodb/models/_meta.php +++ b/src/services/mongodb/models/_meta.php @@ -77,8 +77,8 @@ public function jsonSerialize(): array { ]; //TODO: make sure this is using the actual database it's being used for - if( isset( config::getEnvironmentConfig()->mongoDatabases[ 0 ] ) ) { - $mdbConfig = config::getEnvironmentConfig()->mongoDatabases[ 0 ]; + if( isset( config::getMongoDatabases()[ 0 ] ) ) { + $mdbConfig = config::getMongoDatabases()[ 0 ]; if( $mdbConfig->include_metaLabels ) { $export[ 'labels' ] = $this->labels; } diff --git a/src/services/mongodb/models/auth/user.php b/src/services/mongodb/models/auth/user.php index c7c8d9f..8ad9295 100644 --- a/src/services/mongodb/models/auth/user.php +++ b/src/services/mongodb/models/auth/user.php @@ -31,7 +31,7 @@ class user public function __construct() { parent::__construct(); $this->_id = new \MongoDB\BSON\ObjectId(); - if( config::getAppConfig()->settings->forceMfaForPasswordUsers ) { + if( config::getSettings()->forceMfaForPasswordUsers ) { $this->mfaRequired = true; } } @@ -170,7 +170,7 @@ public function getId(): \MongoDB\BSON\ObjectId { protected function _beforeBsonSerialize(): void { - if( config::getAppConfig()->settings->forceMfaForPasswordUsers ) { + if( config::getSettings()->forceMfaForPasswordUsers ) { $this->mfaRequired = true; } @@ -196,7 +196,7 @@ protected function _beforeBsonSerialize(): void { protected function _afterBsonUnserialize( $rawBsonData ): void { - if( config::getAppConfig()->settings->forceMfaForPasswordUsers ) { + if( config::getSettings()->forceMfaForPasswordUsers ) { $this->mfaRequired = true; } } diff --git a/src/services/mongodb/tools/log.php b/src/services/mongodb/tools/log.php index f3ba85a..055a66d 100644 --- a/src/services/mongodb/tools/log.php +++ b/src/services/mongodb/tools/log.php @@ -83,12 +83,12 @@ public static function emergency( string $channel, string $message, array $conte private static function isMongoLoggingEnabled(): bool { try { - $envConfig = config::getEnvironmentConfig(); + $mongoDatabases = config::getMongoDatabases(); } catch( \gcgov\framework\exceptions\configException $e ) { return false; } - return isset( $envConfig->mongoDatabases[ 0 ] ) && $envConfig->mongoDatabases[ 0 ]->logging; + return isset( $mongoDatabases[ 0 ] ) && $mongoDatabases[ 0 ]->logging; } @@ -102,7 +102,7 @@ private static function getLogger( string $channel = '' ): Logger { if( $channel === '' ) { try { - $channel = \gcgov\framework\config::getAppConfig()->app->title; + $channel = \gcgov\framework\config::getApp()->title; if( $channel === '' ) { $channel = 'app'; } diff --git a/src/services/mongodb/tools/mdb.php b/src/services/mongodb/tools/mdb.php index 83f1d0c..b8df99d 100644 --- a/src/services/mongodb/tools/mdb.php +++ b/src/services/mongodb/tools/mdb.php @@ -118,9 +118,7 @@ private function addEncryptionDriverOptions( mongoDatabase $connector, string $c * @throws \gcgov\framework\exceptions\modelException */ public function getConnector( string $database = '' ): mongoDatabase { - $environmentConfig = config::getEnvironmentConfig(); - - foreach( $environmentConfig->mongoDatabases as $mongoDatabase ) { + foreach( config::getMongoDatabases() as $mongoDatabase ) { if( $mongoDatabase->default && $database==='' ) { return $mongoDatabase; } diff --git a/src/services/pdodb/pdodb.php b/src/services/pdodb/pdodb.php index 28b9f78..d8b006a 100644 --- a/src/services/pdodb/pdodb.php +++ b/src/services/pdodb/pdodb.php @@ -16,16 +16,16 @@ class pdodb extends PDO { * @throws \PDOException */ public function __construct( bool $readOnly=true, string $databaseName='' ) { - $envConfig = config::getEnvironmentConfig(); + $sqlDatabases = config::getSqlDatabases(); - if(count($envConfig->sqlDatabases)===0) { + if(count($sqlDatabases)===0) { throw new \PDOException('No database connectors are defined in the app environment config'); } //find the matching database /** @var ?\gcgov\framework\models\config\environment\sqlDatabase $useSqlDatabase */ $useSqlDatabase = null; - foreach($envConfig->sqlDatabases as $sqlDatabase) { + foreach($sqlDatabases as $sqlDatabase) { if($databaseName==='' && $sqlDatabase->default) { $useSqlDatabase = $sqlDatabase; break; diff --git a/tests/Unit/Cli/AppContextTest.php b/tests/Unit/Cli/AppContextTest.php index c2fd5ce..1097b50 100644 --- a/tests/Unit/Cli/AppContextTest.php +++ b/tests/Unit/Cli/AppContextTest.php @@ -95,38 +95,38 @@ public function testDirectoryAccessors(): void { $this->assertSame( $root . '/vendor/autoload.php', $context->getVendorAutoloadPath() ); } - public function testLoadEnvironmentConfigParsesActiveFile(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment.json', json_encode( [ + public function testLoadConfigParsesActiveFile(): void { + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => 'mongodb://u:p@h:27017/widgets' ] ], ] ) ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); - $environmentConfig = $context->loadEnvironmentConfig(); + $environmentConfig = $context->loadConfig(); $this->assertSame( 'prod', $environmentConfig->type ); $this->assertCount( 1, $environmentConfig->mongoDatabases ); $this->assertSame( 'widgets', $environmentConfig->mongoDatabases[0]->database ); } - public function testLoadEnvironmentConfigThrowsWhenMissing(): void { + public function testLoadConfigThrowsWhenMissing(): void { $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); $this->expectException( cliException::class ); - $context->loadEnvironmentConfig(); + $context->loadConfig(); } - public function testLoadEnvironmentConfigResolvesEnvVars(): void { + public function testLoadConfigResolvesEnvVars(): void { $_ENV[ 'TEST_MONGO_URI' ] = 'mongodb://resolved:27017/widgets'; putenv( 'TEST_MONGO_URI=mongodb://resolved:27017/widgets' ); try { - file_put_contents( $this->tempRootDir . '/app/config/environment.json', json_encode( [ + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MONGO_URI)%' ] ], ] ) ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); - $environmentConfig = $context->loadEnvironmentConfig(); + $environmentConfig = $context->loadConfig(); $this->assertSame( 'mongodb://resolved:27017/widgets', $environmentConfig->mongoDatabases[ 0 ]->uri ); } finally { @@ -136,38 +136,38 @@ public function testLoadEnvironmentConfigResolvesEnvVars(): void { } - public function testLoadEnvironmentConfigThrowsCliExceptionWhenEnvVarMissing(): void { + public function testLoadConfigThrowsCliExceptionWhenEnvVarMissing(): void { unset( $_ENV[ 'TEST_MISSING_URI' ] ); putenv( 'TEST_MISSING_URI' ); - file_put_contents( $this->tempRootDir . '/app/config/environment.json', json_encode( [ + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MISSING_URI)%' ] ], ] ) ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); $this->expectException( cliException::class ); - $context->loadEnvironmentConfig(); + $context->loadConfig(); } - public function testLoadEnvironmentConfigVariantAppliesOverlay(): void { + public function testLoadConfigVariantAppliesOverlay(): void { // Ambient value must LOSE to the overlay for an explicit variant read. $_ENV[ 'TEST_MONGO_URI' ] = 'mongodb://local:27017'; putenv( 'TEST_MONGO_URI=mongodb://local:27017' ); try { - file_put_contents( $this->tempRootDir . '/app/config/environment.json', json_encode( [ + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ 'type' => '%env(default:local:TEST_APP_TYPE)%', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MONGO_URI)%' ] ], ] ) ); - file_put_contents( $this->tempRootDir . '/app/config/prod.env', "TEST_APP_TYPE=prod\nTEST_MONGO_URI=mongodb://prod:27017\n" ); + file_put_contents( $this->tempRootDir . '/prod.env', "TEST_APP_TYPE=prod\nTEST_MONGO_URI=mongodb://prod:27017\n" ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); - $prodConfig = $context->loadEnvironmentConfig( 'prod' ); + $prodConfig = $context->loadConfig( 'prod' ); $this->assertSame( 'prod', $prodConfig->type ); $this->assertSame( 'mongodb://prod:27017', $prodConfig->mongoDatabases[ 0 ]->uri ); - $activeConfig = $context->loadEnvironmentConfig(); + $activeConfig = $context->loadConfig(); $this->assertSame( 'local', $activeConfig->type ); $this->assertSame( 'mongodb://local:27017', $activeConfig->mongoDatabases[ 0 ]->uri ); } @@ -178,19 +178,19 @@ public function testLoadEnvironmentConfigVariantAppliesOverlay(): void { } - public function testLoadEnvironmentConfigVariantAmbientFillsOverlayGaps(): void { + public function testLoadConfigVariantAmbientFillsOverlayGaps(): void { $_ENV[ 'TEST_MONGO_DB' ] = 'localDb'; putenv( 'TEST_MONGO_DB=localDb' ); try { - file_put_contents( $this->tempRootDir . '/app/config/environment.json', json_encode( [ + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ 'type' => 'local', 'mongoDatabases' => [ [ 'default' => true, 'database' => '%env(TEST_MONGO_DB)%', 'uri' => '%env(TEST_MONGO_URI)%' ] ], ] ) ); - file_put_contents( $this->tempRootDir . '/app/config/prod.env', "TEST_MONGO_URI=mongodb://prod:27017\n" ); + file_put_contents( $this->tempRootDir . '/prod.env', "TEST_MONGO_URI=mongodb://prod:27017\n" ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); - $prodConfig = $context->loadEnvironmentConfig( 'prod' ); + $prodConfig = $context->loadConfig( 'prod' ); $this->assertSame( 'mongodb://prod:27017', $prodConfig->mongoDatabases[ 0 ]->uri ); // TEST_MONGO_DB not in the overlay -> ambient value fills the gap $this->assertSame( 'localDb', $prodConfig->mongoDatabases[ 0 ]->database ); @@ -202,12 +202,12 @@ public function testLoadEnvironmentConfigVariantAmbientFillsOverlayGaps(): void } - public function testLoadEnvironmentConfigVariantThrowsWhenOverlayMissing(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"local"}' ); + public function testLoadConfigVariantThrowsWhenOverlayMissing(): void { + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local"}' ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); try { - $context->loadEnvironmentConfig( 'prod' ); + $context->loadConfig( 'prod' ); $this->fail( 'Expected cliException' ); } catch( cliException $e ) { @@ -216,13 +216,13 @@ public function testLoadEnvironmentConfigVariantThrowsWhenOverlayMissing(): void } - public function testLoadEnvironmentConfigVariantMentionsMigrationWhenLegacyFileExists(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"local"}' ); + public function testLoadConfigVariantMentionsMigrationWhenLegacyFileExists(): void { + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local"}' ); file_put_contents( $this->tempRootDir . '/app/config/environment-prod.json', '{"type":"prod"}' ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); try { - $context->loadEnvironmentConfig( 'prod' ); + $context->loadConfig( 'prod' ); $this->fail( 'Expected cliException' ); } catch( cliException $e ) { @@ -232,20 +232,24 @@ public function testLoadEnvironmentConfigVariantMentionsMigrationWhenLegacyFileE } - public function testDescribeEnvironmentConfigSource(): void { + public function testDescribeConfigSource(): void { $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); $root = str_replace( '\\', '/', $this->tempRootDir ); - $this->assertSame( $root . '/app/config/environment.json', $context->describeEnvironmentConfigSource() ); - $this->assertSame( $root . '/app/config/environment.json (overlay: ' . $root . '/app/config/prod.env)', $context->describeEnvironmentConfigSource( 'prod' ) ); + $this->assertSame( $root . '/config.json', $context->describeConfigSource() ); + $this->assertSame( $root . '/config.json (overlay: ' . $root . '/prod.env)', $context->describeConfigSource( 'prod' ) ); } public function testGetEnvironmentVariantsListsOverlayFiles(): void { - touch( $this->tempRootDir . '/app/config/prod.env' ); - touch( $this->tempRootDir . '/app/config/staging.env' ); - touch( $this->tempRootDir . '/app/config/prod.env.example' ); - touch( $this->tempRootDir . '/app/config/environment.json' ); + touch( $this->tempRootDir . '/prod.env' ); + touch( $this->tempRootDir . '/staging.env' ); + // none of these may appear as variants: the example file, dotfiles, the + // config itself, or a legacy app/config file + touch( $this->tempRootDir . '/prod.env.example' ); + touch( $this->tempRootDir . '/.env' ); + touch( $this->tempRootDir . '/.env.local' ); + touch( $this->tempRootDir . '/config.json' ); touch( $this->tempRootDir . '/app/config/environment-local.json' ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); diff --git a/tests/Unit/Cli/CommandsTest.php b/tests/Unit/Cli/CommandsTest.php index 598e57a..0c45d31 100644 --- a/tests/Unit/Cli/CommandsTest.php +++ b/tests/Unit/Cli/CommandsTest.php @@ -59,8 +59,8 @@ public function testCliListShowsCliRoutesWithDescriptions(): void { } public function testEnvCommandValidatesVariantOverlay(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"%env(default:local:TEST_ENVCMD_TYPE)%","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_URI)%"}]}' ); - file_put_contents( $this->tempRootDir . '/app/config/prod.env', "TEST_ENVCMD_TYPE=prod\nTEST_ENVCMD_URI=mongodb://user:secret@prod:27017\n" ); + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"%env(default:local:TEST_ENVCMD_TYPE)%","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_URI)%"}]}' ); + file_put_contents( $this->tempRootDir . '/prod.env', "TEST_ENVCMD_TYPE=prod\nTEST_ENVCMD_URI=mongodb://user:secret@prod:27017\n" ); $commandTester = new CommandTester( new envCommand() ); $exitCode = $commandTester->execute( [ 'environment' => 'prod' ] ); @@ -74,8 +74,8 @@ public function testEnvCommandValidatesVariantOverlay(): void { } public function testEnvCommandFailsNamingTheMissingVariable(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"prod","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_MISSING_URI)%"}]}' ); - file_put_contents( $this->tempRootDir . '/app/config/prod.env', "IRRELEVANT=1\n" ); + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"prod","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_MISSING_URI)%"}]}' ); + file_put_contents( $this->tempRootDir . '/prod.env', "IRRELEVANT=1\n" ); $commandTester = new CommandTester( new envCommand() ); $exitCode = $commandTester->execute( [ 'environment' => 'prod' ] ); @@ -85,9 +85,9 @@ public function testEnvCommandFailsNamingTheMissingVariable(): void { } public function testEnvCommandBareListsVariantsAndChecksActiveEnvironment(): void { - file_put_contents( $this->tempRootDir . '/app/config/environment.json', '{"type":"local"}' ); - touch( $this->tempRootDir . '/app/config/prod.env' ); - touch( $this->tempRootDir . '/app/config/staging.env' ); + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local"}' ); + touch( $this->tempRootDir . '/prod.env' ); + touch( $this->tempRootDir . '/staging.env' ); $commandTester = new CommandTester( new envCommand() ); $exitCode = $commandTester->execute( [] ); diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 5eb296c..5a7501f 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use gcgov\framework\config; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; #[CoversClass(config::class)] final class ConfigTest extends TestCase { @@ -32,8 +32,8 @@ public function testGetModelsDirAppendsModels(): void { $this->assertSame( $this->tempRootDir . '/app/models/', config::getModelsDir() ); } - public function testGetConfigDirAppendsConfig(): void { - $this->assertSame( $this->tempRootDir . '/app/config/', config::getConfigDir() ); + public function testGetConfigFilePathIsRootConfigJson(): void { + $this->assertSame( $this->tempRootDir . '/config.json', config::getConfigFilePath() ); } public function testGetServicesDirAppendsServices(): void { @@ -52,14 +52,26 @@ public function testGetTempDirIsRootSrvTmpTmp(): void { $this->assertSame( $this->tempRootDir . '/srv/tmp/tmp', config::getTempDir() ); } - public function testEnvironmentConfigCanBeInjectedAndReadBack(): void { - $env = new environmentConfig(); - $env->basePath = 'custom'; - - $prop = new \ReflectionProperty( config::class, 'environmentConfig' ); - $prop->setValue( null, $env ); - - $this->assertSame( $env, config::getEnvironmentConfig() ); + public function testUnifiedConfigIsExposedThroughStaticAccessors(): void { + $unified = new unifiedConfig(); + $unified->type = 'local'; + $unified->basePath = 'custom'; + $unified->rootUrl = 'https://example.gov/'; + $unified->app->title = 'Widget API'; + $unified->appDictionary = [ 'key' => 'value' ]; + + $prop = new \ReflectionProperty( config::class, 'unifiedConfig' ); + $prop->setValue( null, $unified ); + + $this->assertSame( '/custom', config::getBasePath() ); + $this->assertSame( 'https://example.gov', config::getRootUrl() ); + $this->assertSame( 'https://example.gov/custom', config::getBaseUrl() ); + $this->assertTrue( config::isLocal() ); + $this->assertSame( 'Widget API', config::getApp()->title ); + $this->assertSame( [ 'key' => 'value' ], config::getAppDictionary() ); + $this->assertSame( $unified->logging, config::getLogging() ); + $this->assertSame( $unified->email, config::getEmail() ); + $this->assertSame( $unified->settings, config::getSettings() ); } public function testIsFinalClass(): void { diff --git a/tests/Unit/Models/Config/AppConfigModelsTest.php b/tests/Unit/Models/Config/AppConfigModelsTest.php index c7d3f0d..23951e4 100644 --- a/tests/Unit/Models/Config/AppConfigModelsTest.php +++ b/tests/Unit/Models/Config/AppConfigModelsTest.php @@ -6,20 +6,20 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use gcgov\framework\models\appConfig; +use gcgov\framework\models\unifiedConfig; use gcgov\framework\models\config\app\app; use gcgov\framework\models\config\app\email; use gcgov\framework\models\config\app\settings; -#[CoversClass(appConfig::class)] +#[CoversClass(unifiedConfig::class)] #[CoversClass(app::class)] #[CoversClass(email::class)] #[CoversClass(settings::class)] final class AppConfigModelsTest extends TestCase { - public function testAppConfigInstantiates(): void { - $config = new appConfig(); - $this->assertInstanceOf( appConfig::class, $config ); + public function testUnifiedConfigInstantiates(): void { + $config = new unifiedConfig(); + $this->assertInstanceOf( unifiedConfig::class, $config ); } public function testAppHasTitleAndGuid(): void { @@ -53,7 +53,7 @@ public function testSettingsDefaults(): void { } public function testAllConfigsExtendJsonDeserialize(): void { - foreach ( [ appConfig::class, app::class, email::class, settings::class ] as $class ) { + foreach ( [ unifiedConfig::class, app::class, email::class, settings::class ] as $class ) { $this->assertTrue( is_subclass_of( $class, \andrewsauder\jsonDeserialize\jsonDeserialize::class ) ); } } diff --git a/tests/Unit/Models/EnvironmentConfigTest.php b/tests/Unit/Models/UnifiedConfigTest.php similarity index 83% rename from tests/Unit/Models/EnvironmentConfigTest.php rename to tests/Unit/Models/UnifiedConfigTest.php index 672e21d..abe85f3 100644 --- a/tests/Unit/Models/EnvironmentConfigTest.php +++ b/tests/Unit/Models/UnifiedConfigTest.php @@ -6,14 +6,14 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; use gcgov\framework\models\config\environment\sqlDatabase; -#[CoversClass(environmentConfig::class)] -final class EnvironmentConfigTest extends TestCase { +#[CoversClass(unifiedConfig::class)] +final class UnifiedConfigTest extends TestCase { public function testConstructorInitializesNestedConfigs(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $this->assertInstanceOf( \gcgov\framework\models\config\environment\microsoft::class, $config->microsoft @@ -33,38 +33,38 @@ public function testConstructorInitializesNestedConfigs(): void { } public function testGetRootUrlTrimsTrailingSlashesAndSpaces(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $config->rootUrl = 'https://example.com/ '; $this->assertSame( 'https://example.com', $config->getRootUrl() ); } public function testGetBaseUrlCombinesRootAndBasePath(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $config->rootUrl = 'https://example.com/'; $config->basePath = '/api/v1/'; $this->assertSame( 'https://example.com/api/v1', $config->getBaseUrl() ); } public function testGetBasePathReturnsLeadingSlashTrimmedValue(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $config->basePath = 'api/v1 '; $this->assertSame( '/api/v1', $config->getBasePath() ); } public function testIsLocalReturnsTrueWhenTypeIsLocal(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $config->type = 'local'; $this->assertTrue( $config->isLocal() ); } public function testIsLocalReturnsFalseForOtherEnvironments(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $config->type = 'production'; $this->assertFalse( $config->isLocal() ); } public function testGetDefaultSqlDatabaseReturnsTheOneMarkedDefault(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $db1 = new sqlDatabase(); $db1->name = 'db1'; $db1->default = false; @@ -77,7 +77,7 @@ public function testGetDefaultSqlDatabaseReturnsTheOneMarkedDefault(): void { } public function testGetDefaultSqlDatabaseReturnsNullWhenNoneDefault(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $db = new sqlDatabase(); $db->default = false; $config->sqlDatabases = [ $db ]; @@ -85,7 +85,7 @@ public function testGetDefaultSqlDatabaseReturnsNullWhenNoneDefault(): void { } public function testGetSqlDatabaseByNameMatches(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $db = new sqlDatabase(); $db->name = 'primary'; $config->sqlDatabases = [ $db ]; @@ -95,7 +95,7 @@ public function testGetSqlDatabaseByNameMatches(): void { } public function testAppDictionaryIsArrayByDefault(): void { - $config = new environmentConfig(); + $config = new unifiedConfig(); $this->assertSame( [], $config->appDictionary ); } diff --git a/tests/Unit/Services/Chrome/ChromeServiceTest.php b/tests/Unit/Services/Chrome/ChromeServiceTest.php index 05f4990..3f6b805 100644 --- a/tests/Unit/Services/Chrome/ChromeServiceTest.php +++ b/tests/Unit/Services/Chrome/ChromeServiceTest.php @@ -21,7 +21,7 @@ protected function setUp(): void { mkdir( $this->tempSrvDir, 0777, true ); // point config::getSrvDir() at the fixture (same technique tests/bootstrap.php - // uses to seed environmentConfig) + // uses to seed unifiedConfig) $srvDirProperty = new \ReflectionProperty( config::class, 'srvDir' ); $srvDirProperty->setValue( null, $this->tempSrvDir . '/' ); } diff --git a/tests/Unit/Services/MongoDB/Tools/MongoLogTest.php b/tests/Unit/Services/MongoDB/Tools/MongoLogTest.php index e4c66ee..6228b9b 100644 --- a/tests/Unit/Services/MongoDB/Tools/MongoLogTest.php +++ b/tests/Unit/Services/MongoDB/Tools/MongoLogTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use gcgov\framework\services\mongodb\tools\log; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; use gcgov\framework\models\config\environment\mongoDatabase; #[CoversClass(log::class)] @@ -61,9 +61,9 @@ public function testEmptyMongoDatabasesIsAGracefulNoOp(): void { // than trying to access the first entry. This used to raise a PHP // warning under PHP 8.4; the guard added during the level-5 cleanup // preserves silent no-op semantics. - $env = new environmentConfig(); + $env = new unifiedConfig(); $env->mongoDatabases = []; - $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'environmentConfig' ); + $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); $prop->setValue( null, $env ); log::debug( 'empty-mongo', 'msg' ); @@ -71,11 +71,11 @@ public function testEmptyMongoDatabasesIsAGracefulNoOp(): void { } private function primeEnvWithMongoLogging( bool $enabled ): void { - $env = new environmentConfig(); + $env = new unifiedConfig(); $db = new mongoDatabase(); $db->logging = $enabled; $env->mongoDatabases = [ $db ]; - $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'environmentConfig' ); + $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); $prop->setValue( null, $env ); } diff --git a/tests/Unit/Services/PdoDb/PdodbTest.php b/tests/Unit/Services/PdoDb/PdodbTest.php index 38754c0..64de644 100644 --- a/tests/Unit/Services/PdoDb/PdodbTest.php +++ b/tests/Unit/Services/PdoDb/PdodbTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use gcgov\framework\services\pdodb\pdodb; -use gcgov\framework\models\environmentConfig; +use gcgov\framework\models\unifiedConfig; use gcgov\framework\models\config\environment\sqlDatabase; use gcgov\framework\models\config\environment\sqlDatabaseUser; @@ -88,9 +88,9 @@ private function makeSqlDatabase( string $dsn, string $name, bool $default ): sq * @param list $databases */ private function primeEnvWith( array $databases ): void { - $env = new environmentConfig(); + $env = new unifiedConfig(); $env->sqlDatabases = $databases; - $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'environmentConfig' ); + $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); $prop->setValue( null, $env ); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 341bfba..d0a8fb4 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -38,12 +38,12 @@ public function authentication( \gcgov\framework\models\routeHandler $routeHandl }' ); } -// Seed environmentConfig so config::getEnvironmentConfig() doesn't try to +// Seed unifiedConfig so config accessors don't try to // load a JSON file from disk. -$envConfig = new \gcgov\framework\models\environmentConfig(); +$envConfig = new \gcgov\framework\models\unifiedConfig(); $envConfig->basePath = 'api'; $envConfig->serverName = 'test.local'; $envConfig->rootUrl = 'http://test.local'; $envConfig->type = 'local'; -$prop = new \ReflectionProperty( \gcgov\framework\config::class, 'environmentConfig' ); +$prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); $prop->setValue( null, $envConfig ); From b162affeb1b455eca6352a4dd4635672bd1f1055 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 00:35:27 +0000 Subject: [PATCH 04/30] Add deprecated getAppConfig()/getEnvironmentConfig() pass-throughs Restore both v6 accessors as @deprecated pass-throughs that return the unified config object. Because unifiedConfig carries every former environmentConfig field/helper AND the app/email/settings sections, existing plugin and app call patterns keep working unchanged: config::getEnvironmentConfig()->getBasePath() config::getEnvironmentConfig()->mongoDatabases config::getAppConfig()->settings->forceMfaForPasswordUsers config::getAppConfig()->app->title Plugins can migrate to the flattened accessors (config::getBasePath(), config::getSettings(), ...) gradually instead of as a hard prerequisite for adopting v7. Marked with @deprecated + #[Deprecated]; test pins the v6 call patterns; migration guide and CLAUDE.md updated to say the old methods still work. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JWacdhEDN9upUvYeagNKru --- CLAUDE.md | 3 ++- readme/gf.md | 6 ++++-- src/config.php | 28 ++++++++++++++++++++++++++++ tests/Unit/ConfigTest.php | 23 +++++++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa348e1..ace931b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -327,7 +327,8 @@ returning group keys, and tag constraints with `groups: [...]`. `\app\app`'s file location, so `config::getAppDir()`, `getRootDir()`, `getModelsDir()`, `getSrvDir()`, `getTempDir()`, `getConfigFilePath()` all work without setup. Configuration values come from the **unified `{root}/config.json`** (hydrated once into `\gcgov\framework\models\unifiedConfig`) and are -exposed **directly on `config`** — there are no separate appConfig/environmentConfig objects (v7): +exposed **directly on `config`** — there are no separate appConfig/environmentConfig objects (v7; +`getAppConfig()`/`getEnvironmentConfig()` remain as deprecated pass-throughs returning the unified object): `config::getApp()` (title/guid), `getEmail()`, `getSettings()`, `getType()`, `isLocal()`, `getServerName()`, `getRootUrl()`, `getBaseUrl()`, `getBasePath()`, `getCookieUrl()`, `getPhpPath()`, `getLogging()`, `getMongoDatabases()`, `getSqlDatabases()`, `getDefaultSqlDatabase()`, diff --git a/readme/gf.md b/readme/gf.md index c807337..97a2729 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -222,9 +222,11 @@ are gone. To move an app onto v7: `/*.env`. Local values go in `{root}/.env` (from `.env.example`). 3. Delete `environment-{env}.json`, `composer-{env}.json`, and `www/web-{env}.config`; commit `composer.json` (and a static `www/web.config`, if the app still runs on IIS). -4. Replace `config::getAppConfig()` / `config::getEnvironmentConfig()` calls in app code with +4. Migrate `config::getAppConfig()` / `config::getEnvironmentConfig()` calls in app code to the flattened accessors (`config::getBasePath()`, `config::getSettings()`, - `config::getMongoDatabases()`, …). + `config::getMongoDatabases()`, …). The old methods still work — they are **deprecated + pass-throughs** returning the unified config object, which carries every former field and + helper — so this step can happen gradually after the upgrade. 5. Bump `gcgov/framework` to `^v7.0`; verify with `gf env` and `gf env prod`. --- diff --git a/src/config.php b/src/config.php index 546bb11..cba1c8d 100644 --- a/src/config.php +++ b/src/config.php @@ -162,6 +162,34 @@ private static function setUnifiedConfig(): void { } + // --- deprecated v6 pass-throughs (migration aids) --- + + /** + * @deprecated v7 — use the flattened static accessors instead: `config::getEnvironmentConfig()->getBasePath()` + * becomes `config::getBasePath()`, `->mongoDatabases` becomes `config::getMongoDatabases()`, etc. + * Returns the unified config object, which carries every former environmentConfig field and helper, + * so existing call sites keep working until they migrate. + * @throws \gcgov\framework\exceptions\configException + */ + #[\JetBrains\PhpStorm\Deprecated( reason: 'v7: config values are exposed directly on config', replacement: '\gcgov\framework\config' )] + public static function getEnvironmentConfig(): unifiedConfig { + return self::unifiedConfig(); + } + + + /** + * @deprecated v7 — use the flattened static accessors instead: `config::getAppConfig()->settings` becomes + * `config::getSettings()`, `->app` becomes `config::getApp()`, `->email` becomes `config::getEmail()`. + * Returns the unified config object, which carries the app/email/settings sections, + * so existing call sites keep working until they migrate. + * @throws \gcgov\framework\exceptions\configException + */ + #[\JetBrains\PhpStorm\Deprecated( reason: 'v7: config values are exposed directly on config', replacement: '\gcgov\framework\config' )] + public static function getAppConfig(): unifiedConfig { + return self::unifiedConfig(); + } + + // --- application identity (formerly app.json) --- /** @throws \gcgov\framework\exceptions\configException */ diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 5a7501f..4c6df58 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -74,6 +74,29 @@ public function testUnifiedConfigIsExposedThroughStaticAccessors(): void { $this->assertSame( $unified->settings, config::getSettings() ); } + public function testDeprecatedPassThroughsPreserveV6CallPatterns(): void { + $unified = new unifiedConfig(); + $unified->type = 'prod'; + $unified->basePath = '/api/'; + $unified->settings->forceMfaForPasswordUsers = true; + $unified->app->title = 'Widget API'; + + $prop = new \ReflectionProperty( config::class, 'unifiedConfig' ); + $prop->setValue( null, $unified ); + + // v6 environmentConfig call patterns + $this->assertSame( $unified, config::getEnvironmentConfig() ); + $this->assertSame( '/api', config::getEnvironmentConfig()->getBasePath() ); + $this->assertFalse( config::getEnvironmentConfig()->isLocal() ); + $this->assertSame( [], config::getEnvironmentConfig()->mongoDatabases ); + + // v6 appConfig call patterns + $this->assertSame( $unified, config::getAppConfig() ); + $this->assertTrue( config::getAppConfig()->settings->forceMfaForPasswordUsers ); + $this->assertSame( 'Widget API', config::getAppConfig()->app->title ); + $this->assertSame( '', config::getAppConfig()->email->SMTPUsername ); + } + public function testIsFinalClass(): void { $this->assertTrue( ( new \ReflectionClass( config::class ) )->isFinal() ); } From 1f8f832904e69797b30f95ac9c8a35d1424ac1bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 12:07:19 +0000 Subject: [PATCH 05/30] v7: replace overlay files with config.json environments section; harden resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the foreign-environment mechanism and apply the code-review security/correctness fixes. Foreign environments (db:restore --from, db:run --env, gf env ) no longer come from gitignored {name}.env overlay files. They come from an `environments` section committed inside config.json, keyed by environment name, whose %env() references use environment-PREFIXED variable names (e.g. PROD_MONGO_URI) kept in the same .env. This removes the silent local-value fallback hazard by construction (distinct names fail loudly), keeps prod secrets out of developer workstations except the one Mongo credential a restore needs, and makes the `type` guard reliable again (committed literal). --from-uri/--from-db flags were considered and dropped. - New services\environment\configLoader: the single load pipeline (config.json -> .env -> resolve %env -> hydrate) shared by \gcgov\framework\config (runtime) and appContext (CLI); strips the CLI-only `environments` section for the active config, extracts one entry for loadVariantEnvironment(). Removes the duplicated pipeline the review flagged and gives both layers identical resolution + errors. - New models\config\variantEnvironment (type + mongoDatabases) for an environments entry. - envVarResolver: request-data injection guard now applies by NAME across $_ENV/$_SERVER/getenv() (was $_SERVER-only, bypassable under CGI/FastCGI where headers reach getenv) — HTTP_* plus the CGI meta-variable set are never resolved from the ambient environment; a leftover %env( after resolution (e.g. ')' inside a default: literal) now throws instead of silently shipping the literal; the overlay param is gone (resolveDecoded added for in-place tree resolution). - dotEnvLoader: loads .env and/or .env.local (either may exist alone — the .env-only early return silently skipped a lone .env.local); FormatException from a malformed file is wrapped as environmentException; parseFile() removed. - config.php: uses configLoader; getConfigDir() restored as a deprecated shim (parity with the other kept shims); getAppConfig() now returns a v6-shaped appConfig VIEW (app/email/settings only) so serializing it no longer leaks mongo/microsoft/payjunction secrets; environmentConfig restored as an autoloadable class_alias to unifiedConfig so v6 type references keep working. - cliCommand: a present-but-unresolvable config.json now surfaces loudly instead of being swallowed (which discarded the configured phpPath); --php help says config.json. - setupCommand: the token haystack (full tree read) is built once and shared between the two prompt-filter calls; the "already set up" message only prints when NO prompts (incl. Microsoft) remain. - envCommand/dbRestore/dbRun rewired to the environments section; env command validates an environments.{name} entry or the active config. - Tests reworked for the environments model + security guards (ConfigLoaderTest added); docs updated (CLAUDE.md, README.md, environment-variables.md, gf.md). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JWacdhEDN9upUvYeagNKru --- CLAUDE.md | 39 +++-- README.md | 5 +- readme/environment-variables.md | 62 +++++--- readme/gf.md | 81 +++++----- src/cli/appContext.php | 111 ++++++------- src/cli/commands/cliCommand.php | 11 +- src/cli/commands/dbRestoreCommand.php | 37 +++-- src/cli/commands/dbRunCommand.php | 11 +- src/cli/commands/envCommand.php | 58 +++++-- src/cli/commands/setupCommand.php | 42 +++-- src/config.php | 31 ++-- src/models/appConfig.php | 34 ++++ src/models/config/variantEnvironment.php | 26 ++++ src/models/environmentConfig.php | 11 ++ src/models/unifiedConfig.php | 6 +- src/services/environment/configLoader.php | 145 +++++++++++++++++ src/services/environment/dotEnvLoader.php | 58 ++----- src/services/environment/envVarResolver.php | 141 +++++++++++------ tests/Unit/Cli/AppContextTest.php | 110 ++++++------- tests/Unit/Cli/CommandsTest.php | 48 +++--- tests/Unit/ConfigTest.php | 26 +++- .../Services/Environment/ConfigLoaderTest.php | 146 ++++++++++++++++++ .../Services/Environment/DotEnvLoaderTest.php | 27 ++-- .../Environment/EnvVarResolverTest.php | 95 ++++++++---- 24 files changed, 938 insertions(+), 423 deletions(-) create mode 100644 src/models/appConfig.php create mode 100644 src/models/config/variantEnvironment.php create mode 100644 src/models/environmentConfig.php create mode 100644 src/services/environment/configLoader.php create mode 100644 tests/Unit/Services/Environment/ConfigLoaderTest.php diff --git a/CLAUDE.md b/CLAUDE.md index ace931b..6a567fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,9 +71,8 @@ Required config file (missing it throws `configException` at request time): Typical app tree (scaffolding template adds more — `srv/`, `db/`, `docker/`, `Dockerfile`, etc.): ``` /api -├── config.json # unified config (committed; %env(...) refs) -├── {env}.env # gitignored per-variant overlays for gf db:*/env (e.g. prod.env) -├── .env # gitignored local values (from .env.example) +├── config.json # unified config (committed; %env(...) refs; CLI-only `environments` section) +├── .env # gitignored local values incl. gf db:*/env PREFIXED vars (from .env.example) ├── app/{app,router,renderer,constants}.php │ ├── cli/index.php # CLI entry │ ├── controllers/{name}.php @@ -345,15 +344,24 @@ environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hostin - `file` reads the file at the variable's value (Docker secrets: `%env(trim:file:MONGO_URI_FILE)%`). - `default:` is a **literal** fallback (deviation from Symfony), must be innermost, greedy argument so colons are legal: `%env(default:mongodb://mongodb:27017:MONGO_URI)%`. -- `.env` loading (via `symfony/dotenv`, `dotEnvLoader::loadOnce()`): `{root}/.env` then - `.env.local`; **real environment always wins** over both. No `APP_ENV` cascade — an - environment IS the variable set the process is given; nothing is activated or copied (v7). -- gf variant reads (`db:restore --from=prod`, `db:run --env=prod`, `gf env prod`) resolve the - same committed `config.json` with a gitignored `{root}/{variant}.env` **overlay** - (parsed via `dotEnvLoader::parseFile()`, precedence: overlay > real env > `.env.local` > - `.env` > `default:`). Overlays must define every environment-specific variable — missing ones - silently fall back to local values; validate with `gf env `. -- Missing required var → `configException` (runtime) / `cliException` (gf), naming the variable. +- `.env` loading (via `symfony/dotenv`, `dotEnvLoader::loadOnce()`): `{root}/.env` and/or + `.env.local` (either may exist alone); **real environment always wins**; precedence + `real env > .env.local > .env > default:`. No `APP_ENV` cascade — an environment IS the + variable set the process is given; nothing is activated or copied (v7). +- **Foreign-environment reads** (`db:restore --from=prod`, `db:run --env=prod`, `gf env prod`) come + from the CLI-only `environments` section of `config.json` (stripped before the active config is + resolved). Each `environments.{name}` entry has a literal `type` (the db:restore prod guard needs + it) and `mongoDatabases` referencing **environment-prefixed** variables (e.g. `PROD_MONGO_URI` in + `.env`) so a missing value fails loudly instead of resolving to the local value. + `appContext::loadVariantEnvironment($name)` reads one entry; `configLoader` is the shared load + pipeline for both runtime and CLI. +- **Reserved names**: CGI meta-variable names (`HTTP_*`, `SERVER_*`, `REQUEST_*`, `REMOTE_*`, + `PHP_AUTH_*`, `SCRIPT_*`, `DOCUMENT_*`, `HTTPS`, `QUERY_STRING`, `CONTENT_*`, `AUTH_TYPE`, + `GATEWAY_INTERFACE`, `PHP_SELF`, `PATH_INFO`, `PATH_TRANSLATED`) are never resolved from the + ambient environment (request data can reach it under CGI/FastCGI) — they act as unset. +- Missing/unresolvable var → `configException` (runtime) / `cliException` (gf), naming the variable. + A leftover `%env(` after resolution (e.g. a `)` inside a `default:` literal) is an error, not + silently shipped. **`{root}/config.json`** → `\gcgov\framework\models\unifiedConfig` (one file, all sections): ```jsonc @@ -505,9 +513,10 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea logic lives in `services/chrome/chromeInstallation.php`, download orchestration in `src/cli/chromeInstaller.php` (injectable Guzzle client — tests are network-free). - **Architecture** (`src/cli/`): `application` (command registration + provider discovery), - `appContext` (app-root locator: composer autoload path first, then cwd walk-up; lazy config - access via `loadConfig($variant)` — resolves the root `config.json`, applying the - `{root}/{variant}.env` overlay when a variant is named; never boots the request lifecycle), + `appContext` (app-root locator: composer autoload path first, then cwd walk-up; config access + via `loadConfig()` for the active config and `loadVariantEnvironment($name)` for one + `environments.{name}` entry — both delegate to `services\environment\configLoader`; never boots + the request lifecycle), `routeCatalog` (CLI-route enumeration via `router::getMergedRoutes()`), `phpProcess`, `tokenReplacer`, `mongoTools`, `cliException` (user-facing errors), `internal/run-route.php` (child-process route runner; maps response status ≥400 → exit 1). diff --git a/README.md b/README.md index fd7cbb3..40ed309 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ automatically start with some extra folders and tools. /api │... ├── config.json # committed unified config; secrets/per-env values via %env(...) -├── prod.env.example # copy to prod.env (gitignored) for gf db:*/env variant reads +├── .env.example # copy to .env (gitignored); holds gf db:*/env PROD_* vars too ├── www │ │... ├── app @@ -98,7 +98,6 @@ automatically start with some extra folders and tools. ├── db │ └── local-createuser.js ├── logs -├── .env.example # copy to .env (gitignored) for local development ├── Dockerfile └── docker-compose.yml ``` @@ -216,7 +215,7 @@ gf cli:list # list the app's CLI routes gf cert:generate-auth # JWT signing keys (replaces create-jwt-keys.ps1) gf db:restore --from=prod # pull a source env's mongo dbs into the local env gf db:run db/script.js # run a mongosh script with config-managed credentials -gf env prod # validate that the prod.env overlay fully resolves config.json +gf env prod # validate the config.json environments.prod entry resolves gf setup # bootstrap a scaffolded app (replaces setup.ps1) gf deploy # tag-based deployment (replaces update-production.ps1) ``` diff --git a/readme/environment-variables.md b/readme/environment-variables.md index 9e2078a..c017a88 100644 --- a/readme/environment-variables.md +++ b/readme/environment-variables.md @@ -32,8 +32,8 @@ Resolution runs at the two points where the framework reads the unified config J | Source | Loader | |--------|--------| -| `{root}/config.json` | `\gcgov\framework\config` static accessors (`config::getBasePath()`, `getMongoDatabases()`, `getEmail()`, …) | -| `config.json` + `{root}/{variant}.env` overlay | the `gf` CLI (`appContext::loadConfig($variant)`) — see "Per-variant overlay files" below | +| `{root}/config.json` (active — the `environments` section is stripped) | `\gcgov\framework\config` static accessors (`config::getBasePath()`, `getMongoDatabases()`, `getEmail()`, …) | +| `config.json`'s `environments.{name}` entry | the `gf` CLI (`appContext::loadVariantEnvironment($name)`) — see "Foreign environments" below | Untyped config regions (`appDictionary`, plugin `clientParams`, etc.) are resolved too — the resolver walks the whole decoded tree. @@ -176,27 +176,53 @@ fails loudly, naming exactly what to set — dev covers them via `.env` (`cp .en --- -## Per-variant overlay files (gf CLI) +## Foreign environments (gf CLI: `environments` section) -The gf CLI sometimes needs a **foreign** environment's values without activating anything — -`gf db:restore --from=prod` must resolve prod's Mongo URI while your shell holds local values. -That is what per-variant overlay files are for: a gitignored dotenv file -`{root}/{variant}.env` (e.g. `prod.env`; start from the app template’s -`prod.env.example`). `appContext::loadConfig('prod')` resolves the committed -`config.json` with that file’s variables applied on top. Precedence for such a read: +The gf CLI sometimes needs a **foreign** environment's connection info without activating +anything — `gf db:restore --from=prod` must resolve prod's Mongo URI while your shell holds +local values. This lives in an `environments` section of `config.json`, keyed by environment +name. The runtime **strips this section** before resolving the active configuration, so its +references never have to be set for the app to run: +```jsonc +{ + "type": "%env(default:local:APP_TYPE)%", + "mongoDatabases": [ { "default": true, "database": "%env(MONGO_DATABASE)%", "uri": "%env(MONGO_URI)%" } ], + + // gf-only. type is a committed LITERAL (the db:restore prod guard relies on it); the %env() + // references use ENVIRONMENT-PREFIXED names so a missing value fails loudly instead of + // silently resolving to your local value. + "environments": { + "prod": { + "type": "prod", + "mongoDatabases": [ { "default": true, "database": "%env(PROD_MONGO_DATABASE)%", "uri": "%env(PROD_MONGO_URI)%" } ] + } + } +} ``` -{variant}.env overlay > real environment > .env.local > .env > default: fallback -``` -Two things to know: +Put the `PROD_*` values in the **same gitignored `.env`** you already use for local development. +`appContext::loadVariantEnvironment('prod')` resolves only the `environments.prod` subtree. + +Why prefixed names? Because the source and target of `db:restore` resolve against the same +process environment, a *shared* name (`MONGO_URI`) would silently fall back to your local value +when the prod value is missing. A distinct name (`PROD_MONGO_URI`) fails loudly instead. Validate +an environment before relying on it with `gf env prod` (it reports the resolved databases with +redacted URIs, or names the first unresolvable variable); `db:restore` additionally refuses a +pair whose source and target resolve to the same database. + +--- + +## Reserved variable names (request-data guard) -- **An overlay must define every environment-specific variable.** A variable missing from the - overlay falls back to your *local* value silently. `gf env ` validates that an overlay - fully resolves `config.json`, and `db:restore` refuses a pair whose source and target - resolve to the same database — but neither catches everything. -- Overlay files are parsed with `dotEnvLoader::parseFile()` — they are **never loaded into the - process environment** and never affect the running app; only the one gf resolution sees them. +In web SAPIs, request data leaks into the ambient lookup: CGI/FastCGI turns request headers into +`HTTP_*` variables that reach `getenv()` (and, with `variables_order=E`, `$_ENV`), and `$_SERVER` +carries request-derived CGI meta-variables. To guarantee a `%env(...)%` reference can never be +satisfied by request data, names matching the CGI meta-variable set — `HTTP_*`, `SERVER_*`, +`REQUEST_*`, `REMOTE_*`, `PHP_AUTH_*`, `SCRIPT_*`, `DOCUMENT_*`, and `HTTPS`, `QUERY_STRING`, +`CONTENT_TYPE`, `CONTENT_LENGTH`, `AUTH_TYPE`, `GATEWAY_INTERFACE`, `PHP_SELF`, `PATH_INFO`, +`PATH_TRANSLATED` — are treated as **unset** in every ambient source (`default:` still applies, +otherwise the reference fails loudly). Do not name configuration variables after these. --- diff --git a/readme/gf.md b/readme/gf.md index 97a2729..ef6a596 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -27,7 +27,7 @@ spelling also works — `gf db restore` resolves to `db:restore` automatically. | `gf chrome:status` | — | Show whether chrome-headless-shell is installed and what version | | `gf db:restore` | `db/restore-live-to-local.ps1` | Copy a source environment's mongo databases into a target environment | | `gf db:run ` | ad-hoc `mongosh "" script.js` | Run a mongosh script using config-managed connections | -| `gf env []` | manual `Copy-Item` steps | List environment variants; validate that config resolves (active env or a `{env}.env` overlay) | +| `gf env []` | manual `Copy-Item` steps | List config.json environments; validate that config resolves (active, or an environments.{name} entry) | | `gf setup` | `scripts/setup.ps1` | Bootstrap a freshly scaffolded application | | `gf deploy` | `update-production.ps1` | Tag-based production deployment | | `gf completion` / `gf completion:powershell` | — | Shell tab completion | @@ -147,39 +147,45 @@ installation exists. The `chrome-php/chrome` library is a framework dependency, ## Databases: `gf db:restore` and `gf db:run` -Connection strings come from the unified `{root}/config.json` (`mongoDatabases[]`) — never -hardcode credentials in scripts again. A variant name (`--from=prod`, `--env=prod`) resolves -that same `config.json` with the variables from the gitignored overlay file -`{root}/{name}.env` applied on top of your local environment (see -[Environments](#environments-gf-env) below): +Connection strings come from `{root}/config.json` — never hardcode credentials in scripts +again. The **local** side uses the active `mongoDatabases[]`; a **foreign** environment +(`--from=prod`, `--env=prod`) resolves the `environments.{name}` entry of the same `config.json`, +which references environment-prefixed variables (e.g. `PROD_MONGO_URI`) you keep in your +gitignored `.env` (see [Environments](#environments-gf-env) below): +```jsonc +// config.json — the environments section is CLI-only (stripped at runtime) +"environments": { + "prod": { "type": "prod", + "mongoDatabases": [ { "default": true, "database": "%env(PROD_MONGO_DATABASE)%", "uri": "%env(PROD_MONGO_URI)%" } ] } +} +``` ```ini -# {root}/prod.env (gitignored; start from prod.env.example) -APP_TYPE=prod -MONGO_URI=mongodb+srv://user:pass@prod-cluster/ -MONGO_DATABASE=app +# {root}/.env (gitignored) +PROD_MONGO_URI=mongodb+srv://user:pass@prod-cluster/ +PROD_MONGO_DATABASE=app ``` ``` -gf db:restore # dump prod -> restore into the active environment (--drop) -gf db:restore --from=prod --to=local +gf db:restore # dump prod -> restore into the active configuration (--drop) +gf db:restore --from=prod --to=local # --to also names an environments.{name} entry gf db:restore --db=AppsSchedule # only the named database(s) gf db:restore --keep-dump --dump-dir=db/backup ``` - Source/target databases are paired by database name (falling back to the two `default` entries); differing names are remapped with `--nsFrom/--nsTo`. -- Restoring **into** the variant named `prod`, or into an environment whose resolved `type` is - `prod`, is refused unless `--allow-prod`. +- Restoring **into** the environment named `prod`, or into one whose `type` is `prod`, is refused + unless `--allow-prod`. - A pair whose source and target resolve to the **same uri and database** is refused outright — - that almost always means an incomplete `{name}.env` overlay silently fell back to your local - values. Validate the overlay first with `gf env prod`. + that usually means an `environments.{name}` entry reused a local variable name instead of a + prefixed one. Validate with `gf env prod`. - Requires the [MongoDB Database Tools](https://www.mongodb.com/try/download/database-tools) (`mongodump`, `mongorestore`) on PATH. - The plan (with passwords redacted) is shown and confirmed before anything runs; `--yes` skips. ``` -gf db:run db/create-admin.js # against the active config.json default db +gf db:run db/create-admin.js # against the active configuration's default db gf db:run db/migrate.js --env=prod --db=AppsSchedule gf db:run db/seed.js -- --quiet # everything after -- goes to mongosh ``` @@ -190,22 +196,24 @@ Requires [mongosh](https://www.mongodb.com/try/download/shell) on PATH. ## Environments: `gf env` -Environment selection is **environment-variable driven**: the committed -the root `config.json` references variables with `%env(...)%`, and whichever values the -process environment (container env, Docker secrets, or `{root}/.env`) supplies *are* the -environment. There is nothing to activate or copy. +Environment selection is **environment-variable driven**: the committed root `config.json` +references variables with `%env(...)%`, and whichever values the process environment (container +env, Docker secrets, or `{root}/.env`) supplies *are* the environment. There is nothing to +activate or copy. Foreign-environment connection info for the `db:*` commands lives in the +CLI-only `environments` section of `config.json` (stripped at runtime). -`gf env` is the validator for that model: +`gf env` validates that model: ``` -gf env # list {root}/*.env variants + validate the ACTIVE environment -gf env prod # resolve config.json with the prod.env overlay and validate it +gf env # list config.json environments + validate the ACTIVE configuration +gf env prod # resolve the environments.prod entry and validate it ``` -`gf env ` prints the resolved summary (type, serverName, urls, databases with redacted -URIs) and exits non-zero naming the first unresolvable variable. Run it before trusting a -variant with `db:restore`/`db:run` — a variable missing from the overlay silently falls back to -your local value, so **an overlay file must define every environment-specific variable**. +`gf env ` prints the resolved summary (type, databases with redacted URIs) and exits +non-zero naming the first unresolvable variable. Run it before trusting an environment with +`db:restore`/`db:run`. An `environments.{name}` entry should reference **environment-prefixed +variable names** (e.g. `PROD_MONGO_URI`) so a missing value fails loudly rather than silently +resolving to your local value. ### Migrating a v6 app to v7 @@ -217,9 +225,10 @@ are gone. To move an app onto v7: (everything else) into one JSON object, with every secret and every per-environment value referenced via `%env(...)%` — see [environment-variables.md](environment-variables.md) and the app template's copy. Then delete the `app/config/` directory. -2. For each old variant, create a gitignored **`{env}.env` at the application root** holding - that environment's variable values (start from the template's `prod.env.example`); gitignore - `/*.env`. Local values go in `{root}/.env` (from `.env.example`). +2. For each old variant you need foreign-environment `db:*` access to, add an + `environments.{env}` entry to `config.json` (`type` literal + `mongoDatabases` with + environment-prefixed `%env()` names like `PROD_MONGO_URI`) and put those values in your + gitignored `{root}/.env`. 3. Delete `environment-{env}.json`, `composer-{env}.json`, and `www/web-{env}.config`; commit `composer.json` (and a static `www/web.config`, if the app still runs on IIS). 4. Migrate `config::getAppConfig()` / `config::getEnvironmentConfig()` calls in app code to @@ -275,8 +284,8 @@ config-activation step. ``` Completion is dynamic: `gf cli ` suggests the application's actual CLI routes (with -descriptions), `gf env ` (and `db:restore --from=` etc.) suggests the variant overlay -files (`{root}/*.env`) present in the app. +descriptions), `gf env ` (and `db:restore --from=` etc.) suggests the config.json +environments (from the `environments` section) present in the app. --- @@ -312,8 +321,8 @@ down gf itself (run with `-v` to see discovery errors). Useful helpers for custom commands (all in `\gcgov\framework\cli`): - `appContext::require()` / `appContext::locate()` — application root + config access -- `appContext->loadConfig($variant)` — resolve the root `config.json` (with the `{variant}.env` overlay when a variant is named) -- `dotEnvLoader::parseFile($path)` — parse a dotenv file to an array without touching the process env +- `appContext->loadConfig()` / `loadVariantEnvironment($name)` — resolve the active config, or one `environments.{name}` entry +- `configLoader::load($root)` / `loadVariantEnvironment($root, $name)` — the shared config-load pipeline (also used by `gcgov rameworknfig`) - `mongoTools::findBinary()/redactUri()/uriWithDatabase()` - `phpProcess::findPhpBinary()/requiredIniFlags()/xdebugFlags()` - throw `cliException` for user-facing errors @@ -343,7 +352,7 @@ Reference any secrets that were hardcoded in those scripts via `%env(...)%` in t the root `config.json` — the `db:*` commands and the request lifecycle both resolve them. Keep the actual values in the process environment, Docker/Kubernetes secrets, or a gitignored `.env` file (per-variant values for the `db:*` commands go in gitignored -`{root}/{env}.env` overlays). See **[Environment variables in config](environment-variables.md)** +the config.json `environments` section). See **[Environment variables in config](environment-variables.md)** and **[Migrating a v6 app to v7](#migrating-a-v6-app-to-v7)** above. For example, instead of a plaintext URI: diff --git a/src/cli/appContext.php b/src/cli/appContext.php index d8d6cfd..8e2f821 100644 --- a/src/cli/appContext.php +++ b/src/cli/appContext.php @@ -2,6 +2,7 @@ namespace gcgov\framework\cli; +use gcgov\framework\models\config\variantEnvironment; use gcgov\framework\models\unifiedConfig; /** @@ -159,77 +160,67 @@ public function getServiceNamespaces(): array { /** - * Parse the unified {root}/config.json directly — no \app boot, no ext-mongodb. - * - * $variant '' → resolve against the ambient environment ({root}/.env is loaded - * first; the real process environment wins). - * $variant 'name' → resolve the SAME config.json with the variables from - * {root}/{name}.env applied as an overlay that takes precedence - * over the ambient environment — a foreign-environment read (used by - * db:restore/db:run/env) without activating anything. Variables - * missing from the overlay fall back to ambient values, so overlay - * files should define every environment-specific variable. + * Load and resolve the ACTIVE configuration from the unified {root}/config.json — + * no \app boot, no ext-mongodb. {root}/.env is loaded first; the real process + * environment wins. The CLI-only `environments` section is stripped before + * resolution (see loadVariantEnvironment()). * * @throws \gcgov\framework\cli\cliException */ - public function loadConfig( string $variant = '' ): unifiedConfig { - $file = $this->getConfigPath(); - $legacyHint = $this->legacyConfigHint( $variant ); - - if( !file_exists( $file ) ) { - throw new cliException( 'Missing config file: ' . $file . '. Commit a config.json at the application root that references environment variables with %env(...) and supply values via the process environment or a .env file.' . $legacyHint ); - } - - $overlayVars = []; - $source = $file; - if( $variant!=='' ) { - $overlayPath = $this->getVariantOverlayPath( $variant ); - if( !file_exists( $overlayPath ) ) { - throw new cliException( 'Missing environment overlay file: ' . $overlayPath . '. Create it with the "' . $variant . '" environment\'s variable values (see prod.env.example in the app template).' . $legacyHint ); - } - try { - $overlayVars = \gcgov\framework\services\environment\dotEnvLoader::parseFile( $overlayPath ); - } - catch( \gcgov\framework\services\environment\environmentException $e ) { - throw new cliException( $e->getMessage(), 0, $e ); - } - $source = $this->describeConfigSource( $variant ); + public function loadConfig(): unifiedConfig { + if( !file_exists( $this->getConfigPath() ) ) { + throw new cliException( 'Missing config file: ' . $this->getConfigPath() . '. Commit a config.json at the application root that references environment variables with %env(...) and supply values via the process environment or a .env file.' . $this->legacyConfigHint() ); } - \gcgov\framework\services\environment\dotEnvLoader::loadOnce( $this->rootDir ); - try { - $json = \gcgov\framework\services\environment\envVarResolver::resolveJson( (string)file_get_contents( $file ), $source, $overlayVars ); + return \gcgov\framework\services\environment\configLoader::load( $this->rootDir ); } catch( \gcgov\framework\services\environment\environmentException $e ) { - throw new cliException( 'Failed to resolve environment variables in ' . $source . ': ' . $e->getMessage(), 0, $e ); + throw new cliException( $e->getMessage(), 0, $e ); + } + } + + + /** + * Load and resolve ONE entry of config.json's `environments` section — a + * foreign-environment read (db:restore --from, db:run --env, gf env ). + * The entry's %env() references should use environment-prefixed variable names + * (e.g. PROD_MONGO_URI, defined in {root}/.env), so a missing value fails + * loudly instead of resolving to a local value. + * + * @throws \gcgov\framework\cli\cliException + */ + public function loadVariantEnvironment( string $name ): variantEnvironment { + if( !file_exists( $this->getConfigPath() ) ) { + throw new cliException( 'Missing config file: ' . $this->getConfigPath() . '.' . $this->legacyConfigHint( $name ) ); } try { - return unifiedConfig::jsonDeserialize( $json ); + return \gcgov\framework\services\environment\configLoader::loadVariantEnvironment( $this->rootDir, $name ); } - catch( \andrewsauder\jsonDeserialize\exceptions\jsonDeserializeException $e ) { - throw new cliException( 'Failed to parse ' . $file . ': ' . $e->getMessage(), 0, $e ); + catch( \gcgov\framework\services\environment\environmentException $e ) { + throw new cliException( $e->getMessage() . $this->legacyConfigHint( $name ), 0, $e ); } } /** - * Migration hint when pre-v7 config files are present (split app/config/app.json + - * environment{-variant}.json instead of the unified root config.json). + * Migration hint when pre-v7 config layouts are present: the v6 split + * app/config/app.json + environment{-variant}.json files, or a pre-release + * {root}/{variant}.env overlay file. */ - private function legacyConfigHint( string $variant ): string { + private function legacyConfigHint( string $variant = '' ): string { $legacyFiles = [ - $this->getConfigDir() . '/environment.json', - $this->getConfigDir() . '/app.json', + $this->getConfigDir() . '/environment.json' => 'app/config/environment.json', + $this->getConfigDir() . '/app.json' => 'app/config/app.json', ]; if( $variant!=='' ) { - $legacyFiles[] = $this->getConfigDir() . '/environment-' . $variant . '.json'; - $legacyFiles[] = $this->getConfigDir() . '/' . $variant . '.env'; + $legacyFiles[ $this->getConfigDir() . '/environment-' . $variant . '.json' ] = 'app/config/environment-' . $variant . '.json'; + $legacyFiles[ $this->rootDir . '/' . $variant . '.env' ] = $variant . '.env'; } - foreach( $legacyFiles as $legacyFile ) { + foreach( $legacyFiles as $legacyFile => $label ) { if( file_exists( $legacyFile ) ) { - return ' A legacy app/config/' . basename( $legacyFile ) . ' exists — this framework version reads a single {root}/config.json (with {root}/{name}.env overlay files for variants); see readme/gf.md "Migrating a v6 app to v7".'; + return ' A legacy ' . $label . ' exists — this framework version reads a single {root}/config.json whose `environments` section (with environment-prefixed variables like PROD_MONGO_URI in .env) replaces per-environment files; see readme/gf.md "Migrating a v6 app to v7".'; } } @@ -237,37 +228,25 @@ private function legacyConfigHint( string $variant ): string { } - /** The per-variant overlay env file read by loadConfig($variant). */ - public function getVariantOverlayPath( string $variant ): string { - return $this->rootDir . '/' . $variant . '.env'; - } - - - /** Human-readable description of where a variant's config comes from, for error/guard messages. */ + /** Human-readable description of where an environment's config comes from, for error/guard messages. */ public function describeConfigSource( string $variant = '' ): string { if( $variant==='' ) { return $this->getConfigPath(); } - return $this->getConfigPath() . ' (overlay: ' . $this->getVariantOverlayPath( $variant ) . ')'; + return $this->getConfigPath() . ' (environments.' . $variant . ')'; } /** - * Environment variant names available at the application root ({name}.env overlay files). - * glob's `*` does not match a leading dot, and `*.env` does not match `*.env.example`, - * so `.env`, `.env.local`, and the committed example file never appear as variants. + * Environment names declared in config.json's `environments` section — committed + * literals, so discovery and tab completion work on a fresh clone without any + * resolution or .env loading. * * @return string[] */ public function getEnvironmentVariants(): array { - $variants = []; - foreach( glob( $this->rootDir . '/*.env' ) ?: [] as $file ) { - $variants[] = basename( $file, '.env' ); - } - sort( $variants ); - - return $variants; + return \gcgov\framework\services\environment\configLoader::variantNames( $this->rootDir ); } } diff --git a/src/cli/commands/cliCommand.php b/src/cli/commands/cliCommand.php index d6b6f61..356bc9a 100644 --- a/src/cli/commands/cliCommand.php +++ b/src/cli/commands/cliCommand.php @@ -25,7 +25,7 @@ protected function configure(): void { $this->addOption( 'debug', null, InputOption::VALUE_NONE, 'Run the route with Xdebug step debugging enabled (replaces local-debug.bat)' ); $this->addOption( 'debug-host', null, InputOption::VALUE_REQUIRED, 'Xdebug client host', '127.0.0.1' ); $this->addOption( 'debug-port', null, InputOption::VALUE_REQUIRED, 'Xdebug client port', '9003' ); - $this->addOption( 'php', null, InputOption::VALUE_REQUIRED, 'PHP binary (or its directory) to run the route with. Defaults to GF_PHP, then environment.json phpPath, then the PHP running gf.' ); + $this->addOption( 'php', null, InputOption::VALUE_REQUIRED, 'PHP binary (or its directory) to run the route with. Defaults to GF_PHP, then config.json phpPath, then the PHP running gf.' ); $this->setHelp( 'Executes the route through the full framework lifecycle in a fresh PHP process, exactly like the legacy app/cli/index.php entry. Exit code is 0 on success and 1 when the response status is 400 or higher.' ); } @@ -45,13 +45,14 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $context = appContext::require(); $context->assertAppLoadable(); + // A missing config.json is tolerated (the child process reports it through the + // framework lifecycle), but a PRESENT config that fails to resolve must surface + // loudly here — swallowing it would silently discard the configured phpPath and + // run the route under the wrong interpreter. $unifiedConfig = null; - try { + if( file_exists( $context->getConfigPath() ) ) { $unifiedConfig = $context->loadConfig(); } - catch( cliException ) { - // environment.json missing — the child process will report it through the framework lifecycle - } $commandLine = array_merge( phpProcess::findPhpBinary( $input->getOption( 'php' ), $unifiedConfig ), phpProcess::requiredIniFlags() ); diff --git a/src/cli/commands/dbRestoreCommand.php b/src/cli/commands/dbRestoreCommand.php index 3d4de82..c28e6ae 100644 --- a/src/cli/commands/dbRestoreCommand.php +++ b/src/cli/commands/dbRestoreCommand.php @@ -19,14 +19,14 @@ final class dbRestoreCommand extends Command { protected function configure(): void { - $this->addOption( 'from', null, InputOption::VALUE_REQUIRED, 'Source environment variant (resolves the root config.json with the {from}.env overlay)', 'prod', envCommand::suggestEnvironments( ... ) ); - $this->addOption( 'to', null, InputOption::VALUE_REQUIRED, 'Target environment variant (resolved with the {to}.env overlay). Omit to use the active environment.', '', envCommand::suggestEnvironments( ... ) ); + $this->addOption( 'from', null, InputOption::VALUE_REQUIRED, 'Source environment (resolves the environments.{from} entry of config.json)', 'prod', envCommand::suggestEnvironments( ... ) ); + $this->addOption( 'to', null, InputOption::VALUE_REQUIRED, 'Target environment (resolves the environments.{to} entry of config.json). Omit to use the active configuration.', '', envCommand::suggestEnvironments( ... ) ); $this->addOption( 'db', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Restrict to the named database(s). Repeatable. Default: every database in the source config.' ); $this->addOption( 'dump-dir', null, InputOption::VALUE_REQUIRED, 'Directory to write the mongodump output to. Default: srv/tmp/mongodump-{timestamp}.' ); $this->addOption( 'keep-dump', null, InputOption::VALUE_NONE, 'Keep the dump directory after a successful restore' ); $this->addOption( 'yes', 'y', InputOption::VALUE_NONE, 'Skip the confirmation prompt' ); $this->addOption( 'allow-prod', null, InputOption::VALUE_NONE, 'Allow restoring INTO an environment whose type is "prod" (refused otherwise)' ); - $this->setHelp( 'Cross-platform replacement for the per-app restore-live-to-local.ps1: connection strings come from the root config.json, resolved per variant with the root-level {name}.env overlay files, instead of being hardcoded. Validate an overlay first with `gf env `. Requires the MongoDB Database Tools (mongodump/mongorestore) on PATH.' ); + $this->setHelp( 'Cross-platform replacement for the per-app restore-live-to-local.ps1: connection strings come from config.json — the active mongoDatabases for the local side, and the environments.{name} entries (environment-prefixed variables like PROD_MONGO_URI, defined in .env) for foreign environments. Validate an environment first with `gf env `. Requires the MongoDB Database Tools (mongodump/mongorestore) on PATH.' ); } @@ -40,27 +40,38 @@ protected function execute( InputInterface $input, OutputInterface $output ): in throw new cliException( '--from requires an environment variant name (e.g. --from=prod)' ); } - $sourceConfig = $context->loadConfig( $fromVariant ); - $targetConfig = $context->loadConfig( $toVariant ); + $sourceEnvironment = $context->loadVariantEnvironment( $fromVariant ); + $sourceDatabases = $sourceEnvironment->mongoDatabases; - // Guard by variant NAME first: the resolved `type` comes from an env var, so an - // incomplete overlay (e.g. prod.env missing APP_TYPE) must not defeat the refusal. + if( $toVariant==='' ) { + $activeConfig = $context->loadConfig(); + $targetType = $activeConfig->type; + $targetDatabases = $activeConfig->mongoDatabases; + } + else { + $targetEnvironment = $context->loadVariantEnvironment( $toVariant ); + $targetType = $targetEnvironment->type; + $targetDatabases = $targetEnvironment->mongoDatabases; + } + + // Guard by environment NAME as well as by type: type comes from a committed + // literal in environments.{name}, but an entry could omit it. if( $toVariant==='prod' && !$input->getOption( 'allow-prod' ) ) { - throw new cliException( 'Refusing to restore into the environment variant named "prod". Pass --allow-prod if you really mean it.' ); + throw new cliException( 'Refusing to restore into the environment named "prod". Pass --allow-prod if you really mean it.' ); } - if( $targetConfig->type==='prod' && !$input->getOption( 'allow-prod' ) ) { + if( $targetType==='prod' && !$input->getOption( 'allow-prod' ) ) { throw new cliException( 'Refusing to restore into an environment with type "prod" (' . $context->describeConfigSource( $toVariant ) . '). Pass --allow-prod if you really mean it.' ); } - $pairs = self::pairDatabases( $sourceConfig->mongoDatabases, $targetConfig->mongoDatabases, $input->getOption( 'db' ) ); + $pairs = self::pairDatabases( $sourceDatabases, $targetDatabases, $input->getOption( 'db' ) ); if( count( $pairs[ 'matched' ] )===0 ) { - throw new cliException( 'No database pairs to restore. Source config databases: ' . implode( ', ', array_map( fn( mongoDatabase $db ) => $db->database, $sourceConfig->mongoDatabases ) ) ); + throw new cliException( 'No database pairs to restore. Source environment databases: ' . implode( ', ', array_map( fn( mongoDatabase $db ) => $db->database, $sourceDatabases ) ) ); } $identicalPairs = self::findIdenticalPairs( $pairs[ 'matched' ] ); if( count( $identicalPairs )>0 ) { [ $sourceDb ] = $identicalPairs[ 0 ]; - throw new cliException( 'Source and target resolve to the same database (' . $sourceDb->database . ' @ ' . mongoTools::redactUri( $sourceDb->uri ) . '). If you used a {variant}.env overlay, it is probably incomplete — every environment-specific variable must be set in it (missing ones silently fall back to your local values). Validate with `gf env ' . $fromVariant . '`.' ); + throw new cliException( 'Source and target resolve to the same database (' . $sourceDb->database . ' @ ' . mongoTools::redactUri( $sourceDb->uri ) . '). Check that environments.' . $fromVariant . ' in config.json references its own variables (e.g. ' . strtoupper( $fromVariant ) . '_MONGO_URI) with the right values in .env. Validate with `gf env ' . $fromVariant . '`.' ); } foreach( $pairs[ 'unmatched' ] as $unmatchedName ) { $io->warning( 'Source database "' . $unmatchedName . '" has no matching database in the target config — skipped.' ); @@ -70,7 +81,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $mongodumpBinary = mongoTools::findBinary( 'mongodump' ); $mongorestoreBinary = mongoTools::findBinary( 'mongorestore' ); - $io->section( 'Restore plan (' . $fromVariant . ' -> ' . ( $toVariant===''?'active config.json':$toVariant ) . ')' ); + $io->section( 'Restore plan (' . $fromVariant . ' -> ' . ( $toVariant===''?'active configuration':$toVariant ) . ')' ); foreach( $pairs[ 'matched' ] as [ $sourceDb, $targetDb ] ) { $io->text( ' ' . $sourceDb->database . ' @ ' . mongoTools::redactUri( $sourceDb->uri ) . ' -> ' . $targetDb->database . ' @ ' . mongoTools::redactUri( $targetDb->uri ) . ' (--drop)' ); } diff --git a/src/cli/commands/dbRunCommand.php b/src/cli/commands/dbRunCommand.php index 1af6a26..cd6813d 100644 --- a/src/cli/commands/dbRunCommand.php +++ b/src/cli/commands/dbRunCommand.php @@ -19,7 +19,7 @@ final class dbRunCommand extends Command { protected function configure(): void { $this->addArgument( 'script', InputArgument::REQUIRED, 'Path to the .js script to execute with mongosh' ); $this->addArgument( 'mongoshArgs', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Extra arguments passed through to mongosh (prefix with --)' ); - $this->addOption( 'env', null, InputOption::VALUE_REQUIRED, 'Environment variant to read the connection from (resolves the root config.json with the {env}.env overlay). Omit to use the active environment.', '', envCommand::suggestEnvironments( ... ) ); + $this->addOption( 'env', null, InputOption::VALUE_REQUIRED, 'Environment to read the connection from (resolves the environments.{env} entry of config.json). Omit to use the active configuration.', '', envCommand::suggestEnvironments( ... ) ); $this->addOption( 'db', null, InputOption::VALUE_REQUIRED, 'Database name from the mongoDatabases config to run against. Default: the entry marked default (or the only entry).' ); $this->setHelp( 'Replaces per-script mongosh invocations with hardcoded connection strings, e.g.: gf db:run db/create-admin.js --env=local. Everything after -- is forwarded to mongosh.' ); } @@ -33,18 +33,19 @@ protected function execute( InputInterface $input, OutputInterface $output ): in throw new cliException( 'Script not found: ' . $scriptPath ); } - $environmentConfig = $context->loadConfig( (string)$input->getOption( 'env' ) ); + $environment = (string)$input->getOption( 'env' ); + $mongoDatabases = $environment==='' ? $context->loadConfig()->mongoDatabases : $context->loadVariantEnvironment( $environment )->mongoDatabases; $databaseName = (string)( $input->getOption( 'db' ) ?? '' ); $mongoDatabase = null; - foreach( $environmentConfig->mongoDatabases as $candidate ) { - if( $databaseName!=='' ? $candidate->database===$databaseName : ( $candidate->default || count( $environmentConfig->mongoDatabases )===1 ) ) { + foreach( $mongoDatabases as $candidate ) { + if( $databaseName!=='' ? $candidate->database===$databaseName : ( $candidate->default || count( $mongoDatabases )===1 ) ) { $mongoDatabase = $candidate; break; } } if( $mongoDatabase===null ) { - $available = implode( ', ', array_map( fn( $db ) => $db->database, $environmentConfig->mongoDatabases ) ); + $available = implode( ', ', array_map( fn( $db ) => $db->database, $mongoDatabases ) ); throw new cliException( $databaseName==='' ? 'No default mongo database found in the environment config. Available: ' . $available . '. Choose one with --db.' : 'No mongo database named "' . $databaseName . '" in the environment config. Available: ' . $available ); } diff --git a/src/cli/commands/envCommand.php b/src/cli/commands/envCommand.php index f7e1a74..1947ef4 100644 --- a/src/cli/commands/envCommand.php +++ b/src/cli/commands/envCommand.php @@ -13,12 +13,12 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -#[AsCommand( name: 'env', description: 'List environment variants and validate that a variant\'s app/config/{name}.env overlay fully resolves app/config/environment.json' )] +#[AsCommand( name: 'env', description: 'List config.json environments and validate that %env(...) references resolve (active config, or an environments.{name} entry)' )] final class envCommand extends Command { protected function configure(): void { - $this->addArgument( 'environment', InputArgument::OPTIONAL, 'Variant to validate ({root}/{name}.env). Omit to list variants and check the active environment.', null, self::suggestEnvironments( ... ) ); - $this->setHelp( 'Environment selection is environment-variable driven: the root config.json references variables with %env(...), and the process environment / {root}/.env supplies the values. This command validates that resolution. `gf env ` resolves config.json with the {root}/{name}.env overlay applied — use it to prove an overlay (e.g. prod.env, used by db:restore/db:run) defines every variable it needs before relying on it.' ); + $this->addArgument( 'environment', InputArgument::OPTIONAL, 'environments.{name} entry of config.json to validate. Omit to list environments and check the active configuration.', null, self::suggestEnvironments( ... ) ); + $this->setHelp( 'Environment selection is environment-variable driven: the root config.json references variables with %env(...), and the process environment / {root}/.env supplies the values. This command validates that resolution. `gf env ` resolves the environments.{name} entry of config.json — the per-environment connection info used by db:restore/db:run, referencing environment-prefixed variables (e.g. PROD_MONGO_URI in .env) — and fails naming the first unresolvable variable.' ); } @@ -31,23 +31,23 @@ protected function execute( InputInterface $input, OutputInterface $output ): in if( $environment==='' ) { $variants = $context->getEnvironmentVariants(); $io->text( count( $variants )===0 - ? 'No variant overlay files found at the application root (create {name}.env — see prod.env.example in the app template).' - : 'Variant overlay files: ' . implode( ', ', array_map( fn( string $v ) => $v . '.env', $variants ) ) ); + ? 'No environments section in config.json (define environments.{name} with type + mongoDatabases to enable gf db:restore/db:run against other environments).' + : 'Environments defined in config.json: ' . implode( ', ', $variants ) ); - $io->section( 'Active environment (config.json + ambient environment)' ); + $io->section( 'Active configuration (config.json + ambient environment)' ); - return $this->validate( $context, '', $io ); + return $this->validateActive( $context, $io ); } - $io->section( 'Variant "' . $environment . '" (' . $context->describeConfigSource( $environment ) . ')' ); + $io->section( 'Environment "' . $environment . '" (' . $context->describeConfigSource( $environment ) . ')' ); - return $this->validate( $context, $environment, $io ); + return $this->validateVariant( $context, $environment, $io ); } - private function validate( appContext $context, string $variant, SymfonyStyle $io ): int { + private function validateActive( appContext $context, SymfonyStyle $io ): int { try { - $environmentConfig = $context->loadConfig( $variant ); + $unifiedConfig = $context->loadConfig(); } catch( cliException $e ) { $io->error( $e->getMessage() ); @@ -55,14 +55,14 @@ private function validate( appContext $context, string $variant, SymfonyStyle $i return Command::FAILURE; } - $io->text( 'type: ' . $environmentConfig->type ); - if( $environmentConfig->serverName!=='' ) { - $io->text( 'serverName: ' . $environmentConfig->serverName ); + $io->text( 'type: ' . $unifiedConfig->type ); + if( $unifiedConfig->serverName!=='' ) { + $io->text( 'serverName: ' . $unifiedConfig->serverName ); } - if( $environmentConfig->rootUrl!=='' ) { - $io->text( 'rootUrl: ' . $environmentConfig->rootUrl . ' basePath: ' . $environmentConfig->getBasePath() ); + if( $unifiedConfig->rootUrl!=='' ) { + $io->text( 'rootUrl: ' . $unifiedConfig->rootUrl . ' basePath: ' . $unifiedConfig->getBasePath() ); } - foreach( $environmentConfig->mongoDatabases as $mongoDatabase ) { + foreach( $unifiedConfig->mongoDatabases as $mongoDatabase ) { $io->text( 'mongo: ' . $mongoDatabase->database . ' @ ' . mongoTools::redactUri( $mongoDatabase->uri ) . ( $mongoDatabase->default ? ' (default)' : '' ) ); } @@ -72,6 +72,30 @@ private function validate( appContext $context, string $variant, SymfonyStyle $i } + private function validateVariant( appContext $context, string $environment, SymfonyStyle $io ): int { + try { + $variantEnvironment = $context->loadVariantEnvironment( $environment ); + } + catch( cliException $e ) { + $io->error( $e->getMessage() ); + + return Command::FAILURE; + } + + $io->text( 'type: ' . $variantEnvironment->type ); + foreach( $variantEnvironment->mongoDatabases as $mongoDatabase ) { + $io->text( 'mongo: ' . $mongoDatabase->database . ' @ ' . mongoTools::redactUri( $mongoDatabase->uri ) . ( $mongoDatabase->default ? ' (default)' : '' ) ); + } + if( $variantEnvironment->type==='' ) { + $io->warning( 'environments.' . $environment . ' has no "type" — set it to a committed literal (e.g. "prod"); the db:restore prod guard relies on it.' ); + } + + $io->success( 'Resolved successfully — every %env(...) reference has a value.' ); + + return Command::SUCCESS; + } + + /** * @return string[] */ diff --git a/src/cli/commands/setupCommand.php b/src/cli/commands/setupCommand.php index a43480b..9fada84 100644 --- a/src/cli/commands/setupCommand.php +++ b/src/cli/commands/setupCommand.php @@ -74,14 +74,19 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $io->text( [ 'Application root: ' . $context->rootDir, 'To skip replacing a value, press enter.', '' ] ); // Only prompt for values whose {token} actually appears in the project tree, so - // templates that no longer carry a token (e.g. the prod_* config set) stop asking for it. - $prompts = self::filterPromptsToPresentTokens( self::PROMPTS, $context->rootDir ); - $microsoftPrompts = self::filterPromptsToPresentTokens( self::MICROSOFT_PROMPTS, $context->rootDir ); + // templates that no longer carry a token (e.g. the prod_* config set) stop asking + // for it. The haystack (one full tree read) is built once and shared. + $haystack = self::buildTokenHaystack( $context->rootDir ); + $prompts = self::filterPromptsToPresentTokens( self::PROMPTS, $context->rootDir, $haystack ); + $microsoftPrompts = self::filterPromptsToPresentTokens( self::MICROSOFT_PROMPTS, $context->rootDir, $haystack ); + if( count( $prompts )===0 && count( $microsoftPrompts )===0 ) { + $io->text( 'No {placeholder} tokens found in the project — it appears to be already set up.' ); + } if( count( $microsoftPrompts )>0 && $io->confirm( 'Do you want to define Microsoft Azure App ids during set up?', false ) ) { $prompts = array_merge( $prompts, $microsoftPrompts ); } - if( count( $prompts )===0 ) { - $io->text( 'No {placeholder} tokens found in the project — it appears to be already set up.' ); + elseif( count( $microsoftPrompts )>0 && count( $prompts )===0 ) { + $io->text( 'Skipping the Microsoft Azure prompts — their {tokens} remain in place for a later re-run. No other tokens to replace.' ); } $inputs = []; @@ -158,14 +163,11 @@ public static function tokensForPromptKey( string $key ): array { /** - * Keep only the prompts whose token(s) actually appear somewhere in the project's - * token-eligible files, so setup never asks for values it cannot place. - * - * @param array $prompts prompt key => label - * - * @return array + * Concatenated contents of every token-eligible file — the haystack prompt + * filtering searches. Build it once per run and pass it to each + * filterPromptsToPresentTokens() call (the tree walk + reads are not cheap). */ - public static function filterPromptsToPresentTokens( array $prompts, string $rootDir ): array { + public static function buildTokenHaystack( string $rootDir ): string { $haystack = ''; foreach( tokenReplacer::findEligibleFiles( $rootDir ) as $filePath ) { $contents = file_get_contents( $filePath ); @@ -174,6 +176,22 @@ public static function filterPromptsToPresentTokens( array $prompts, string $roo } } + return $haystack; + } + + + /** + * Keep only the prompts whose token(s) actually appear somewhere in the project's + * token-eligible files, so setup never asks for values it cannot place. + * + * @param array $prompts prompt key => label + * @param ?string $haystack pass buildTokenHaystack() when filtering multiple sets + * + * @return array + */ + public static function filterPromptsToPresentTokens( array $prompts, string $rootDir, ?string $haystack = null ): array { + $haystack ??= self::buildTokenHaystack( $rootDir ); + return array_filter( $prompts, function( string $key ) use ( $haystack ): bool { foreach( self::tokensForPromptKey( $key ) as $token ) { if( str_contains( $haystack, $token ) ) { diff --git a/src/config.php b/src/config.php index cba1c8d..668274b 100644 --- a/src/config.php +++ b/src/config.php @@ -125,7 +125,18 @@ private static function setSrvDir(): void { * The absolute path of the unified config file. */ public static function getConfigFilePath(): string { - return self::getRootDir() . '/config.json'; + return \gcgov\framework\services\environment\configLoader::configFilePath( self::getRootDir() ); + } + + + /** + * @deprecated v7 — the app/config directory no longer exists (configuration is the + * single {root}/config.json; see getConfigFilePath()). Kept so v6 code + * that located files under app/config keeps resolving the same path. + */ + #[\JetBrains\PhpStorm\Deprecated( reason: 'v7: configuration is the single {root}/config.json', replacement: '\gcgov\framework\config::getConfigFilePath()' )] + public static function getConfigDir(): string { + return self::getAppDir() . '/config/'; } @@ -145,20 +156,12 @@ private static function unifiedConfig(): unifiedConfig { * @throws \gcgov\framework\exceptions\configException */ private static function setUnifiedConfig(): void { - $configFile = self::getConfigFilePath(); - if( !file_exists( $configFile ) ) { - throw new \gcgov\framework\exceptions\configException( 'Missing config file at ' . $configFile ); - } - - \gcgov\framework\services\environment\dotEnvLoader::loadOnce( self::getRootDir() ); try { - $json = \gcgov\framework\services\environment\envVarResolver::resolveJson( (string)file_get_contents( $configFile ), $configFile ); + self::$unifiedConfig = \gcgov\framework\services\environment\configLoader::load( self::getRootDir() ); } catch( \gcgov\framework\services\environment\environmentException $e ) { throw new \gcgov\framework\exceptions\configException( $e->getMessage(), 500, $e ); } - - self::$unifiedConfig = unifiedConfig::jsonDeserialize( $json ); } @@ -180,13 +183,13 @@ public static function getEnvironmentConfig(): unifiedConfig { /** * @deprecated v7 — use the flattened static accessors instead: `config::getAppConfig()->settings` becomes * `config::getSettings()`, `->app` becomes `config::getApp()`, `->email` becomes `config::getEmail()`. - * Returns the unified config object, which carries the app/email/settings sections, - * so existing call sites keep working until they migrate. + * Returns a v6-shaped VIEW (app/email/settings only) over the unified config, so existing + * call sites — including ones that serialize the object — keep their exact v6 behavior. * @throws \gcgov\framework\exceptions\configException */ #[\JetBrains\PhpStorm\Deprecated( reason: 'v7: config values are exposed directly on config', replacement: '\gcgov\framework\config' )] - public static function getAppConfig(): unifiedConfig { - return self::unifiedConfig(); + public static function getAppConfig(): \gcgov\framework\models\appConfig { + return new \gcgov\framework\models\appConfig( self::unifiedConfig() ); } diff --git a/src/models/appConfig.php b/src/models/appConfig.php new file mode 100644 index 0000000..378c152 --- /dev/null +++ b/src/models/appConfig.php @@ -0,0 +1,34 @@ +app = $unifiedConfig->app; + $this->email = $unifiedConfig->email; + $this->settings = $unifiedConfig->settings; + } + +} diff --git a/src/models/config/variantEnvironment.php b/src/models/config/variantEnvironment.php new file mode 100644 index 0000000..06a0ca6 --- /dev/null +++ b/src/models/config/variantEnvironment.php @@ -0,0 +1,26 @@ +environments ); + envVarResolver::resolveDecoded( $decoded, $configFile ); + + return self::hydrate( unifiedConfig::class, $decoded, $configFile ); + } + + + /** + * Load and resolve ONE entry of the `environments` section — a + * foreign-environment read for the gf CLI. + * + * @throws \gcgov\framework\services\environment\environmentException + */ + public static function loadVariantEnvironment( string $rootDir, string $name ): variantEnvironment { + $configFile = self::configFilePath( $rootDir ); + $decoded = self::readAndDecode( $rootDir, $configFile ); + + if( is_string( $decoded ) ) { + throw new environmentException( 'Failed to parse ' . $configFile . ': the file is not a valid JSON object.' ); + } + + $environments = $decoded->environments ?? null; + if( !$environments instanceof \stdClass || !isset( $environments->{$name} ) || !$environments->{$name} instanceof \stdClass ) { + $available = $environments instanceof \stdClass ? array_keys( get_object_vars( $environments ) ) : []; + throw new environmentException( 'No "' . $name . '" entry in the environments section of ' . $configFile . '. ' . ( count( $available )>0 ? 'Defined environments: ' . implode( ', ', $available ) . '.' : 'Define one, e.g. "environments": { "' . $name . '": { "type": "' . $name . '", "mongoDatabases": [ { "default": true, "database": "%env(' . strtoupper( $name ) . '_MONGO_DATABASE)%", "uri": "%env(' . strtoupper( $name ) . '_MONGO_URI)%" } ] } } with the variable values in your .env.' ) ); + } + + $source = $configFile . ' (environments.' . $name . ')'; + envVarResolver::resolveDecoded( $environments->{$name}, $source ); + + return self::hydrate( variantEnvironment::class, $environments->{$name}, $source ); + } + + + /** + * Environment names declared in config.json's `environments` section. + * Read WITHOUT resolution or .env loading — the keys are literals — so this + * is safe for tab completion in any state. + * + * @return string[] + */ + public static function variantNames( string $rootDir ): array { + $configFile = self::configFilePath( $rootDir ); + if( !file_exists( $configFile ) ) { + return []; + } + + $decoded = json_decode( (string)file_get_contents( $configFile ), false ); + if( !$decoded instanceof \stdClass || !( $decoded->environments ?? null ) instanceof \stdClass ) { + return []; + } + + $names = array_keys( get_object_vars( $decoded->environments ) ); + sort( $names ); + + return $names; + } + + + /** + * @return \stdClass|string Decoded object, or the raw string when it does not decode to an object. + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function readAndDecode( string $rootDir, string $configFile ): \stdClass|string { + if( !file_exists( $configFile ) ) { + throw new environmentException( 'Missing config file: ' . $configFile ); + } + + dotEnvLoader::loadOnce( $rootDir ); + + $json = (string)file_get_contents( $configFile ); + $decoded = json_decode( $json, false ); + + return $decoded instanceof \stdClass ? $decoded : $json; + } + + + /** + * @template T of \andrewsauder\jsonDeserialize\jsonDeserialize + * + * @param class-string $class + * @param \stdClass|string $data + * + * @return T + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function hydrate( string $class, \stdClass|string $data, string $source ): object { + try { + return $class::jsonDeserialize( $data ); + } + catch( \andrewsauder\jsonDeserialize\exceptions\jsonDeserializeException $e ) { + throw new environmentException( 'Failed to parse ' . $source . ': ' . $e->getMessage(), 0, $e ); + } + } + +} diff --git a/src/services/environment/dotEnvLoader.php b/src/services/environment/dotEnvLoader.php index 8099c9a..69e82c2 100644 --- a/src/services/environment/dotEnvLoader.php +++ b/src/services/environment/dotEnvLoader.php @@ -8,19 +8,21 @@ /** * Idempotent wrapper over symfony/dotenv that loads a project's `.env` file(s) - * once per process, before config JSON is resolved by {@see envVarResolver}. + * once per process, before {root}/config.json is resolved by {@see envVarResolver}. * * Precedence (highest wins): real process environment > .env.local > .env. * The real container/process environment always wins — Symfony's Dotenv never * overrides variables that are already present in the environment. `usePutenv()` * is enabled so that call sites reading through `getenv()` (e.g. `GF_PHP` in the - * gf CLI) also observe values loaded from .env files. + * gf CLI) also observe values loaded from .env files. Either file may exist on + * its own — a project keeping only machine-local values in `.env.local` loads + * exactly like one with only `.env`. * * There is deliberately no APP_ENV cascade: environment selection is simply * which variables the process environment (or .env) supplies. The gf CLI reads - * a *foreign* environment's values via per-variant overlay files - * (app/config/{name}.env, parsed with parseFile() — never loaded into the - * process environment). + * a *foreign* environment's values via the `environments.{name}` section of + * config.json, referencing distinctly-named variables (e.g. PROD_MONGO_URI) + * that live in the same `.env`. */ final class dotEnvLoader { @@ -29,8 +31,10 @@ final class dotEnvLoader { /** - * Load {root}/.env then {root}/.env.local when present. No-op when neither + * Load {root}/.env and/or {root}/.env.local when present. No-op when neither * exists or when this root has already been loaded in the current process. + * + * @throws \gcgov\framework\services\environment\environmentException When a present file has invalid syntax */ public static function loadOnce( string $rootDir ): void { $rootDir = rtrim( str_replace( '\\', '/', $rootDir ), '/' ); @@ -40,50 +44,22 @@ public static function loadOnce( string $rootDir ): void { } self::$loadedRoots[ $rootDir ] = true; - $envFile = $rootDir . '/.env'; - if( !file_exists( $envFile ) ) { - // Nothing to load; still mark as processed so we don't re-stat every call. + // load() applies files left to right with later files overriding earlier ones, + // never overriding real environment variables that are already set. + $files = array_values( array_filter( [ $rootDir . '/.env', $rootDir . '/.env.local' ], 'file_exists' ) ); + if( count( $files )===0 ) { + // Nothing to load; still marked as processed so we don't re-stat every call. return; } $dotenv = new Dotenv(); $dotenv->usePutenv(); - // load() reads .env and, when present, .env.local — never overriding real - // environment variables that are already set. - $files = [ $envFile ]; - $localFile = $rootDir . '/.env.local'; - if( file_exists( $localFile ) ) { - $files[] = $localFile; - } - - $dotenv->load( ...$files ); - } - - - /** - * Parse a dotenv-format file into an array WITHOUT mutating the process - * environment. Used by the gf CLI to build the overlay for foreign-environment - * reads (e.g. app/config/prod.env for `db:restore --from=prod`). - * - * @return array - * @throws \gcgov\framework\services\environment\environmentException - */ - public static function parseFile( string $path ): array { - if( !is_file( $path ) || !is_readable( $path ) ) { - throw new environmentException( 'Environment file "' . $path . '" does not exist or is not readable.' ); - } - - $contents = file_get_contents( $path ); - if( $contents===false ) { - throw new environmentException( 'Failed reading environment file "' . $path . '".' ); - } - try { - return ( new Dotenv() )->parse( $contents, $path ); + $dotenv->load( ...$files ); } catch( \Symfony\Component\Dotenv\Exception\FormatException $e ) { - throw new environmentException( 'Invalid syntax in environment file "' . $path . '": ' . $e->getMessage(), 0, $e ); + throw new environmentException( 'Invalid syntax in environment file (' . implode( ', ', $files ) . '): ' . $e->getMessage(), 0, $e ); } } diff --git a/src/services/environment/envVarResolver.php b/src/services/environment/envVarResolver.php index 2625d6a..da2ba3e 100644 --- a/src/services/environment/envVarResolver.php +++ b/src/services/environment/envVarResolver.php @@ -5,17 +5,19 @@ namespace gcgov\framework\services\environment; /** - * Resolves Symfony-style `%env(...)%` references inside a config JSON document. + * Resolves Symfony-style `%env(...)%` references inside the unified {root}/config.json. * * This is a small, standalone, directly-testable resolver — it is intentionally * NOT coupled to Symfony's dependency-injection container (where Symfony's own - * env processors live). It is applied to the raw JSON string of app.json / - * environment.json before that string is handed to `jsonDeserialize()`. + * env processors live). The framework applies it to config.json before the JSON + * is handed to `jsonDeserialize()` (see configLoader); the gf CLI applies it to + * the `environments.{name}` subtree for foreign-environment reads. * * ## Backwards compatibility - * A config file that contains no `%env(` substring is returned byte-for-byte - * unchanged (including its existing malformed-JSON error behavior). Only files - * that opt in by using `%env(...)%` are decoded and re-serialized. + * A config string that contains no `%env(` substring is returned byte-for-byte + * unchanged. Only values that opt in by using `%env(...)%` are touched. A value + * that still contains the literal text `%env(` AFTER resolution throws — there is + * no escape syntax, so a config value cannot contain that literal text. * * ## Syntax * `%env(PROCESSOR:...:VAR_NAME)%` @@ -35,33 +37,43 @@ * Unlike Symfony — where `default:` names a fallback *parameter* — here `default` * takes a **literal** fallback value. It must be innermost (closest to the var), * and its argument is greedy: everything between `default:` and the final `:VAR`, - * so colons are legal in the fallback: + * so colons are legal in the fallback (a `)` is not — the reference syntax ends + * at the first `)`): * `%env(default:mongodb://mongodb:27017:MONGO_URI)%` * The fallback applies only when the variable is unset: * `%env(default::VAR)%` → '' when VAR is unset * `%env(int:default:587:SMTP_PORT)%` → int 587 when SMTP_PORT is unset + * + * ## Reserved (blocked) variable names — request-data injection guard + * In web SAPIs, request data leaks into the ambient lookup sources: CGI/FastCGI + * turns request headers into `HTTP_*` variables that reach the real process + * environment (getenv) and, depending on `variables_order`, `$_ENV`; `$_SERVER` + * additionally carries request-derived CGI meta-variables (SERVER_NAME, + * PHP_AUTH_PW, QUERY_STRING, …). To guarantee a `%env(...)%` reference can never + * be satisfied by request data, names matching the CGI meta-variable set are + * treated as UNSET in every ambient source — `default:` applies, otherwise the + * reference fails loudly. Do not name real configuration variables after CGI + * meta-variables. */ final class envVarResolver { + /** Name prefixes never resolved from the ambient environment (request-derived under web SAPIs). */ + private const array BLOCKED_NAME_PREFIXES = [ 'HTTP_', 'SERVER_', 'REQUEST_', 'REMOTE_', 'PHP_AUTH_', 'SCRIPT_', 'DOCUMENT_' ]; + + /** Exact names never resolved from the ambient environment (request-derived under web SAPIs). */ + private const array BLOCKED_NAMES = [ 'HTTPS', 'QUERY_STRING', 'CONTENT_TYPE', 'CONTENT_LENGTH', 'AUTH_TYPE', 'GATEWAY_INTERFACE', 'PHP_SELF', 'PATH_INFO', 'PATH_TRANSLATED' ]; + + /** * Resolve every `%env(...)%` reference in $json. * - * @param string $json Raw config JSON. - * @param string $sourceDescription Human-readable source (e.g. the file path) for error messages. - * @param array $overlayVars Variables that take precedence over the ambient - * environment during this resolution. Used by the gf CLI - * to resolve a *foreign* environment's config (e.g. - * `app/config/prod.env` for `db:restore --from=prod`) — - * an explicit variant request must beat the local - * environment. A variable missing from the overlay falls - * back to the ambient lookup, so an incomplete overlay - * silently picks up local values — overlay files should - * define every environment-specific variable. + * @param string $json Raw config JSON. + * @param string $sourceDescription Human-readable source (e.g. the file path) for error messages. * * @return string|\stdClass The original string (fast path / undecodable), or the resolved object tree. * @throws \gcgov\framework\services\environment\environmentException */ - public static function resolveJson( string $json, string $sourceDescription, array $overlayVars = [] ): string|\stdClass { + public static function resolveJson( string $json, string $sourceDescription ): string|\stdClass { // Fast path: configs that do not opt in take a byte-identical route, preserving // full backwards compatibility (including today's malformed-JSON error behavior). if( !str_contains( $json, '%env(' ) ) { @@ -75,37 +87,48 @@ public static function resolveJson( string $json, string $sourceDescription, arr return $json; } - $resolved = self::resolveNode( $decoded, $sourceDescription, $overlayVars ); + return self::resolveDecoded( $decoded, $sourceDescription ); + } + - return $resolved instanceof \stdClass ? $resolved : $json; + /** + * Resolve every `%env(...)%` reference in an already-decoded config tree, in place. + * Used by configLoader so the `environments` subtree can be stripped/extracted + * before resolution without a re-encode round trip. + * + * @throws \gcgov\framework\services\environment\environmentException + */ + public static function resolveDecoded( \stdClass $decoded, string $sourceDescription ): \stdClass { + self::resolveNode( $decoded, $sourceDescription ); + + return $decoded; } /** * Recursively resolve string leaves within the decoded tree. * - * @param mixed $node - * @param string $sourceDescription - * @param array $overlayVars + * @param mixed $node + * @param string $sourceDescription * * @return mixed * @throws \gcgov\framework\services\environment\environmentException */ - private static function resolveNode( mixed $node, string $sourceDescription, array $overlayVars ): mixed { + private static function resolveNode( mixed $node, string $sourceDescription ): mixed { if( $node instanceof \stdClass ) { foreach( get_object_vars( $node ) as $key => $value ) { - $node->$key = self::resolveNode( $value, $sourceDescription, $overlayVars ); + $node->$key = self::resolveNode( $value, $sourceDescription ); } return $node; } if( is_array( $node ) ) { - return array_map( static fn( $value ) => self::resolveNode( $value, $sourceDescription, $overlayVars ), $node ); + return array_map( static fn( $value ) => self::resolveNode( $value, $sourceDescription ), $node ); } if( is_string( $node ) ) { - return self::resolveString( $node, $sourceDescription, $overlayVars ); + return self::resolveString( $node, $sourceDescription ); } return $node; @@ -115,24 +138,22 @@ private static function resolveNode( mixed $node, string $sourceDescription, arr /** * Resolve `%env(...)%` occurrences in a single string leaf. * - * @param array $overlayVars - * * @return mixed Typed value when the whole string is one reference; a string otherwise. * @throws \gcgov\framework\services\environment\environmentException */ - private static function resolveString( string $value, string $sourceDescription, array $overlayVars ): mixed { + private static function resolveString( string $value, string $sourceDescription ): mixed { if( !str_contains( $value, '%env(' ) ) { return $value; } // Whole-string reference → typed result. if( preg_match( '/^%env\(([^)]+)\)%$/', $value, $matches )===1 ) { - return self::resolveExpression( $matches[ 1 ], $sourceDescription, $overlayVars ); + return self::resolveExpression( $matches[ 1 ], $sourceDescription ); } // Embedded reference(s) → string substitution. - $result = preg_replace_callback( '/%env\(([^)]+)\)%/', static function( array $matches ) use ( $sourceDescription, $overlayVars ): string { - $resolved = self::resolveExpression( $matches[ 1 ], $sourceDescription, $overlayVars ); + $result = preg_replace_callback( '/%env\(([^)]+)\)%/', static function( array $matches ) use ( $sourceDescription ): string { + $resolved = self::resolveExpression( $matches[ 1 ], $sourceDescription ); if( is_bool( $resolved ) ) { return $resolved ? 'true' : 'false'; } @@ -143,21 +164,26 @@ private static function resolveString( string $value, string $sourceDescription, return (string)$resolved; } throw new environmentException( 'Cannot embed non-scalar environment value for %env(' . $matches[ 1 ] . ')% inside a larger string in ' . $sourceDescription . '. Reference it as the whole value ("%env(...)%") instead.' ); - }, $value ); + }, $value ) ?? $value; - return $result ?? $value; + // Fail loud instead of silently shipping an unresolved reference: a leftover + // '%env(' means malformed syntax (e.g. a ')' inside a default: literal) or a + // literal '%env(' in a config value — neither is supported. + if( str_contains( $result, '%env(' ) ) { + throw new environmentException( 'Unresolvable %env(...) reference in ' . $sourceDescription . ': "' . $value . '". The reference syntax ends at the first ")" — a ")" inside a default: literal is not supported, and a config value cannot contain the literal text "%env(".' ); + } + + return $result; } /** * Resolve one `%env(...)%` expression (the text between the parentheses). * - * @param array $overlayVars - * * @return mixed * @throws \gcgov\framework\services\environment\environmentException */ - private static function resolveExpression( string $expression, string $sourceDescription, array $overlayVars = [] ): mixed { + private static function resolveExpression( string $expression, string $sourceDescription ): mixed { $lastColon = strrpos( $expression, ':' ); if( $lastColon===false ) { $varName = $expression; @@ -193,9 +219,12 @@ private static function resolveExpression( string $expression, string $sourceDes } // Environment lookup (with optional literal default fallback). - $raw = self::lookupEnv( $varName, $overlayVars ); + $raw = self::lookupEnv( $varName ); if( $raw===null ) { if( $default===null ) { + if( self::isBlockedName( $varName ) ) { + throw new environmentException( '"' . $varName . '" is a reserved CGI meta-variable name and is never resolved from the environment (referenced as "%env(' . $expression . ')%" in ' . $sourceDescription . '). Rename the configuration variable.' ); + } throw new environmentException( 'Required environment variable "' . $varName . '" is not set (referenced as "%env(' . $expression . ')%" in ' . $sourceDescription . '). Set it in the process environment, a Docker secret, or a .env file.' ); } $value = $default; @@ -300,24 +329,40 @@ private static function toBool( mixed $value ): bool { } + /** Request-derived under web SAPIs — never satisfiable from the ambient environment. */ + private static function isBlockedName( string $name ): bool { + if( in_array( $name, self::BLOCKED_NAMES, true ) ) { + return true; + } + foreach( self::BLOCKED_NAME_PREFIXES as $prefix ) { + if( str_starts_with( $name, $prefix ) ) { + return true; + } + } + + return false; + } + + /** * Look up an environment variable value. - * Precedence: overlay → $_ENV → $_SERVER (excluding HTTP_* request headers) → getenv(). + * Precedence: $_ENV → $_SERVER → getenv(). The blocked-name guard applies to ALL + * three sources by name (not per source): under CGI/FastCGI SAPIs request headers + * reach the real process environment (getenv) and — with `variables_order=E` — + * $_ENV, so filtering only $_SERVER would be bypassable. * Returns null only when the variable is genuinely unset (a set-but-empty - * variable — overlay included — resolves to '', which also suppresses `default:`). - * - * @param array $overlayVars + * variable resolves to '', which also suppresses `default:`). */ - private static function lookupEnv( string $name, array $overlayVars = [] ): ?string { - if( array_key_exists( $name, $overlayVars ) ) { - return (string)$overlayVars[ $name ]; + private static function lookupEnv( string $name ): ?string { + if( self::isBlockedName( $name ) ) { + return null; } if( array_key_exists( $name, $_ENV ) ) { return (string)$_ENV[ $name ]; } - if( !str_starts_with( $name, 'HTTP_' ) && array_key_exists( $name, $_SERVER ) ) { + if( array_key_exists( $name, $_SERVER ) && is_scalar( $_SERVER[ $name ] ) ) { return (string)$_SERVER[ $name ]; } diff --git a/tests/Unit/Cli/AppContextTest.php b/tests/Unit/Cli/AppContextTest.php index 1097b50..9797769 100644 --- a/tests/Unit/Cli/AppContextTest.php +++ b/tests/Unit/Cli/AppContextTest.php @@ -150,79 +150,85 @@ public function testLoadConfigThrowsCliExceptionWhenEnvVarMissing(): void { } - public function testLoadConfigVariantAppliesOverlay(): void { - // Ambient value must LOSE to the overlay for an explicit variant read. - $_ENV[ 'TEST_MONGO_URI' ] = 'mongodb://local:27017'; - putenv( 'TEST_MONGO_URI=mongodb://local:27017' ); + public function testActiveConfigStripsEnvironmentsSection(): void { + // The CLI-only environments section must not have to resolve for the active + // config to load — its PROD_* variables are unset here. + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ + 'type' => 'local', + 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => 'mongodb://local:27017' ] ], + 'environments' => [ 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(PROD_MONGO_URI)%' ] ] ] ], + ] ) ); + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); + $active = $context->loadConfig(); + $this->assertSame( 'local', $active->type ); + $this->assertSame( 'mongodb://local:27017', $active->mongoDatabases[ 0 ]->uri ); + } + + + public function testLoadVariantEnvironmentResolvesEntry(): void { + $_ENV[ 'PROD_MONGO_URI' ] = 'mongodb://prod:27017/widgets'; + putenv( 'PROD_MONGO_URI=mongodb://prod:27017/widgets' ); try { file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ - 'type' => '%env(default:local:TEST_APP_TYPE)%', - 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(TEST_MONGO_URI)%' ] ], + 'type' => 'local', + 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => 'mongodb://local:27017' ] ], + 'environments' => [ 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(PROD_MONGO_URI)%' ] ] ] ], ] ) ); - file_put_contents( $this->tempRootDir . '/prod.env', "TEST_APP_TYPE=prod\nTEST_MONGO_URI=mongodb://prod:27017\n" ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); - - $prodConfig = $context->loadConfig( 'prod' ); - $this->assertSame( 'prod', $prodConfig->type ); - $this->assertSame( 'mongodb://prod:27017', $prodConfig->mongoDatabases[ 0 ]->uri ); - - $activeConfig = $context->loadConfig(); - $this->assertSame( 'local', $activeConfig->type ); - $this->assertSame( 'mongodb://local:27017', $activeConfig->mongoDatabases[ 0 ]->uri ); + $prod = $context->loadVariantEnvironment( 'prod' ); + $this->assertSame( 'prod', $prod->type ); + $this->assertSame( 'mongodb://prod:27017/widgets', $prod->mongoDatabases[ 0 ]->uri ); } finally { - unset( $_ENV[ 'TEST_MONGO_URI' ] ); - putenv( 'TEST_MONGO_URI' ); + unset( $_ENV[ 'PROD_MONGO_URI' ] ); + putenv( 'PROD_MONGO_URI' ); } } - public function testLoadConfigVariantAmbientFillsOverlayGaps(): void { - $_ENV[ 'TEST_MONGO_DB' ] = 'localDb'; - putenv( 'TEST_MONGO_DB=localDb' ); + public function testLoadVariantEnvironmentThrowsCliExceptionWhenPrefixedVarMissing(): void { + unset( $_ENV[ 'PROD_MONGO_URI' ] ); + putenv( 'PROD_MONGO_URI' ); + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ + 'type' => 'local', + 'environments' => [ 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(PROD_MONGO_URI)%' ] ] ] ], + ] ) ); + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); try { - file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ - 'type' => 'local', - 'mongoDatabases' => [ [ 'default' => true, 'database' => '%env(TEST_MONGO_DB)%', 'uri' => '%env(TEST_MONGO_URI)%' ] ], - ] ) ); - file_put_contents( $this->tempRootDir . '/prod.env', "TEST_MONGO_URI=mongodb://prod:27017\n" ); - $context = appContext::locate( $this->tempRootDir ); - $this->assertNotNull( $context ); - - $prodConfig = $context->loadConfig( 'prod' ); - $this->assertSame( 'mongodb://prod:27017', $prodConfig->mongoDatabases[ 0 ]->uri ); - // TEST_MONGO_DB not in the overlay -> ambient value fills the gap - $this->assertSame( 'localDb', $prodConfig->mongoDatabases[ 0 ]->database ); + $context->loadVariantEnvironment( 'prod' ); + $this->fail( 'Expected cliException' ); } - finally { - unset( $_ENV[ 'TEST_MONGO_DB' ] ); - putenv( 'TEST_MONGO_DB' ); + catch( cliException $e ) { + $this->assertStringContainsString( 'PROD_MONGO_URI', $e->getMessage() ); } } - public function testLoadConfigVariantThrowsWhenOverlayMissing(): void { - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local"}' ); + public function testLoadVariantEnvironmentThrowsWhenEntryMissing(): void { + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local","environments":{"staging":{"type":"staging"}}}' ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); try { - $context->loadConfig( 'prod' ); + $context->loadVariantEnvironment( 'prod' ); $this->fail( 'Expected cliException' ); } catch( cliException $e ) { - $this->assertStringContainsString( 'prod.env', $e->getMessage() ); + $this->assertStringContainsString( 'No "prod" entry', $e->getMessage() ); + $this->assertStringContainsString( 'staging', $e->getMessage() ); } } - public function testLoadConfigVariantMentionsMigrationWhenLegacyFileExists(): void { + public function testLoadVariantEnvironmentMentionsMigrationWhenLegacyFileExists(): void { file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local"}' ); file_put_contents( $this->tempRootDir . '/app/config/environment-prod.json', '{"type":"prod"}' ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); try { - $context->loadConfig( 'prod' ); + $context->loadVariantEnvironment( 'prod' ); $this->fail( 'Expected cliException' ); } catch( cliException $e ) { @@ -237,25 +243,25 @@ public function testDescribeConfigSource(): void { $this->assertNotNull( $context ); $root = str_replace( '\\', '/', $this->tempRootDir ); $this->assertSame( $root . '/config.json', $context->describeConfigSource() ); - $this->assertSame( $root . '/config.json (overlay: ' . $root . '/prod.env)', $context->describeConfigSource( 'prod' ) ); + $this->assertSame( $root . '/config.json (environments.prod)', $context->describeConfigSource( 'prod' ) ); } - public function testGetEnvironmentVariantsListsOverlayFiles(): void { - touch( $this->tempRootDir . '/prod.env' ); - touch( $this->tempRootDir . '/staging.env' ); - // none of these may appear as variants: the example file, dotfiles, the - // config itself, or a legacy app/config file - touch( $this->tempRootDir . '/prod.env.example' ); - touch( $this->tempRootDir . '/.env' ); - touch( $this->tempRootDir . '/.env.local' ); - touch( $this->tempRootDir . '/config.json' ); - touch( $this->tempRootDir . '/app/config/environment-local.json' ); + public function testGetEnvironmentVariantsListsEnvironmentsSection(): void { + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local","environments":{"prod":{"type":"prod"},"staging":{"type":"staging"}}}' ); $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); $this->assertSame( [ 'prod', 'staging' ], $context->getEnvironmentVariants() ); } + + public function testGetEnvironmentVariantsEmptyWithoutEnvironmentsSection(): void { + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local"}' ); + $context = appContext::locate( $this->tempRootDir ); + $this->assertNotNull( $context ); + $this->assertSame( [], $context->getEnvironmentVariants() ); + } + private function deleteDirectory( string $directory ): void { if( !is_dir( $directory ) ) { return; diff --git a/tests/Unit/Cli/CommandsTest.php b/tests/Unit/Cli/CommandsTest.php index 0c45d31..1fb5393 100644 --- a/tests/Unit/Cli/CommandsTest.php +++ b/tests/Unit/Cli/CommandsTest.php @@ -58,24 +58,32 @@ public function testCliListShowsCliRoutesWithDescriptions(): void { $this->assertStringNotContainsString( '/widget', $display ); } - public function testEnvCommandValidatesVariantOverlay(): void { - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"%env(default:local:TEST_ENVCMD_TYPE)%","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_URI)%"}]}' ); - file_put_contents( $this->tempRootDir . '/prod.env', "TEST_ENVCMD_TYPE=prod\nTEST_ENVCMD_URI=mongodb://user:secret@prod:27017\n" ); - - $commandTester = new CommandTester( new envCommand() ); - $exitCode = $commandTester->execute( [ 'environment' => 'prod' ] ); - - $this->assertSame( 0, $exitCode ); - $display = $commandTester->getDisplay(); - $this->assertStringContainsString( 'type: prod', $display ); - $this->assertStringContainsString( 'widgets', $display ); - $this->assertStringNotContainsString( 'secret', $display, 'mongo uri credentials must be redacted' ); - $this->assertStringContainsString( 'Resolved successfully', $display ); + public function testEnvCommandValidatesEnvironmentEntry(): void { + putenv( 'TEST_ENVCMD_URI=mongodb://user:secret@prod:27017' ); + $_ENV[ 'TEST_ENVCMD_URI' ] = 'mongodb://user:secret@prod:27017'; + try { + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local","environments":{"prod":{"type":"prod","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_URI)%"}]}}}' ); + + $commandTester = new CommandTester( new envCommand() ); + $exitCode = $commandTester->execute( [ 'environment' => 'prod' ] ); + + $this->assertSame( 0, $exitCode ); + $display = $commandTester->getDisplay(); + $this->assertStringContainsString( 'type: prod', $display ); + $this->assertStringContainsString( 'widgets', $display ); + $this->assertStringNotContainsString( 'secret', $display, 'mongo uri credentials must be redacted' ); + $this->assertStringContainsString( 'Resolved successfully', $display ); + } + finally { + unset( $_ENV[ 'TEST_ENVCMD_URI' ] ); + putenv( 'TEST_ENVCMD_URI' ); + } } public function testEnvCommandFailsNamingTheMissingVariable(): void { - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"prod","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_MISSING_URI)%"}]}' ); - file_put_contents( $this->tempRootDir . '/prod.env', "IRRELEVANT=1\n" ); + unset( $_ENV[ 'TEST_ENVCMD_MISSING_URI' ] ); + putenv( 'TEST_ENVCMD_MISSING_URI' ); + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local","environments":{"prod":{"type":"prod","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_MISSING_URI)%"}]}}}' ); $commandTester = new CommandTester( new envCommand() ); $exitCode = $commandTester->execute( [ 'environment' => 'prod' ] ); @@ -84,18 +92,16 @@ public function testEnvCommandFailsNamingTheMissingVariable(): void { $this->assertStringContainsString( 'TEST_ENVCMD_MISSING_URI', $commandTester->getDisplay() ); } - public function testEnvCommandBareListsVariantsAndChecksActiveEnvironment(): void { - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local"}' ); - touch( $this->tempRootDir . '/prod.env' ); - touch( $this->tempRootDir . '/staging.env' ); + public function testEnvCommandBareListsEnvironmentsAndChecksActive(): void { + file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local","environments":{"prod":{"type":"prod"},"staging":{"type":"staging"}}}' ); $commandTester = new CommandTester( new envCommand() ); $exitCode = $commandTester->execute( [] ); $this->assertSame( 0, $exitCode ); $display = $commandTester->getDisplay(); - $this->assertStringContainsString( 'prod.env', $display ); - $this->assertStringContainsString( 'staging.env', $display ); + $this->assertStringContainsString( 'prod', $display ); + $this->assertStringContainsString( 'staging', $display ); $this->assertStringContainsString( 'Resolved successfully', $display ); } diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 4c6df58..43caee4 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -84,17 +84,31 @@ public function testDeprecatedPassThroughsPreserveV6CallPatterns(): void { $prop = new \ReflectionProperty( config::class, 'unifiedConfig' ); $prop->setValue( null, $unified ); - // v6 environmentConfig call patterns + // v6 environmentConfig call patterns — the shim returns the unified object $this->assertSame( $unified, config::getEnvironmentConfig() ); $this->assertSame( '/api', config::getEnvironmentConfig()->getBasePath() ); $this->assertFalse( config::getEnvironmentConfig()->isLocal() ); $this->assertSame( [], config::getEnvironmentConfig()->mongoDatabases ); - // v6 appConfig call patterns - $this->assertSame( $unified, config::getAppConfig() ); - $this->assertTrue( config::getAppConfig()->settings->forceMfaForPasswordUsers ); - $this->assertSame( 'Widget API', config::getAppConfig()->app->title ); - $this->assertSame( '', config::getAppConfig()->email->SMTPUsername ); + // v6 appConfig call patterns — the shim returns a v6-shaped VIEW (app/email/settings) + $appConfig = config::getAppConfig(); + $this->assertInstanceOf( \gcgov\framework\models\appConfig::class, $appConfig ); + $this->assertTrue( $appConfig->settings->forceMfaForPasswordUsers ); + $this->assertSame( 'Widget API', $appConfig->app->title ); + $this->assertSame( '', $appConfig->email->SMTPUsername ); + $this->assertSame( $unified->settings, $appConfig->settings, 'view shares the live section objects' ); + + // The view must NOT expose environment-side secrets (mongo/microsoft/payjunction/jwtAuth) + $viewProperties = array_keys( get_object_vars( $appConfig ) ); + $this->assertSame( [ 'app', 'email', 'settings' ], $viewProperties ); + } + + + public function testEnvironmentConfigClassAliasResolvesToUnifiedConfig(): void { + // v6 type references (\gcgov\framework\models\environmentConfig) must still autoload. + $this->assertTrue( class_exists( \gcgov\framework\models\environmentConfig::class ) ); + $this->assertSame( unifiedConfig::class, ( new \ReflectionClass( \gcgov\framework\models\environmentConfig::class ) )->getName() ); + $this->assertInstanceOf( \gcgov\framework\models\environmentConfig::class, new unifiedConfig() ); } public function testIsFinalClass(): void { diff --git a/tests/Unit/Services/Environment/ConfigLoaderTest.php b/tests/Unit/Services/Environment/ConfigLoaderTest.php new file mode 100644 index 0000000..2b9ad52 --- /dev/null +++ b/tests/Unit/Services/Environment/ConfigLoaderTest.php @@ -0,0 +1,146 @@ + */ + private array $envSnapshot = []; + + /** @var array */ + private array $serverSnapshot = []; + + private string $tempDir = ''; + + + protected function setUp(): void { + $this->envSnapshot = $_ENV; + $this->serverSnapshot = $_SERVER; + $this->tempDir = sys_get_temp_dir() . '/gcgov-configloader-test-' . uniqid(); + mkdir( $this->tempDir, 0777, true ); + dotEnvLoader::resetForTesting(); + } + + + protected function tearDown(): void { + foreach( array_keys( $_ENV ) as $key ) { + if( !array_key_exists( $key, $this->envSnapshot ) ) { + putenv( $key ); + } + } + $_ENV = $this->envSnapshot; + $_SERVER = $this->serverSnapshot; + dotEnvLoader::resetForTesting(); + $this->deleteDirectory( $this->tempDir ); + } + + + private function writeConfig( array $config ): void { + file_put_contents( $this->tempDir . '/config.json', json_encode( $config ) ); + } + + + public function testConfigFilePathIsRootConfigJson(): void { + $this->assertSame( $this->tempDir . '/config.json', configLoader::configFilePath( $this->tempDir ) ); + $this->assertSame( 'config.json', configLoader::FILE_NAME ); + } + + + public function testLoadStripsEnvironmentsSectionBeforeResolving(): void { + // PROD_MONGO_URI is unset — the active load must succeed anyway because the + // environments section is removed before resolution. + $this->writeConfig( [ + 'type' => 'local', + 'mongoDatabases' => [ [ 'default' => true, 'database' => 'db', 'uri' => 'mongodb://local:27017' ] ], + 'environments' => [ 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'db', 'uri' => '%env(PROD_MONGO_URI)%' ] ] ] ], + ] ); + + $config = configLoader::load( $this->tempDir ); + $this->assertInstanceOf( unifiedConfig::class, $config ); + $this->assertSame( 'local', $config->type ); + } + + + public function testLoadThrowsWhenConfigMissing(): void { + $this->expectException( environmentException::class ); + configLoader::load( $this->tempDir ); + } + + + public function testLoadVariantEnvironmentResolvesPrefixedVariables(): void { + putenv( 'PROD_MONGO_URI=mongodb://prod:27017/db' ); + $_ENV[ 'PROD_MONGO_URI' ] = 'mongodb://prod:27017/db'; + $this->writeConfig( [ + 'type' => 'local', + 'environments' => [ 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'db', 'uri' => '%env(PROD_MONGO_URI)%' ] ] ] ], + ] ); + + $variant = configLoader::loadVariantEnvironment( $this->tempDir, 'prod' ); + $this->assertSame( 'prod', $variant->type ); + $this->assertSame( 'mongodb://prod:27017/db', $variant->mongoDatabases[ 0 ]->uri ); + } + + + public function testLoadVariantEnvironmentThrowsWhenEntryMissing(): void { + $this->writeConfig( [ 'type' => 'local', 'environments' => [ 'staging' => [ 'type' => 'staging' ] ] ] ); + try { + configLoader::loadVariantEnvironment( $this->tempDir, 'prod' ); + $this->fail( 'Expected environmentException' ); + } + catch( environmentException $e ) { + $this->assertStringContainsString( 'No "prod" entry', $e->getMessage() ); + $this->assertStringContainsString( 'staging', $e->getMessage() ); + } + } + + + public function testLoadVariantEnvironmentThrowsWhenNoEnvironmentsSection(): void { + $this->writeConfig( [ 'type' => 'local' ] ); + $this->expectException( environmentException::class ); + configLoader::loadVariantEnvironment( $this->tempDir, 'prod' ); + } + + + public function testVariantNamesListsSortedKeysWithoutResolution(): void { + // %env references present but unset — variantNames must not resolve them. + $this->writeConfig( [ + 'type' => 'local', + 'environments' => [ + 'staging' => [ 'type' => 'staging', 'mongoDatabases' => [ [ 'uri' => '%env(STAGING_UNSET)%' ] ] ], + 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'uri' => '%env(PROD_UNSET)%' ] ] ], + ], + ] ); + + $this->assertSame( [ 'prod', 'staging' ], configLoader::variantNames( $this->tempDir ) ); + } + + + public function testVariantNamesEmptyWhenNoEnvironmentsOrNoFile(): void { + $this->assertSame( [], configLoader::variantNames( $this->tempDir ) ); + $this->writeConfig( [ 'type' => 'local' ] ); + $this->assertSame( [], configLoader::variantNames( $this->tempDir ) ); + } + + + private function deleteDirectory( string $directory ): void { + if( !is_dir( $directory ) ) { + return; + } + $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); + foreach( $iterator as $file ) { + $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $directory ); + } + +} diff --git a/tests/Unit/Services/Environment/DotEnvLoaderTest.php b/tests/Unit/Services/Environment/DotEnvLoaderTest.php index 86c844a..e11440a 100644 --- a/tests/Unit/Services/Environment/DotEnvLoaderTest.php +++ b/tests/Unit/Services/Environment/DotEnvLoaderTest.php @@ -91,27 +91,20 @@ public function testNoOpWhenAbsent(): void { } - public function testParseFileReturnsMapWithoutMutatingEnvironment(): void { - file_put_contents( $this->tempDir . '/prod.env', "DOTENV_TEST_A=prod_value\nDOTENV_TEST_B=other\n" ); - - $parsed = dotEnvLoader::parseFile( $this->tempDir . '/prod.env' ); - - $this->assertSame( [ 'DOTENV_TEST_A' => 'prod_value', 'DOTENV_TEST_B' => 'other' ], $parsed ); - $this->assertArrayNotHasKey( 'DOTENV_TEST_A', $_ENV ); - $this->assertFalse( getenv( 'DOTENV_TEST_A' ) ); - } - - - public function testParseFileThrowsWhenMissing(): void { - $this->expectException( \gcgov\framework\services\environment\environmentException::class ); - dotEnvLoader::parseFile( $this->tempDir . '/does-not-exist.env' ); + public function testLoadsEnvLocalWhenEnvAbsent(): void { + // A project keeping only machine-local values in .env.local (no committed .env) + // must still load — the documented precedence lists both files. + file_put_contents( $this->tempDir . '/.env.local', "DOTENV_TEST_A=only_local\n" ); + dotEnvLoader::loadOnce( $this->tempDir ); + $this->assertSame( 'only_local', $_ENV[ 'DOTENV_TEST_A' ] ?? null ); + $this->assertSame( 'only_local', getenv( 'DOTENV_TEST_A' ) ); } - public function testParseFileThrowsOnMalformedContent(): void { - file_put_contents( $this->tempDir . '/bad.env', "NOT A VALID LINE ===\n" ); + public function testMalformedEnvFileThrowsEnvironmentException(): void { + file_put_contents( $this->tempDir . '/.env', "NOT A VALID LINE ===\n" ); $this->expectException( \gcgov\framework\services\environment\environmentException::class ); - dotEnvLoader::parseFile( $this->tempDir . '/bad.env' ); + dotEnvLoader::loadOnce( $this->tempDir ); } diff --git a/tests/Unit/Services/Environment/EnvVarResolverTest.php b/tests/Unit/Services/Environment/EnvVarResolverTest.php index db8c1cf..1aa572c 100644 --- a/tests/Unit/Services/Environment/EnvVarResolverTest.php +++ b/tests/Unit/Services/Environment/EnvVarResolverTest.php @@ -203,57 +203,88 @@ public function testNestedAppDictionaryResolution(): void { } - public function testOverlayBeatsAmbientEnvironment(): void { - $this->setEnv( 'MONGO_URI', 'mongodb://local:27017' ); - $result = envVarResolver::resolveJson( '{"uri":"%env(MONGO_URI)%"}', 'test', [ 'MONGO_URI' => 'mongodb://prod:27017' ] ); - $this->assertSame( 'mongodb://prod:27017', $result->uri ); + public function testResolveDecodedResolvesInPlace(): void { + $this->setEnv( 'RD_URI', 'mongodb://rd:27017' ); + $decoded = json_decode( '{"a":{"uri":"%env(RD_URI)%"}}', false ); + $result = envVarResolver::resolveDecoded( $decoded, 'test' ); + $this->assertSame( $decoded, $result ); + $this->assertSame( 'mongodb://rd:27017', $result->a->uri ); } - public function testOverlayMissFallsBackToAmbient(): void { - $this->setEnv( 'MONGO_URI', 'mongodb://local:27017' ); - $result = envVarResolver::resolveJson( '{"uri":"%env(MONGO_URI)%","db":"%env(MONGO_DATABASE)%"}', 'test', [ 'MONGO_DATABASE' => 'prodDb' ] ); - $this->assertSame( 'mongodb://local:27017', $result->uri ); - $this->assertSame( 'prodDb', $result->db ); + // --- request-data injection guard (see BLOCKED_NAME_PREFIXES/BLOCKED_NAMES) --- + + public function testHttpPrefixedNameIsNeverResolvedFromServer(): void { + // A malicious request header exposed via $_SERVER must not satisfy an env reference. + $_SERVER[ 'HTTP_MONGO_URI' ] = 'mongodb://attacker'; + $this->expectException( environmentException::class ); + try { + envVarResolver::resolveJson( '{"uri":"%env(HTTP_MONGO_URI)%"}', 'test' ); + } + finally { + unset( $_SERVER[ 'HTTP_MONGO_URI' ] ); + } } - public function testOverlayValueSuppressesDefault(): void { - $result = envVarResolver::resolveJson( '{"uri":"%env(default:mongodb://fallback:27017:MONGO_URI)%"}', 'test', [ 'MONGO_URI' => 'mongodb://overlay:27017' ] ); - $this->assertSame( 'mongodb://overlay:27017', $result->uri ); + public function testHttpPrefixedNameIsNeverResolvedFromGetenv(): void { + // Under CGI/FastCGI the header reaches the real process env; the guard must + // still hold at the getenv() fallback, not just $_SERVER. + putenv( 'HTTP_EVIL_VAR=attacker' ); + try { + envVarResolver::resolveJson( '{"v":"%env(HTTP_EVIL_VAR)%"}', 'test' ); + $this->fail( 'Expected environmentException' ); + } + catch( environmentException ) { + $this->addToAssertionCount( 1 ); + } + finally { + putenv( 'HTTP_EVIL_VAR' ); + } } - public function testEmptyOverlayValueResolvesToEmptyStringAndSuppressesDefault(): void { - $result = envVarResolver::resolveJson( '{"secret":"%env(default:fallback:CLIENT_SECRET)%"}', 'test', [ 'CLIENT_SECRET' => '' ] ); - $this->assertSame( '', $result->secret ); + public function testServerMetaVariableNameIsNeverResolved(): void { + // $_SERVER['SERVER_NAME'] is request-derived (Host header); a %env(SERVER_NAME) + // reference must fail loud, not silently bind to the request value. + $_SERVER[ 'SERVER_NAME' ] = 'evil.host'; + try { + envVarResolver::resolveJson( '{"v":"%env(SERVER_NAME)%"}', 'test' ); + $this->fail( 'Expected environmentException' ); + } + catch( environmentException $e ) { + $this->assertStringContainsString( 'reserved', $e->getMessage() ); + } + // (leave $_SERVER['SERVER_NAME'] — it is part of the real server env; restored in tearDown) } - public function testEmptyOverlayArrayIsIdenticalToTwoArgCall(): void { - $this->setEnv( 'MONGO_URI', 'mongodb://ambient:27017' ); - $json = '{"uri":"%env(MONGO_URI)%","port":"%env(int:default:587:SMTP_PORT)%"}'; - $this->assertEquals( envVarResolver::resolveJson( $json, 'test' ), envVarResolver::resolveJson( $json, 'test', [] ) ); + public function testBlockedNameStillAllowsDefaultFallback(): void { + // A blocked name is treated as unset, so an explicit default: still applies. + $_SERVER[ 'HTTP_X' ] = 'attacker'; + try { + $result = envVarResolver::resolveJson( '{"v":"%env(default:safe:HTTP_X)%"}', 'test' ); + $this->assertSame( 'safe', $result->v ); + } + finally { + unset( $_SERVER[ 'HTTP_X' ] ); + } } - public function testOverlayWorksWithProcessorsAndEmbeddedRefs(): void { - $result = envVarResolver::resolveJson( '{"port":"%env(int:SMTP_PORT)%","url":"https://%env(HOSTNAME_X)%/api"}', 'test', [ 'SMTP_PORT' => '2525', 'HOSTNAME_X' => 'prod.example.com' ] ); - $this->assertSame( 2525, $result->port ); - $this->assertSame( 'https://prod.example.com/api', $result->url ); + // --- fail-loud on unresolvable references --- + + public function testParenInsideDefaultLiteralThrowsInsteadOfSilentPassthrough(): void { + $this->expectException( environmentException::class ); + envVarResolver::resolveJson( '{"v":"%env(default:pa)ss:SOME_UNSET_VAR_X)%"}', 'test' ); } - public function testServerHttpKeysAreNotUsedForLookup(): void { - // A malicious request header must not satisfy an env reference. - $_SERVER[ 'HTTP_MONGO_URI' ] = 'mongodb://attacker'; + public function testLiteralEnvPrefixInValueThrows(): void { + // A value containing the literal '%env(' that isn't a valid reference must not + // ship unresolved. $this->expectException( environmentException::class ); - try { - envVarResolver::resolveJson( '{"uri":"%env(HTTP_MONGO_URI)%"}', 'test' ); - } - finally { - unset( $_SERVER[ 'HTTP_MONGO_URI' ] ); - } + envVarResolver::resolveJson( '{"v":"prefix %env( not a ref"}', 'test' ); } From aa2d2a096e986f369046699759b9bebe62ce5790 Mon Sep 17 00:00:00 2001 From: Andrew Sauder Date: Wed, 26 Aug 2026 14:09:54 -0400 Subject: [PATCH 06/30] set up mattpocock skills --- CLAUDE.md | 16 +++++++++++ docs/agents/domain.md | 51 ++++++++++++++++++++++++++++++++++++ docs/agents/issue-tracker.md | 45 +++++++++++++++++++++++++++++++ docs/agents/triage-labels.md | 15 +++++++++++ 4 files changed, 127 insertions(+) create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/CLAUDE.md b/CLAUDE.md index 6a567fb..1b5e45e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -538,3 +538,19 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea add a mirrored test in `tests/Unit/Cli/` (external binaries are exercised via pure arg-builder methods, e.g. `dbRestoreCommand::buildDumpCommand()`). - The legacy `scripts/*.ps1` are deprecated wrappers kept for backward compatibility. + +--- + +## Agent skills + +### Issue tracker + +Issues live in GitHub Issues for `gcgov/framework`, driven by the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The five canonical roles, each label string equal to its role name. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: one root `CONTEXT.md` plus `docs/adr/`. See `docs/agents/domain.md`. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..3524904 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists: it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..b258aeb --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,45 @@ +# Issue tracker: GitHub + +Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v`; `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`). +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either: resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies**, the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only, the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me`, the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..b716855 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. From e9c4ea21f67d53c4847224147bb731adf243e450 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 20:23:42 +0000 Subject: [PATCH 07/30] Add CONTEXT.md: domain glossary for the framework Fixes one word per concept across code, docs and conversation. Notably disambiguates "environment", which currently names three unrelated things (a deployment target, a variable set, and a config section), and records the v6 terms that no longer name anything. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012kqJazGHDMq19pQ64732i6 --- CONTEXT.md | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..8df11c5 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,115 @@ +# gcgov/framework + +The domain language of the framework itself and of the applications built on it. This file is a +glossary: it fixes what each term means so that code, documentation, and conversation use one word +per concept. It is not a specification — see `README.md`, `readme/`, and `docs/adr/` for those. + +## Language + +### Applications and extensions + +**Application**: +A deployable REST API (optionally server-rendered) built on the framework, living in its own +repository and depending on the framework as a library. +_Avoid_: project, site, instance, consumer + +**Framework Service**: +An installable extension that contributes routes, controllers, an auth guard, and CLI commands to +an Application when the Application registers its namespace. +_Avoid_: plugin, module, extension, package + +**Scaffold**: +The one-time act of creating a new Application from the application template. +_Avoid_: setup, bootstrap, generate + +### Request handling + +**Route**: +A binding of an HTTP method and URL pattern to a controller method, together with the +authentication and role requirements that reaching it implies. + +**CLI Route**: +A Route dispatched from the command line rather than from an HTTP request. CLI Routes are never +authenticated. +_Avoid_: command, task, job + +**Auth Guard**: +A router's authentication check, run before a Route with authentication enabled is dispatched. An +Application has its own; each Framework Service may add one. +_Avoid_: middleware, filter, interceptor + +**Auth User**: +The authenticated identity for the current request, carrying its roles. Populated by an Auth Guard +and absent on unauthenticated Routes. +_Avoid_: current user, principal, session user + +**Controller Response**: +The value a controller method returns, describing what to send and how to serialize it. Returning +one is the only way a controller may end a request. +_Avoid_: result, output, payload + +### Documents + +**Model**: +A document that is stored as a collection in its own right and can be loaded, saved, and deleted +independently. +_Avoid_: entity, record, document class + +**Embeddable**: +A document that exists only nested inside a Model or another Embeddable, and is never stored in a +collection of its own. +_Avoid_: sub-document, nested model, value object + +**Embedded Copy**: +A duplicate of a Model's data stored inside other documents for read convenience, which the +framework refreshes wherever it appears whenever the original Model is saved. +_Avoid_: join, denormalization, reference, cache + +**Typemap**: +The declaration of which class each part of a stored document hydrates into. Typed arrays require +an explicit element type; without one the array cannot be hydrated. + +### Configuration and deployment + +**Environment**: +A deployment target — local, production — distinguished *only* by the set of variables its +processes are given. Nothing is activated, copied, or selected by name; supplying a different +variable set is what makes an Environment different. +_Avoid_: variant, stage, tier, environment file, profile + +**Unified Config**: +The single committed configuration file at an Application's root. It is Environment-invariant: the +same bytes are correct in every Environment. +_Avoid_: app config, environment config, config files, settings file + +**Config Reference**: +A placeholder inside the Unified Config naming an environment variable, optionally through a chain +of processors. Every Config Reference is required — an unresolvable one is a startup failure, never +a silent fallback. +_Avoid_: token, placeholder, interpolation, variable expansion + +**Secret**: +A configuration value that must never be committed and must never enter a process's environment — +credentials, connection strings, signing keys, API keys. +_Avoid_: credential, sensitive value, private setting + +**Secret File**: +The file a Secret is delivered as at runtime, named by a Config Reference rather than carrying the +Secret's value in the environment itself. + +**Release**: +A tagged, immutable build of an Application, identified in production by content digest rather than +by tag or branch. Deploying and rolling back are both the act of pointing a host at a different +Release. +_Avoid_: version, build, deployment + +### Retired language + +These terms named real things in v6 and no longer name anything. They are listed so that they are +recognized as history rather than reintroduced. + +- **App config / Environment config** — the two configuration files merged into the Unified Config. +- **Environment variant** — a named Environment whose connection details were committed. Removed + along with the ability to read another Environment's database from a workstation. +- **Scaffolding token** — a marker replaced once at Scaffold time. Replaced by Config References + and by generated developer environment files. From 19e7eda6c65cf90b6a03be5e89cc5ed97691cd48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 20:46:44 +0000 Subject: [PATCH 08/30] CONTEXT.md: add Zone, Ops Repo, Provisioning Zone names the network isolation boundary (internal-only / public with internal access / public without) and is explicitly distinguished from Environment, which the two were being conflated into. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012kqJazGHDMq19pQ64732i6 --- CONTEXT.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index 8df11c5..d2f2661 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -97,6 +97,23 @@ _Avoid_: credential, sensitive value, private setting The file a Secret is delivered as at runtime, named by a Config Reference rather than carrying the Secret's value in the environment itself. +**Zone**: +A network isolation boundary, defined by what it can reach and what can reach it. Three exist: +internal-only, public with internal access, and public without. An Application in production runs in +exactly one Zone; a Zone is not an Environment, and the two vary independently. +_Avoid_: server, host, network, tier, segment + +**Ops Repo**: +The single private repository describing what runs on every host — encrypted Secrets, production +compose definitions, and the shared proxy stack — organized by Zone. An Application repository never +holds production topology or Secrets. +_Avoid_: infra repo, config repo + +**Provisioning**: +Writing decrypted Secrets onto a host. Performed by an operator as a step deliberately separate from +deploying, so that no host holds a decryption key and no deploy needs one. +_Avoid_: secret sync, secret deploy, key distribution + **Release**: A tagged, immutable build of an Application, identified in production by content digest rather than by tag or branch. Deploying and rolling back are both the act of pointing a host at a different From 67059c68885d3ad44abc3f320fcb5806b3c1cb1b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 21:24:08 +0000 Subject: [PATCH 09/30] Record ADRs 0001-0004 for the v7 configuration and deployment design 0001 fail-closed configuration (no default:, empty is unset) 0002 immutable Release pinned by digest, replacing in-place gf deploy 0003 secrets never decrypt in CI or on hosts (SOPS, per-Zone KMS, operator provisioning) 0004 one self-hosted runner per Zone, dedicated host, no Docker socket Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012kqJazGHDMq19pQ64732i6 --- docs/adr/0001-fail-closed-configuration.md | 28 ++++++++++++++++++ .../0002-immutable-release-digest-pinning.md | 25 ++++++++++++++++ ...secrets-never-decrypt-in-ci-or-on-hosts.md | 27 +++++++++++++++++ docs/adr/0004-self-hosted-runners-per-zone.md | 29 +++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 docs/adr/0001-fail-closed-configuration.md create mode 100644 docs/adr/0002-immutable-release-digest-pinning.md create mode 100644 docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md create mode 100644 docs/adr/0004-self-hosted-runners-per-zone.md diff --git a/docs/adr/0001-fail-closed-configuration.md b/docs/adr/0001-fail-closed-configuration.md new file mode 100644 index 0000000..34be4ea --- /dev/null +++ b/docs/adr/0001-fail-closed-configuration.md @@ -0,0 +1,28 @@ +# Configuration is fail-closed: one committed file, every reference required + +An Application's configuration is a single committed `config.json` whose environment-varying values +are `%env(...)%` references. Every reference is **required**: there is no `default:` processor, and a +variable that is set but empty counts as unset. A missing value is a startup failure naming the +variable, never a silent fallback. + +## Considered Options + +v6 split configuration across `app/config/app.json` and per-environment `environment-{name}.json` +files, and an early v7 draft kept a Symfony-style `default:` processor so a value could fall back to +a literal baked in at scaffold time. + +`default:` was removed because it made the dangerous case the quiet one. `type` defaulted to +`local`, and `isLocal()` gates real behavior — so a production container that forgot `APP_TYPE` +booted successfully in development posture. Scaffolding also wrote its `{tokens}` *inside* the +default argument, so skipping a setup prompt shipped the literal string `{app_root_url}` to +production as a URL. Both failures were silent. + +## Consequences + +- Developer environments need real values for every reference. `gf env --init` generates the `.env` + skeleton by walking `config.json`, so the manifest cannot drift from the config. +- Optional integrations (Microsoft, PayJunction, SMTP) are **absent** from the template rather than + present-and-blank. An Application adds the block when it needs it; a missing section hydrates to + its defaults. +- Removing `default:` also removed the resolver's greedy-argument parsing and its ban on `)` inside + a reference, which existed only to support it. diff --git a/docs/adr/0002-immutable-release-digest-pinning.md b/docs/adr/0002-immutable-release-digest-pinning.md new file mode 100644 index 0000000..403978f --- /dev/null +++ b/docs/adr/0002-immutable-release-digest-pinning.md @@ -0,0 +1,25 @@ +# Deployment ships an immutable Release, pinned by digest + +A Release is a container image built once by CI, pushed to GHCR, and identified in production by +**content digest**. Deploying and rolling back are the same operation: point a host at a different +digest and restart. Nothing is built, resolved, or updated on a production host. + +## Considered Options + +v6 deployed by running `gf deploy` on the server: `git pull`, `git checkout tags/X`, `composer +update`. That resolves dependencies in production at deploy time, which means two hosts running +"the same tag" can be running different code, and rollback requires a second dependency resolution +that may not reproduce the earlier one. + +A moving tag (`:latest`, `:prod`) was rejected for the same reason in miniature: it makes "what is +running right now" unanswerable without trusting a mutable pointer. + +## Consequences + +- `gf deploy` is deleted. Deployment is a GitHub Actions workflow plus a compose file, not a PHP + command shipped inside the artifact it deploys. +- `composer.lock` must be committed; without it the image is not reproducible. +- Rollback is re-pinning a prior digest — seconds, no rebuild. +- Anything an Application writes to its own filesystem is lost on every deploy. This is why logging + goes to stderr rather than `logs/*.log`, and why JWT signing keys are provisioned onto the host + rather than living in the application tree. diff --git a/docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md b/docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md new file mode 100644 index 0000000..f56ab6b --- /dev/null +++ b/docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md @@ -0,0 +1,27 @@ +# Production secrets never decrypt in CI, and hosts hold no decryption key + +Secrets live SOPS-encrypted in the `gcgov/deploy` Ops Repo, encrypted to a **GCP KMS key per Zone** +plus one offline age key held as break-glass. An operator decrypts on their own workstation and +writes the plaintext to the host as files under `/run/secrets` — a **Provisioning** step deliberately +separate from deploying. GitHub Actions never decrypts anything, and no host holds a key that could. + +## Considered Options + +A root-owned `.env` per host was simpler but keeps every secret in the process environment, visible +through `docker inspect` and `/proc//environ`. Holding the SOPS key as an Actions secret would +have let CI decrypt, putting every production credential into GitHub's blast radius and into runner +memory — which would have made SOPS strictly worse than the `.env` it replaced, since the ceremony +would be there without the isolation. Pure age keyfiles were rejected because offboarding becomes a +re-encryption exercise with no record of what the departing operator ever decrypted; KMS makes it an +IAM revocation against an audit log. KMS *alone* was rejected because it puts a network round trip to +Google on the critical path for restarting a container. + +## Consequences + +- The Ops Repo's read access is equivalent to access to every credential it has ever held, because + `git log -p` exposes historical values. Encryption protects the repository's contents, not its + history from its own readers. +- Rotating a secret is two deliberate steps (provision, then deploy) rather than one automatic one. +- CI cannot run tests that need real credentials. Integration tests use throwaway ones. +- Offboarding is revoke **and** rotate. Credentials are therefore scoped per Application per Zone, + with a `-g{n}` generation suffix so old and new can coexist during a rotation. diff --git a/docs/adr/0004-self-hosted-runners-per-zone.md b/docs/adr/0004-self-hosted-runners-per-zone.md new file mode 100644 index 0000000..0a841c6 --- /dev/null +++ b/docs/adr/0004-self-hosted-runners-per-zone.md @@ -0,0 +1,29 @@ +# One self-hosted runner per Zone, on a dedicated host, without Docker access + +Each Zone has its own ephemeral self-hosted GitHub Actions runner, registered to the `gcgov/deploy` +Ops Repo only, living on a small host of its own with **no Docker socket**. It deploys to the +Application hosts over SSH using a forced-command key that can run only `deploy `. + +## Considered Options + +An internal-only Zone cannot accept inbound SSH from GitHub, so push-based deployment does not reach +it. A pull agent on each host watching the registry would work but loses the health gate and the +"did my deploy land" answer, and would give internal Applications a second deployment mechanism to +debug. Runners let Actions drive every Zone identically over outbound connections only. + +The isolation choices exist because a self-hosted runner executes workflow code inside the Zone: + +- **Ops Repo only.** Registering to app repositories would give the contributors of thirty + repositories code execution inside the network. Applications fire a `repository_dispatch` at the + Ops Repo with an image digest; the Ops Repo runs its own trusted workflow. This cannot use + `workflow_call`, which executes in the caller's context and would erase the boundary. +- **Ephemeral.** A persistent runner lets one poisoned job leave something behind for the next. +- **No Docker socket.** Access to the socket is root on that host, with no partial version. Keeping + the runner off the Application hosts means runner compromise is bounded by what the forced command + permits, rather than being equivalent to owning the Zone. + +## Consequences + +- Three additional small hosts to build and patch. +- The image digest arriving by dispatch is untrusted input and must be validated, not interpolated. +- Deploys are gated on a protected GitHub Environment, which doubles as the deploy approval. From c6a1adb7fb8c233d8e7ba2c2c846b37c84692a43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 21:25:35 +0000 Subject: [PATCH 10/30] v7: require PHP >= 8.4 v7 is container-only and its images are built on php:8.4-fpm, so the framework's floor moves with them. CI drops the 8.3 leg. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012kqJazGHDMq19pQ64732i6 --- .github/workflows/ci.yml | 4 ++-- CLAUDE.md | 6 +++--- README.md | 2 +- composer.json | 7 +++++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7329a33..12ec419 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.3', '8.4'] + php: ['8.4'] steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.3', '8.4'] + php: ['8.4'] steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 diff --git a/CLAUDE.md b/CLAUDE.md index 1b5e45e..074958e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file is the fast path to a correct mental model. For exhaustive reference, ## 1. What this is -`gcgov/framework` is a small, opinionated PHP 8.3+ framework for building **REST APIs** (and optionally +`gcgov/framework` is a small, opinionated PHP 8.4+ framework for building **REST APIs** (and optionally SSR apps) for Garrett County Government. Composer package name: `gcgov/framework`, PSR-4 root `gcgov\framework\` → `src/`. @@ -480,11 +480,11 @@ at a time (oauth-server OR auth-ms-front). --- ## 14. Build / test / CI -- Install: `composer install`. PHP `>=8.3`; ext `mongodb`, `sodium`, `fileinfo`, `pdo`. +- Install: `composer install`. PHP `>=8.4`; ext `mongodb`, `sodium`, `fileinfo`, `pdo`. - Static analysis: `composer phpstan` (PHPStan; `phpstan-stubs/` provides stubs for optional deps). - Tests: `composer test` (PHPUnit; `tests/` mirrors `src/`, uses `tests/Shims/MongoDBShims.php` so unit tests run without a live Mongo). `composer ci` = phpstan + test. -- GitHub Actions (`.github/workflows/ci.yml`) runs both on PHP 8.3 and 8.4. **Run `composer ci` before pushing.** +- GitHub Actions (`.github/workflows/ci.yml`) runs on PHP 8.4. **Run `composer ci` before pushing.** - When you change `src/`, add/adjust the mirrored test under `tests/Unit/…`. --- diff --git a/README.md b/README.md index 40ed309..8837daa 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ corresponding front end application. Framework package requirements from `composer.json`: -* PHP `>=8.3` +* PHP `>=8.4` * PHP extensions: `ext-mongodb`, `ext-fileinfo`, `ext-pdo` Install dependencies with Composer: diff --git a/composer.json b/composer.json index 536d956..aad47b5 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "bin/gf" ], "require": { - "php": ">=8.3", + "php": ">=8.4", "nikic/fast-route": "^1.3", "phpmailer/phpmailer": "^6.2", "mongodb/mongodb": "^2.1", @@ -56,6 +56,9 @@ "scripts": { "phpstan": "phpstan analyse --memory-limit=512M", "test": "phpunit", - "ci": ["@phpstan", "@test"] + "ci": [ + "@phpstan", + "@test" + ] } } From a9f404c123c061b36b071a868ec5889e9ccaefcc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:20:45 +0000 Subject: [PATCH 11/30] v7 phase 02: fail-closed config, secret files, stderr logging, health routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configuration - envVarResolver: delete the `default` processor; every reference is now required and a set-but-empty variable counts as unset. Removes the greedy argument parsing that only existed to let a fallback literal hold colons. - New `secret` processor implementing the conventional _FILE indirection: %env(secret:MONGO_URI)% reads MONGO_URI_FILE's file when set, else MONGO_URI. A _FILE naming a missing file is a hard error and never falls back — that fallback would substitute a stale environment value for a secret that failed to mount. One committed config.json now serves both a developer machine and production. - Processor set trimmed to secret/file/trim/int/bool/json; string, not, float and base64 had no users. - collectReferences()/configLoader::references() enumerate what config.json needs without resolving it, so the .env manifest is derived rather than hand-kept. - Drop serverName, cookieUrl and phpPath: no reader in the framework or in any of the five framework services. app.guid stays — the oauth server uses it as the OAuth client_id. jwtAuth issuer/audience now derive from rootUrl/basePath when unset. - Delete the `environments` section and all variant plumbing. Runtime - jwtAuth.keyPath makes the signing-key directory configurable. The keys are gitignored, so they are never in a built image; a container must point this at a provisioned mount or authentication cannot work at all. - logging.destination (stderr default, JSON lines / file / both). A container filesystem does not survive a deploy, so file logs would be per-replica and destroyed on every release. - The framework contributes GET {basePath}/health (liveness, no I/O) and /health/ready (readiness, pings Mongo, 503 when down), merged before services and the app. Not opt-in: a deploy pipeline cannot gate on an endpoint an application might omit. Split because a shared probe turns a brief database outage into a crash loop. CLI - Remove `deploy` (in-place git+composer on the server), `db:restore` (production credentials on every workstation), `db:run --env`, `setup` and tokenReplacer. - `gf env` gains --list and --init; `gf init` replaces the setup wizard, non-interactive so it runs from a scaffolding script or devcontainer; `gf migrate` converts a v6 application, its plan() a pure function of the two v6 documents so it is unit-tested rather than run hopefully. 542 tests pass; PHPStan level 5 clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012kqJazGHDMq19pQ64732i6 --- CLAUDE.md | 104 +++-- docs/adr/0001-fail-closed-configuration.md | 8 +- readme/environment-variables.md | 298 +++++++------- readme/gf.md | 194 ++++----- src/cli/appContext.php | 77 +--- src/cli/application.php | 5 +- src/cli/commands/cliCommand.php | 13 +- src/cli/commands/dbRestoreCommand.php | 247 ------------ src/cli/commands/dbRunCommand.php | 6 +- src/cli/commands/deployCommand.php | 130 ------ src/cli/commands/envCommand.php | 148 ++++--- src/cli/commands/initCommand.php | 150 +++++++ src/cli/commands/migrateCommand.php | 372 ++++++++++++++++++ src/cli/commands/setupCommand.php | 249 ------------ src/cli/phpProcess.php | 18 +- src/cli/tokenReplacer.php | 113 ------ src/config.php | 43 +- src/models/config/environment/jwtAuth.php | 18 +- src/models/config/environment/logging.php | 34 +- src/models/config/variantEnvironment.php | 26 -- src/models/unifiedConfig.php | 21 +- src/router.php | 5 + src/services/environment/configLoader.php | 57 +-- src/services/environment/envVarResolver.php | 298 ++++++++------ src/services/health/controllers/health.php | 96 +++++ src/services/health/router.php | 50 +++ src/services/jwtAuth/jwtAuth.php | 29 +- src/services/log.php | 50 ++- tests/Unit/Cli/AppContextTest.php | 98 ----- tests/Unit/Cli/ApplicationTest.php | 4 +- tests/Unit/Cli/CommandsTest.php | 90 ----- tests/Unit/Cli/DbRestoreCommandTest.php | 118 ------ tests/Unit/Cli/EnvCommandTest.php | 53 +++ tests/Unit/Cli/GfBinSmokeTest.php | 6 +- tests/Unit/Cli/MigrateCommandTest.php | 190 +++++++++ tests/Unit/Cli/RouteCatalogTest.php | 26 +- tests/Unit/Cli/TokenReplacerTest.php | 118 ------ tests/Unit/ConfigTest.php | 74 ++++ .../Services/Environment/ConfigLoaderTest.php | 58 --- .../Environment/EnvVarResolverTest.php | 351 ++++++++--------- tests/Unit/Services/LogTest.php | 49 +++ tests/bootstrap.php | 1 - 42 files changed, 1976 insertions(+), 2119 deletions(-) delete mode 100644 src/cli/commands/dbRestoreCommand.php delete mode 100644 src/cli/commands/deployCommand.php create mode 100644 src/cli/commands/initCommand.php create mode 100644 src/cli/commands/migrateCommand.php delete mode 100644 src/cli/commands/setupCommand.php delete mode 100644 src/cli/tokenReplacer.php delete mode 100644 src/models/config/variantEnvironment.php create mode 100644 src/services/health/controllers/health.php create mode 100644 src/services/health/router.php delete mode 100644 tests/Unit/Cli/DbRestoreCommandTest.php create mode 100644 tests/Unit/Cli/EnvCommandTest.php create mode 100644 tests/Unit/Cli/MigrateCommandTest.php delete mode 100644 tests/Unit/Cli/TokenReplacerTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 074958e..ef27135 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,8 +71,8 @@ Required config file (missing it throws `configException` at request time): Typical app tree (scaffolding template adds more — `srv/`, `db/`, `docker/`, `Dockerfile`, etc.): ``` /api -├── config.json # unified config (committed; %env(...) refs; CLI-only `environments` section) -├── .env # gitignored local values incl. gf db:*/env PREFIXED vars (from .env.example) +├── config.json # unified config (committed; every %env(...) ref is REQUIRED) +├── .env # gitignored local values (generate with `gf env --init`) ├── app/{app,router,renderer,constants}.php │ ├── cli/index.php # CLI entry │ ├── controllers/{name}.php @@ -329,9 +329,12 @@ returning group keys, and tag constraints with `groups: [...]`. exposed **directly on `config`** — there are no separate appConfig/environmentConfig objects (v7; `getAppConfig()`/`getEnvironmentConfig()` remain as deprecated pass-throughs returning the unified object): `config::getApp()` (title/guid), `getEmail()`, `getSettings()`, `getType()`, `isLocal()`, -`getServerName()`, `getRootUrl()`, `getBaseUrl()`, `getBasePath()`, `getCookieUrl()`, `getPhpPath()`, -`getLogging()`, `getMongoDatabases()`, `getSqlDatabases()`, `getDefaultSqlDatabase()`, -`getSqlDatabaseByName($name)`, `getMicrosoft()`, `getJwtAuth()`, `getPayjunction()`, `getAppDictionary()`. +`getRootUrl()`, `getBaseUrl()`, `getBasePath()`, `getLogging()`, `getMongoDatabases()`, +`getSqlDatabases()`, `getDefaultSqlDatabase()`, `getSqlDatabaseByName($name)`, `getMicrosoft()`, +`getJwtAuth()`, `getTokenIssuedBy()`, `getTokenPermittedFor()`, `getJwtKeyPath()`, +`getPayjunction()`, `getAppDictionary()`. +`serverName`, `cookieUrl` and `phpPath` were **removed in v7** — nothing read them (confirmed across +the framework and all five framework services). The PHP interpreter is `GF_PHP` / `gf cli --php`. ### Environment variables in config — `%env(...)%` config.json supports **Symfony-style `%env(...)%` references**, resolved at load time by @@ -339,29 +342,28 @@ config.json supports **Symfony-style `%env(...)%` references**, resolved at load This keeps secrets out of the committed config and lets them come from the process environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hosting. - A file with no `%env(` substring is loaded byte-for-byte as before. You opt in by writing `%env(...)%`. +- **Every reference is REQUIRED.** There is no `default:` processor (removed in v7), and a variable + set to the empty string counts as unset. A missing value is a startup failure naming the variable. + A value that does not vary between environments is written as a literal, not referenced. - Whole-value ref → typed result (`"%env(int:SMTP_PORT)%"` → `587`); embedded ref → string - substitution. Processors (right-to-left): `string,bool,not,int,float,trim,file,base64,json,default`. -- `file` reads the file at the variable's value (Docker secrets: `%env(trim:file:MONGO_URI_FILE)%`). -- `default:` is a **literal** fallback (deviation from Symfony), must be innermost, greedy - argument so colons are legal: `%env(default:mongodb://mongodb:27017:MONGO_URI)%`. + substitution. Processors, applied right-to-left: `secret, file, trim, int, bool, json`. +- **`secret`** implements the conventional `_FILE` indirection: `%env(secret:MONGO_URI)%` reads the + file named by `MONGO_URI_FILE` if that is set, else `MONGO_URI`. A `_FILE` pointing at a missing + file is a hard error and **never** falls back — that fallback would silently substitute a stale + environment value for a secret that failed to mount. This is what lets one committed config.json + serve both a developer machine (plain vars in `.env`) and production (files at `/run/secrets`). + `secret` must be the innermost processor. - `.env` loading (via `symfony/dotenv`, `dotEnvLoader::loadOnce()`): `{root}/.env` and/or `.env.local` (either may exist alone); **real environment always wins**; precedence - `real env > .env.local > .env > default:`. No `APP_ENV` cascade — an environment IS the - variable set the process is given; nothing is activated or copied (v7). -- **Foreign-environment reads** (`db:restore --from=prod`, `db:run --env=prod`, `gf env prod`) come - from the CLI-only `environments` section of `config.json` (stripped before the active config is - resolved). Each `environments.{name}` entry has a literal `type` (the db:restore prod guard needs - it) and `mongoDatabases` referencing **environment-prefixed** variables (e.g. `PROD_MONGO_URI` in - `.env`) so a missing value fails loudly instead of resolving to the local value. - `appContext::loadVariantEnvironment($name)` reads one entry; `configLoader` is the shared load - pipeline for both runtime and CLI. + `real env > .env.local > .env`. No `APP_ENV` cascade — an environment IS the variable set the + process is given; nothing is activated or copied. - **Reserved names**: CGI meta-variable names (`HTTP_*`, `SERVER_*`, `REQUEST_*`, `REMOTE_*`, `PHP_AUTH_*`, `SCRIPT_*`, `DOCUMENT_*`, `HTTPS`, `QUERY_STRING`, `CONTENT_*`, `AUTH_TYPE`, `GATEWAY_INTERFACE`, `PHP_SELF`, `PATH_INFO`, `PATH_TRANSLATED`) are never resolved from the ambient environment (request data can reach it under CGI/FastCGI) — they act as unset. - Missing/unresolvable var → `configException` (runtime) / `cliException` (gf), naming the variable. - A leftover `%env(` after resolution (e.g. a `)` inside a `default:` literal) is an error, not - silently shipped. +- `gf env --list` prints every referenced variable; `gf env --init` writes the `.env` skeleton from + config.json itself, so the manifest cannot drift. **`{root}/config.json`** → `\gcgov\framework\models\unifiedConfig` (one file, all sections): ```jsonc @@ -369,13 +371,16 @@ environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hostin "app": { "title": "...", "guid": "..." }, "email": { "fromAddress": "", "fromName": "", "useSMTP": false, "SMTPHost": "", "SMTPPort": 587, "...": "" }, "settings": { "useSession": false, "forceMfaForPasswordUsers": false }, - "type": "local|prod", "serverName": "", "rootUrl": "", "basePath": "", "cookieUrl": "", - "logging": { "lifecycle": false, "renderer": false }, // lifecycle=true logs the whole request pipeline + "type": "local|prod", "rootUrl": "", "basePath": "", + "logging": { "lifecycle": false, "renderer": false, + "destination": "stderr|file|both" }, // stderr (default) emits JSON lines "mongoDatabases": [ { "default": true, "database": "", "uri": "mongodb+srv://...", "logging": true, "audit": false, "include_meta": true, "encryption": { /* optional */ } } ], "sqlDatabases": [ { "default": true, "name": "", "dsn": "", "readAccount": {}, "writeAccount": {} } ], "microsoft": { "clientId": "", "clientSecret": "", "tenant": "", "driveId": "", "fromAddress": "" }, - "jwtAuth": { "tokenIssuedBy": "", "tokenPermittedFor": "", "redirectAfterLoginUrl": "", "redirectAfterLogoutUrl": "" }, + "jwtAuth": { "tokenIssuedBy": "", "tokenPermittedFor": "", // empty → derived from rootUrl / basePath + "redirectAfterLoginUrl": "", "redirectAfterLogoutUrl": "", + "keyPath": "" }, // empty → {root}/srv/jwtCertificates "appDictionary": { } // free-form key/values plugins read (e.g. cronMonitorUrl) } ``` @@ -386,7 +391,7 @@ environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hostin | Call | Purpose | |------|---------| -| `services\log::{debug,info,notice,warning,error,critical,alert,emergency}($channel,$msg,$context=[])` | Monolog-backed; writes `/logs/{channel}.log`. | +| `services\log::{debug,info,notice,warning,error,critical,alert,emergency}($channel,$msg,$context=[])` | Monolog-backed. Destination is `logging.destination`: `stderr` (default, JSON lines) / `file` (`/logs/{channel}.log`) / `both`. | | `services\request::getAuthUser(): authUser` | Request-scoped authenticated user (roles, id, email…). Populated by the auth plugin's guard. | | `services\request::getUserClassFqdn(): string` | Resolves the app's user model FQDN: `\app\models\user`, else the Mongo `…\models\auth\user`. | | `services\request::getPostData(): array` | Parsed request body. | @@ -443,6 +448,11 @@ List routes with `gf cli:list`; debug with `gf cli /path --debug`. - There's no auth without an auth plugin; and auth plugins register a **global guard** over every `authentication:true` route in the app. - Set `logging.lifecycle=true` in `config.json` to trace the entire pipeline when debugging routing/auth. +- Every `%env()` reference is required — there is no default and `FOO=` counts as unset. `gf env` says + which one is missing. +- Logs go to **stderr** by default, not `logs/*.log`. An app on IIS sets `logging.destination: "file"`. +- JWT signing keys are gitignored, so they are never in a built image: a container must point + `jwtAuth.keyPath` at a provisioned directory or authentication cannot work. --- @@ -485,6 +495,9 @@ at a time (oauth-server OR auth-ms-front). - Tests: `composer test` (PHPUnit; `tests/` mirrors `src/`, uses `tests/Shims/MongoDBShims.php` so unit tests run without a live Mongo). `composer ci` = phpstan + test. - GitHub Actions (`.github/workflows/ci.yml`) runs on PHP 8.4. **Run `composer ci` before pushing.** +- Every application gets `GET {basePath}/health` (liveness, no I/O) and `/health/ready` (readiness, + pings Mongo, 503 when a dependency is down) from `services/health/` — contributed by the framework + router itself, not opt-in, because a deploy pipeline cannot gate on an endpoint an app might omit. - When you change `src/`, add/adjust the mirrored test under `tests/Unit/…`. --- @@ -503,9 +516,17 @@ at a time (oauth-server OR auth-ms-front). The framework ships a symfony/console-based command line tool exposed as a composer bin: every consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `readme/gf.md`. -- **Commands** (canonical names; `gf db restore` auto-resolves to `db:restore`): `cli`, `cli:list`, - `cert:generate-auth`, `chrome:install`, `chrome:update`, `chrome:status`, `db:restore`, `db:run`, - `env`, `setup`, `deploy`, `completion`, `completion:powershell`. Bare `gf` lists everything. +- **Commands** (canonical names; `gf db run` auto-resolves to `db:run`): `cli`, `cli:list`, + `cert:generate-auth`, `chrome:install`, `chrome:update`, `chrome:status`, `db:run`, `env`, `init`, + `migrate`, `completion`, `completion:powershell`. Bare `gf` lists everything. + **Removed in v7**: `deploy` (a Release is an immutable image pinned by digest — see ADR 0002), + `db:restore` (it required production credentials on every workstation), and `setup` (replaced by + the non-interactive `init`, since bootstrap belongs in a scaffolding script or a devcontainer). +- **`gf env`** validates that config.json resolves; `--list` prints every referenced variable and + whether it is currently set; `--init` writes the `.env` skeleton (refuses to overwrite without + `--force`). **`gf init --title="…"`** bootstraps a scaffolded app: title, guid, `.env`, JWT keys, + chrome. **`gf migrate`** converts a v6 app — its `plan()` is a pure function of `app.json` + + `environment.json`, so it is unit-tested rather than run hopefully. - **chrome-headless-shell**: `chrome:install`/`chrome:update` download the Chrome for Testing Stable build for the current platform into `srv/chrome/{version}/` (manifest: `srv/chrome/installation.json`; needs ext-zip; `gf setup` auto-installs, `--skip-chrome` opts @@ -513,20 +534,18 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea logic lives in `services/chrome/chromeInstallation.php`, download orchestration in `src/cli/chromeInstaller.php` (injectable Guzzle client — tests are network-free). - **Architecture** (`src/cli/`): `application` (command registration + provider discovery), - `appContext` (app-root locator: composer autoload path first, then cwd walk-up; config access - via `loadConfig()` for the active config and `loadVariantEnvironment($name)` for one - `environments.{name}` entry — both delegate to `services\environment\configLoader`; never boots - the request lifecycle), + `appContext` (app-root locator: composer autoload path first, then cwd walk-up; config via + `loadConfig()` and `configReferences()`, both delegating to `services\environment\configLoader`; + never boots the request lifecycle), `routeCatalog` (CLI-route enumeration via `router::getMergedRoutes()`), `phpProcess`, - `tokenReplacer`, `mongoTools`, `cliException` (user-facing errors), + `mongoTools`, `cliException` (user-facing errors), `internal/run-route.php` (child-process route runner; maps response status ≥400 → exit 1). - `gf env` validates config resolution (it stopped copying files in v7); `gf setup` prompts only - for `{token}`s present in the tree (`setupCommand::filterPromptsToPresentTokens`). + - **Command tiers**: no context (list/help/completion — must work anywhere, including this repo); - root-only (env, db:*, cert:*, deploy, setup — config JSON only, no `\app` boot); + root-only (env, db:run, cert:*, init, migrate — config JSON only, no `\app` boot); app-boot (cli, cli:list — `assertAppLoadable()`; `\app\app::_before()` is deliberately NOT called). - **`gf cli `** always spawns a fresh PHP child process (Xdebug flags need fresh INI; - isolates `exit()`; interpreter picked via `--php` > `GF_PHP` > config.json `phpPath` > current). + isolates `exit()`; interpreter picked via `--php` > `GF_PHP` > current). The interpreter must be the CLI binary — `php-cgi`/`php-fpm`/`php-win` are swapped for the `php`/`php.exe` beside them, else rejected; the child always gets `-dregister_argc_argv=1`, and `internal/run-route.php` assumes neither `$argv` nor `STDERR` exists until it has checked. @@ -535,9 +554,9 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea Discovery is fail-safe — errors never break gf (visible with `-v`). - When adding a command: lowercase lowerCamelCase class in `src/cli/commands/`, `#[AsCommand]` attribute, register it in `application::__construct()`, throw `cliException` for user errors, - add a mirrored test in `tests/Unit/Cli/` (external binaries are exercised via pure - arg-builder methods, e.g. `dbRestoreCommand::buildDumpCommand()`). -- The legacy `scripts/*.ps1` are deprecated wrappers kept for backward compatibility. + add a mirrored test in `tests/Unit/Cli/`. Keep the logic in a pure static method the test can + call directly (e.g. `migrateCommand::plan()`, `envCommand::renderEnvFile()`) rather than driving + everything through CommandTester. --- @@ -554,3 +573,10 @@ The five canonical roles, each label string equal to its role name. See `docs/ag ### Domain docs Single-context: one root `CONTEXT.md` plus `docs/adr/`. See `docs/agents/domain.md`. + +`CONTEXT.md` is the glossary — read it before naming anything. Note especially that **Environment** +(a deployment target, defined by the variable set a process is given) and **Zone** (a network +isolation boundary) are different things, and that v6's "environment variant" no longer exists. + +ADRs recorded so far: 0001 fail-closed configuration · 0002 immutable Release pinned by digest · +0003 secrets never decrypt in CI or on hosts · 0004 one self-hosted runner per Zone. diff --git a/docs/adr/0001-fail-closed-configuration.md b/docs/adr/0001-fail-closed-configuration.md index 34be4ea..67f0b15 100644 --- a/docs/adr/0001-fail-closed-configuration.md +++ b/docs/adr/0001-fail-closed-configuration.md @@ -24,5 +24,9 @@ production as a URL. Both failures were silent. - Optional integrations (Microsoft, PayJunction, SMTP) are **absent** from the template rather than present-and-blank. An Application adds the block when it needs it; a missing section hydrates to its defaults. -- Removing `default:` also removed the resolver's greedy-argument parsing and its ban on `)` inside - a reference, which existed only to support it. +- Removing `default:` removed the resolver's greedy-argument parsing, which existed only to let a + fallback literal contain colons. A reference still ends at the first `)` and a leftover `%env(` + after resolution is still an error — those are plain syntax rules that catch typos, not + consequences of the removed processor. +- The processor set shrank to `secret, file, trim, int, bool, json`. `string`, `not`, `float` and + `base64` had no users, and each one is an API surface, a test and an error path to carry. diff --git a/readme/environment-variables.md b/readme/environment-variables.md index c017a88..11dd6b0 100644 --- a/readme/environment-variables.md +++ b/readme/environment-variables.md @@ -1,235 +1,201 @@ -# Environment variables in config (`%env(...)%`) +# Configuration and environment variables -`gcgov/framework` can resolve **environment variables** inside your JSON config files -(the unified `{root}/config.json`) at load time. This lets you keep -secrets — Mongo URIs, Microsoft client secrets, SMTP/PayJunction credentials — **out of the -config files entirely** and inject them from the process environment, Docker/Kubernetes -secrets, or a local `.env` file. This is what makes the framework hostable in Docker (see the -app template's `DOCKER.md`). +An application has **one** committed configuration file, `{root}/config.json`. The same bytes are +correct in every Environment: everything that varies is an `%env(...)%` reference, and the values +come from the process environment, a provisioned secret file, or a `.env` on a developer's machine. -The syntax is intentionally **Symfony-compatible** (`%env(processor:VAR)%`), but the resolver -is a small standalone class in the framework -(`\gcgov\framework\services\environment\envVarResolver`) — it is **not** coupled to Symfony's -dependency-injection container. +Two rules carry most of the design: + +1. **Every reference is required.** There is no default. A variable that is unset — or set to the + empty string — is a startup failure that names the variable. A value that does *not* vary + between Environments is written as a literal, not referenced. +2. **Secrets never have to be in the environment.** `%env(secret:NAME)%` reads a provisioned file + when one is mounted, so the same config serves a laptop and production without a second file. + +> See also: `CONTEXT.md` for what "Environment" and "Secret" mean here, and +> `docs/adr/0001-fail-closed-configuration.md` for why the fallback mechanism was removed. --- -## Backwards compatibility +## Why every reference is required -**Existing config files keep working unchanged.** A config file that contains no `%env(` -substring takes a byte-for-byte identical path through the loader (including today's -malformed-JSON error behavior). You only opt in by writing `%env(...)%` somewhere in the file. +v7 briefly had a Symfony-style `default:` processor. It was removed, because it made the dangerous +case the quiet one: -> **BC edge case (documented):** because `%env(` is now meaningful, a config *value* that needs -> to contain the literal text `%env(...)%` can no longer be stored verbatim. There is no known -> usage of such a value. +```jsonc +"type": "%env(default:local:APP_TYPE)%" // ← removed in v7 +``` ---- +`isLocal()` gates real behaviour. A production container that forgot `APP_TYPE` booted successfully +in development posture and said nothing. The same pattern let a skipped scaffolding prompt ship the +literal string `{app_root_url}` to production as a URL. -## Where it applies +Failing at startup with *"Required environment variable APP_TYPE is not set"* is the whole point. If +a value has a sensible fixed answer, that answer belongs in `config.json` as a literal. -Resolution runs at the two points where the framework reads the unified config JSON: +Empty counts as unset for the same reason: an `.env` copied from an example, with blank lines where +the credentials go, must fail rather than configure an application with empty ones. -| Source | Loader | -|--------|--------| -| `{root}/config.json` (active — the `environments` section is stripped) | `\gcgov\framework\config` static accessors (`config::getBasePath()`, `getMongoDatabases()`, `getEmail()`, …) | -| `config.json`'s `environments.{name}` entry | the `gf` CLI (`appContext::loadVariantEnvironment($name)`) — see "Foreign environments" below | +--- + +## Where values come from -Untyped config regions (`appDictionary`, plugin `clientParams`, etc.) are resolved too — the -resolver walks the whole decoded tree. +| Source | Precedence | Notes | +|---|---|---| +| Real process environment | highest | What a container is given. Always wins. | +| `{root}/.env.local` | | Machine-local overrides. | +| `{root}/.env` | lowest | Generated by `gf env --init`; gitignored. | -A failed resolution (e.g. a required variable is missing) throws: -- a `configException` (HTTP 500) at request time, or -- a `cliException` from `gf`, +Either `.env` file may exist alone. There is no `APP_ENV` cascade and nothing is activated or +copied: an Environment simply *is* the set of variables a process is given. -each with a message naming the offending variable and the source file. +Loading happens once per process, in `dotEnvLoader::loadOnce()`, before `config.json` is resolved. +`usePutenv()` is on, so call sites reading through `getenv()` see `.env` values too. --- ## Syntax ``` -%env(PROCESSOR:...:VAR_NAME)% +%env(PROCESSOR:...:VARIABLE_NAME)% ``` -- The **last** `:`-delimited segment is the environment variable name - (`[A-Za-z_][A-Za-z0-9_]*`). -- Preceding segments form a **processor chain applied right-to-left** (Symfony order): - `%env(trim:file:DB_PASS_FILE)%` = `trim( file( env(DB_PASS_FILE) ) )`. +The last `:`-delimited segment is the variable name; anything before it is a processor chain applied +**right to left**, innermost first — `%env(int:trim:file:PORT_FILE)%` is `int(trim(file(env(PORT_FILE))))`. -### Typed vs. embedded +A reference that is the **whole** value produces a typed result: -- **Whole-value reference** — when the entire JSON string is a single `%env(...)%`, the - **typed** result replaces the value (int/bool/float/array/stdClass/string): +```jsonc +"SMTPPort": "%env(int:SMTP_PORT)%" // → 587, an int +"useSMTP": "%env(bool:SMTP_ENABLED)%" // → true, a bool +``` - ```jsonc - "SMTPPort": "%env(int:SMTP_PORT)%" // → 587 (an integer, not "587") - "uri": "%env(MONGO_URI)%" // → "mongodb+srv://…" (a string) - ``` +A reference **embedded** in a larger string is substituted as text: -- **Embedded reference** — when `%env(...)%` appears inside a larger string, its result is - substituted as a **string**. A non-scalar embedded result (e.g. `json:`) throws. +```jsonc +"rootUrl": "https://%env(APP_HOST)%/api" +``` - ```jsonc - "baseUrl": "https://%env(SERVER_NAME)%/api" - ``` +A reference ends at the first `)`. A configuration value cannot contain the literal text `%env(` — +there is no escape syntax, and a leftover `%env(` after resolution is an error rather than something +shipped verbatim. A file containing no `%env(` at all is loaded byte-for-byte, untouched. --- -## Environment lookup precedence - -For each variable the resolver looks in, in order: - -1. `$_ENV` -2. `$_SERVER` — **excluding `HTTP_*` keys** (request headers can never satisfy an env - reference) -3. `getenv()` +## Processors -A variable that is *set but empty* resolves to `''`. A variable that is genuinely **unset** -triggers the `default:` fallback if present, otherwise an error. +| Processor | Effect | +|---|---| +| `secret` | The `_FILE` indirection — see below. Must be innermost. | +| `file` | Replace the value with the contents of the file it names. | +| `trim` | Strip surrounding whitespace. | +| `int` | Cast to int; error if not numeric. | +| `bool` | `true/1/yes/on` → `true`, otherwise `false`. | +| `json` | Parse as JSON into an object/array. | + +`string`, `not`, `float` and `base64` existed in an early v7 draft and were removed — nothing used +them, and each one is a public API surface, a test, and an error path to carry. Ask if you need one +back; adding is easy, removing later is not. -### `.env` files +--- -Before resolving, the framework loads (once per process, if present): +## `secret` — one config file for a laptop and for production -``` -{app-root}/.env then {app-root}/.env.local +```jsonc +"uri": "%env(secret:MONGO_URI)%" ``` -via `symfony/dotenv`. Precedence, highest wins: +resolves in one of two ways: -``` -real process environment > .env.local > .env -``` +- If **`MONGO_URI_FILE`** is set, its value is a path, and the (trimmed) contents of that file are + the result. This is production: the ops repository provisions `/run/secrets/permits-api/mongo_uri` + and sets `MONGO_URI_FILE` to point at it. The credential never enters the process environment, so + it is not visible in `docker inspect` or `/proc//environ`, and not inherited by child + processes. +- Otherwise **`MONGO_URI`** is read directly. This is a developer machine, with the value in `.env`. -The **real environment always wins** — dotenv never overrides a variable already present in -the process environment. There is no `APP_ENV` cascade: an "environment" is simply the set of -variable values the process is given — a prod container gets prod values from its runtime -environment/secrets, a dev machine gets dev values from `.env`. Nothing is activated or copied. +**A `_FILE` variable that is set but names a missing or unreadable file is an error.** It does not +fall back to the plain variable. That fallback is exactly the failure you do not want: a secret +mount that silently did not happen, quietly replaced by whatever stale value the environment +happens to hold. -Keep `.env` / `.env.local` **out of version control** (the app template gitignores them and -ships a committed `.env.example`). +`secret` must sit immediately before the variable name, since it decides *where* the value is read +rather than transforming one. --- -## Processors +## Finding out what an application needs -| Processor | Effect | -|-----------|--------| -| `string` | Cast to string. | -| `bool` | Truthy → `true` (`1/true/yes/on`), else `false`. | -| `not` | Boolean negation of `bool`. | -| `int` | Cast to integer (errors on a non-numeric value). | -| `float` | Cast to float (errors on a non-numeric value). | -| `trim` | Trim surrounding whitespace. | -| `file` | **Read the file whose path is the variable's value** — the Docker/Kubernetes secrets pattern. | -| `base64` | Base64-decode (URL-safe tolerant; padding optional). | -| `json` | JSON-decode into an array/object/scalar. | -| `default` | Literal fallback when the variable is unset (see below). | - -Chains apply right-to-left. The canonical Docker-secret read: +The manifest is derived from `config.json`, never hand-maintained: -```jsonc -"uri": "%env(trim:file:MONGO_URI_FILE)%" +```bash +gf env # resolve config.json against the current environment; name the first failure +gf env --list # every referenced variable, whether it is a secret, whether it is set +gf env --init # write a .env skeleton from that list (--force to overwrite) ``` -`MONGO_URI_FILE=/run/secrets/mongo_uri` → read that file → trim the trailing newline → use the -contents as the Mongo URI. +`.env` also carries variables `config.json` knows nothing about — docker compose ports, CORS +origins. Those live in the template's `.env.example`; the two files have deliberately disjoint +ownership so neither can drift into the other's territory. --- -## The `default` processor (deliberate deviation from Symfony) - -Unlike Symfony — where `default:` names a fallback **parameter** — here `default` provides a -**literal** fallback value. Rules: +## Reserved names -- It must be **innermost** (closest to the variable name). -- Its argument is **greedy**: everything between `default:` and the final `:VAR`, so **colons - are legal** in the fallback. -- The fallback applies **only when the variable is unset** (a set-but-empty variable wins). +Under CGI and FastCGI, request headers reach the real process environment as `HTTP_*`, and +`$_SERVER` carries request-derived CGI meta-variables. So that a `%env()` reference can never be +satisfied by request data, these names are treated as **unset** in every lookup source: -```jsonc -// dev-safe fallback that itself contains colons: -"uri": "%env(default:mongodb://mongodb:27017:MONGO_URI)%" +`HTTP_*`, `SERVER_*`, `REQUEST_*`, `REMOTE_*`, `PHP_AUTH_*`, `SCRIPT_*`, `DOCUMENT_*`, `HTTPS`, +`QUERY_STRING`, `CONTENT_TYPE`, `CONTENT_LENGTH`, `AUTH_TYPE`, `GATEWAY_INTERFACE`, `PHP_SELF`, +`PATH_INFO`, `PATH_TRANSLATED`. -// empty-string fallback: -"clientSecret": "%env(default::MICROSOFT_CLIENT_SECRET)%" +Do not name a configuration variable after one of these. Referencing one produces an error that +says so. -// composed with another processor (default is still innermost): -"SMTPPort": "%env(int:default:587:SMTP_PORT)%" // → int 587 when SMTP_PORT is unset -``` +--- -With the single committed `config.json`, the split is per **value**, not per file: give -`default:` fallbacks only to non-secret dev conveniences (identity URLs, a local `type`), and -leave secrets and database coordinates as **hard references** so a misconfigured prod container -fails loudly, naming exactly what to set — dev covers them via `.env` (`cp .env.example .env`): +## A complete example ```jsonc -// config.json — one file for every environment: -"type": "%env(default:local:APP_TYPE)%", // dev-safe default; prod sets APP_TYPE=prod -"uri": "%env(MONGO_URI)%", // hard: fail fast when unset -"clientSecret": "%env(MICROSOFT_CLIENT_SECRET)%" - -// …or, preferring file-based secrets: -"uri": "%env(trim:file:MONGO_URI_FILE)%" -``` - ---- +{ + "app": { "title": "Permits API", "guid": "…" }, // literals: they never vary + "settings": { "useSession": false }, -## Foreign environments (gf CLI: `environments` section) + "type": "%env(APP_TYPE)%", + "rootUrl": "%env(APP_ROOT_URL)%", + "basePath": "%env(APP_BASE_PATH)%", -The gf CLI sometimes needs a **foreign** environment's connection info without activating -anything — `gf db:restore --from=prod` must resolve prod's Mongo URI while your shell holds -local values. This lives in an `environments` section of `config.json`, keyed by environment -name. The runtime **strips this section** before resolving the active configuration, so its -references never have to be set for the app to run: + "logging": { "destination": "stderr" }, -```jsonc -{ - "type": "%env(default:local:APP_TYPE)%", - "mongoDatabases": [ { "default": true, "database": "%env(MONGO_DATABASE)%", "uri": "%env(MONGO_URI)%" } ], - - // gf-only. type is a committed LITERAL (the db:restore prod guard relies on it); the %env() - // references use ENVIRONMENT-PREFIXED names so a missing value fails loudly instead of - // silently resolving to your local value. - "environments": { - "prod": { - "type": "prod", - "mongoDatabases": [ { "default": true, "database": "%env(PROD_MONGO_DATABASE)%", "uri": "%env(PROD_MONGO_URI)%" } ] + "mongoDatabases": [ + { + "default": true, + "database": "%env(MONGO_DATABASE)%", + "uri": "%env(secret:MONGO_URI)%" } - } + ], + + "jwtAuth": { "keyPath": "/run/secrets/permits-api/jwt" } + // tokenIssuedBy / tokenPermittedFor omitted: they derive from rootUrl / basePath } ``` -Put the `PROD_*` values in the **same gitignored `.env`** you already use for local development. -`appContext::loadVariantEnvironment('prod')` resolves only the `environments.prod` subtree. - -Why prefixed names? Because the source and target of `db:restore` resolve against the same -process environment, a *shared* name (`MONGO_URI`) would silently fall back to your local value -when the prod value is missing. A distinct name (`PROD_MONGO_URI`) fails loudly instead. Validate -an environment before relying on it with `gf env prod` (it reports the resolved databases with -redacted URIs, or names the first unresolvable variable); `db:restore` additionally refuses a -pair whose source and target resolve to the same database. +Five variables. A developer sets them in `.env`; production supplies four as environment variables +and `MONGO_URI` as a provisioned file. --- -## Reserved variable names (request-data guard) +## Migrating a v6 application -In web SAPIs, request data leaks into the ambient lookup: CGI/FastCGI turns request headers into -`HTTP_*` variables that reach `getenv()` (and, with `variables_order=E`, `$_ENV`), and `$_SERVER` -carries request-derived CGI meta-variables. To guarantee a `%env(...)%` reference can never be -satisfied by request data, names matching the CGI meta-variable set — `HTTP_*`, `SERVER_*`, -`REQUEST_*`, `REMOTE_*`, `PHP_AUTH_*`, `SCRIPT_*`, `DOCUMENT_*`, and `HTTPS`, `QUERY_STRING`, -`CONTENT_TYPE`, `CONTENT_LENGTH`, `AUTH_TYPE`, `GATEWAY_INTERFACE`, `PHP_SELF`, `PATH_INFO`, -`PATH_TRANSLATED` — are treated as **unset** in every ambient source (`default:` still applies, -otherwise the reference fails loudly). Do not name configuration variables after these. - ---- +`gf migrate` does the deterministic half: merges `app/config/app.json` and +`app/config/environment.json` into `config.json`, turns their values into references, writes the +extracted values to `.env`, and deletes the IIS and batch files. Run `gf migrate --dry-run` first +and read the diff. -## Why file-based secrets are preferred +It reports rather than guesses: `sqlDatabases` credentials, a missing `app.guid`, and the dropped +`serverName` / `cookieUrl` / `phpPath` keys all come back as warnings. It also pins +`logging.destination` to `"file"` so an application's logging behaviour does not change underneath +it — switch that to `"stderr"` when the application moves into a container. -Process environment variables are visible to anyone who can run `docker inspect` on the -container, and can leak into logs and crash dumps. A **Docker/Swarm/Kubernetes secret** mounted -as a file at `/run/secrets/` and read with `%env(trim:file:_FILE)%` keeps the -secret value off the process environment entirely. See the app template's `DOCKER.md` for the -full deployment guidance. +See `DOCKER.md` in the application template for the deployment side. diff --git a/readme/gf.md b/readme/gf.md index ef6a596..4e83b6e 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -14,8 +14,8 @@ Run it with no arguments to see everything available: Tip: add `vendor/bin` to your PATH (or use `composer exec gf`) so you can type `gf` alone. Throughout this document `gf` means `vendor/bin/gf` (`vendor\bin\gf.bat` on Windows). -Command names use the `namespace:command` convention (`db:restore`). The space-separated -spelling also works — `gf db restore` resolves to `db:restore` automatically. +Command names use the `namespace:command` convention (`db:run`). The space-separated +spelling also works — `gf db run` resolves to `db:run` automatically. | Command | Replaces | Purpose | |---|---|---| @@ -25,11 +25,10 @@ spelling also works — `gf db restore` resolves to `db:restore` automatically. | `gf chrome:install` | manual Chrome installs | Download chrome-headless-shell into srv/chrome | | `gf chrome:update` | — | Update chrome-headless-shell to current Stable + remove old versions | | `gf chrome:status` | — | Show whether chrome-headless-shell is installed and what version | -| `gf db:restore` | `db/restore-live-to-local.ps1` | Copy a source environment's mongo databases into a target environment | | `gf db:run ` | ad-hoc `mongosh "" script.js` | Run a mongosh script using config-managed connections | -| `gf env []` | manual `Copy-Item` steps | List config.json environments; validate that config resolves (active, or an environments.{name} entry) | -| `gf setup` | `scripts/setup.ps1` | Bootstrap a freshly scaffolded application | -| `gf deploy` | `update-production.ps1` | Tag-based production deployment | +| `gf env` | manual `Copy-Item` steps | Validate that config.json resolves; `--list` its variables; `--init` a .env skeleton | +| `gf init` | `scripts/setup.ps1` | Bootstrap a freshly scaffolded application (non-interactive) | +| `gf migrate` | — | Convert a v6 application's configuration to v7 | | `gf completion` / `gf completion:powershell` | — | Shell tab completion | `gf` never requires a Windows shell: everything is implemented in PHP or shells out to @@ -120,7 +119,7 @@ that touches the network, and a network failure there only warns). `srv/chrome/installation.json` manifest recording the active version; the directory is git-ignored automatically. Installation is atomic — an interrupted download never leaves a half-installed version. -- `gf setup` runs the install automatically (`--skip-chrome` to opt out). `chrome:update` is +- `gf init` runs the install automatically (`--skip-chrome` to opt out). `chrome:update` is idempotent and safe to run on a schedule. - Requires the PHP **zip** extension (`extension=zip` in php.ini on Windows, `php-zip` on Linux). - macOS note: if Gatekeeper ever blocks the binary, clear the quarantine attribute with @@ -145,132 +144,97 @@ installation exists. The `chrome-php/chrome` library is a framework dependency, --- -## Databases: `gf db:restore` and `gf db:run` +## Databases: `gf db:run` -Connection strings come from `{root}/config.json` — never hardcode credentials in scripts -again. The **local** side uses the active `mongoDatabases[]`; a **foreign** environment -(`--from=prod`, `--env=prod`) resolves the `environments.{name}` entry of the same `config.json`, -which references environment-prefixed variables (e.g. `PROD_MONGO_URI`) you keep in your -gitignored `.env` (see [Environments](#environments-gf-env) below): +Runs a `.js` script through `mongosh` against the application's configured connection, so scripts +stop carrying hardcoded connection strings: -```jsonc -// config.json — the environments section is CLI-only (stripped at runtime) -"environments": { - "prod": { "type": "prod", - "mongoDatabases": [ { "default": true, "database": "%env(PROD_MONGO_DATABASE)%", "uri": "%env(PROD_MONGO_URI)%" } ] } -} -``` -```ini -# {root}/.env (gitignored) -PROD_MONGO_URI=mongodb+srv://user:pass@prod-cluster/ -PROD_MONGO_DATABASE=app +```bash +gf db:run db/create-admin.js +gf db:run db/seed.js --db=reporting # pick a database when the app has several +gf db:run db/report.js -- --quiet # everything after -- goes to mongosh ``` -``` -gf db:restore # dump prod -> restore into the active configuration (--drop) -gf db:restore --from=prod --to=local # --to also names an environments.{name} entry -gf db:restore --db=AppsSchedule # only the named database(s) -gf db:restore --keep-dump --dump-dir=db/backup -``` +Requires `mongosh` on PATH. Connection details come from `config.json`'s `mongoDatabases`; the URI +is redacted in all output. -- Source/target databases are paired by database name (falling back to the two `default` - entries); differing names are remapped with `--nsFrom/--nsTo`. -- Restoring **into** the environment named `prod`, or into one whose `type` is `prod`, is refused - unless `--allow-prod`. -- A pair whose source and target resolve to the **same uri and database** is refused outright — - that usually means an `environments.{name}` entry reused a local variable name instead of a - prefixed one. Validate with `gf env prod`. -- Requires the [MongoDB Database Tools](https://www.mongodb.com/try/download/database-tools) - (`mongodump`, `mongorestore`) on PATH. -- The plan (with passwords redacted) is shown and confirmed before anything runs; `--yes` skips. - -``` -gf db:run db/create-admin.js # against the active configuration's default db -gf db:run db/migrate.js --env=prod --db=AppsSchedule -gf db:run db/seed.js -- --quiet # everything after -- goes to mongosh -``` - -Requires [mongosh](https://www.mongodb.com/try/download/shell) on PATH. +> **`gf db:restore` was removed in v7.** It pulled another Environment's databases down to a +> workstation, which meant every developer's `.env` held production credentials. Developers get +> realistic data from the separate backup-restore workflow instead. --- -## Environments: `gf env` +## Configuration: `gf env` -Environment selection is **environment-variable driven**: the committed root `config.json` -references variables with `%env(...)%`, and whichever values the process environment (container -env, Docker secrets, or `{root}/.env`) supplies *are* the environment. There is nothing to -activate or copy. Foreign-environment connection info for the `db:*` commands lives in the -CLI-only `environments` section of `config.json` (stripped at runtime). +Configuration is one committed `config.json` whose environment-varying values are `%env(...)%` +references, every one of them required. `gf env` is how you find out what an Environment is missing +before the application does. -`gf env` validates that model: - -``` -gf env # list config.json environments + validate the ACTIVE configuration -gf env prod # resolve the environments.prod entry and validate it +```bash +gf env # resolve config.json against the current environment +gf env --list # every variable it references, whether each is a secret, whether each is set +gf env --init # write a .env skeleton from that list (--force to overwrite) ``` -`gf env ` prints the resolved summary (type, databases with redacted URIs) and exits -non-zero naming the first unresolvable variable. Run it before trusting an environment with -`db:restore`/`db:run`. An `environments.{name}` entry should reference **environment-prefixed -variable names** (e.g. `PROD_MONGO_URI`) so a missing value fails loudly rather than silently -resolving to your local value. - -### Migrating a v6 app to v7 - -v6's split `app/config/app.json` + `environment-{env}.json` files and the `gf env` copy step -are gone. To move an app onto v7: - -1. Commit a single **`config.json` at the application root**: merge the contents of the old - `app/config/app.json` (`app`, `email`, `settings` sections) and `app/config/environment.json` - (everything else) into one JSON object, with every secret and every per-environment value - referenced via `%env(...)%` — see [environment-variables.md](environment-variables.md) and - the app template's copy. Then delete the `app/config/` directory. -2. For each old variant you need foreign-environment `db:*` access to, add an - `environments.{env}` entry to `config.json` (`type` literal + `mongoDatabases` with - environment-prefixed `%env()` names like `PROD_MONGO_URI`) and put those values in your - gitignored `{root}/.env`. -3. Delete `environment-{env}.json`, `composer-{env}.json`, and `www/web-{env}.config`; commit - `composer.json` (and a static `www/web.config`, if the app still runs on IIS). -4. Migrate `config::getAppConfig()` / `config::getEnvironmentConfig()` calls in app code to - the flattened accessors (`config::getBasePath()`, `config::getSettings()`, - `config::getMongoDatabases()`, …). The old methods still work — they are **deprecated - pass-throughs** returning the unified config object, which carries every former field and - helper — so this step can happen gradually after the upgrade. -5. Bump `gcgov/framework` to `^v7.0`; verify with `gf env` and `gf env prod`. +Validation prints the resolved type, urls, logging destination and Mongo connections (URIs +redacted), or fails naming the first unresolvable variable. `--list` and `--init` read `config.json` +without resolving anything, so they work on a fresh clone with no `.env` at all. + +Because the manifest is derived from `config.json`, it cannot drift from it. `.env` also holds +variables `config.json` never sees — compose ports, CORS origins — which live in the template's +`.env.example`; `--init` does not touch an existing file. + +Full reference: **[Environment variables in config](environment-variables.md)**. --- -## Project bootstrap: `gf setup` +## Project bootstrap: `gf init` + +Run once after scaffolding from `gcgov/framework-app-template`: + +```bash +gf init --title="Timesheet API" +``` + +It writes the title and guid into `config.json`, writes a `.env` skeleton, generates JWT signing +keypairs, and installs chrome-headless-shell. `--skip-env`, `--skip-keys` and `--skip-chrome` opt +out of each step; `--guid` sets the guid explicitly. -Interactive replacement for `scripts/setup.ps1`. Run once after scaffolding a project from -`gcgov/framework-app-template` (after `composer install`): prompts for the project values, -generates the app GUID, then replaces the `{placeholder}` tokens across the project's -`.ini/.json/.php/.config/.bat/.ps1` files — including the per-environment `php.ini` files under -`srv/` (`vendor/`, `.git/`, `node_modules/` are excluded). Pressing enter skips a value and -leaves its token for a later re-run. +The guid is the OAuth `client_id`, so re-running `init` on an application that already has one keeps +it rather than minting a new one and invalidating every registered client. -Setup finishes by downloading chrome-headless-shell (the `gf chrome:install` step); a failure -there — offline machine, missing php-zip — only prints a warning and never fails setup. Pass -`--skip-chrome` to skip it entirely. +It is deliberately **non-interactive**, which is what lets it run from a scaffolding script, a +devcontainer `postCreateCommand`, or CI. It replaces v6's `gf setup` wizard, whose prompts filled +`{placeholder}` tokens in `php.ini` and `web.config` files that no longer exist. --- -## Deployment: `gf deploy` +## Migrating a v6 application: `gf migrate` -Cross-platform replacement for the per-app `update-production.ps1`: +Converts the configuration half of a v6 application. Run it on a clean working tree so the result +is reviewable as a diff: +```bash +gf migrate --dry-run # show the plan +gf migrate # apply it ``` -gf deploy # interactive tag picker -gf deploy --tag=v2.4.1 --yes # non-interactive -gf deploy --no-composer -``` -Steps: `git fetch/pull` → pick a tag (newest first, `--tags=N` to widen) → confirm → -`git checkout tags/` → `git submodule sync/update` → write -`version.json` (`{"version": "", "inherit": true}`) → `composer update`. -Any failing step aborts the deploy with that step's exit code. Configuration is committed -(`config.json` + `%env()` values from the server's environment), so there is no -config-activation step. +It merges `app/config/app.json` and `app/config/environment.json` into `{root}/config.json`, turns +their environment-varying values into `%env()` references (credentials become `%env(secret:…)%`), +writes the extracted values to `.env`, and deletes the v6 IIS, batch and PowerShell files. + +What it will not do is guess. `sqlDatabases` credentials, a missing `app.guid`, and the removed +`serverName` / `cookieUrl` / `phpPath` keys are reported for you to handle. It pins +`logging.destination` to `"file"` so an application's logging does not silently change on upgrade — +switch it to `"stderr"` when the application moves into a container. + +It does not write a Dockerfile, choose a Zone, or move secrets into the ops repository. Those need +judgement; the companion skill covers them. + +> **`gf deploy` was removed in v7.** It deployed by running `git checkout` and `composer update` on +> the server, which resolves dependencies in production at deploy time — two hosts on "the same tag" +> could be running different code. A Release is now an immutable image pinned by digest, deployed by +> a GitHub Actions workflow. See `docs/adr/0002-immutable-release-digest-pinning.md`. --- @@ -284,8 +248,7 @@ config-activation step. ``` Completion is dynamic: `gf cli ` suggests the application's actual CLI routes (with -descriptions), `gf env ` (and `db:restore --from=` etc.) suggests the config.json -environments (from the `environments` section) present in the app. +descriptions), `gf ` completes command names. --- @@ -321,7 +284,7 @@ down gf itself (run with `-v` to see discovery errors). Useful helpers for custom commands (all in `\gcgov\framework\cli`): - `appContext::require()` / `appContext::locate()` — application root + config access -- `appContext->loadConfig()` / `loadVariantEnvironment($name)` — resolve the active config, or one `environments.{name}` entry +- `appContext->loadConfig()` / `configReferences()` — resolve config.json, or list what it references - `configLoader::load($root)` / `loadVariantEnvironment($root, $name)` — the shared config-load pipeline (also used by `gcgov rameworknfig`) - `mongoTools::findBinary()/redactUri()/uriWithDatabase()` - `phpProcess::findPhpBinary()/requiredIniFlags()/xdebugFlags()` @@ -337,10 +300,9 @@ Useful helpers for custom commands (all in `\gcgov\framework\cli`): | `app\cli\prod.bat /cli/x` (Task Scheduler) | `vendor\bin\gf.bat cli /cli/x` | | `app\cli\local-debug.bat /cli/x` | `vendor/bin/gf cli /cli/x --debug` | | `scripts\create-jwt-keys.ps1` | `vendor/bin/gf cert:generate-auth` | -| `scripts\setup.ps1` | `vendor/bin/gf setup` | -| `db\restore-live-to-local.ps1` | `vendor/bin/gf db:restore --from=prod` | +| `scripts\setup.ps1` | `vendor/bin/gf init --title="…"` | | `mongosh "mongodb://user:pass@..." db\fix.js` | `vendor/bin/gf db:run db/fix.js --env=prod` | -| `update-production.ps1` | `vendor/bin/gf deploy` | +| `update-production.ps1` | removed — deployment is a GitHub Actions workflow (ADR 0002) | | `Copy-Item composer-local.json composer.json` (+ 2 more) | nothing — config is committed and environment-variable driven (v7); `gf env` validates it | Files an app can delete once migrated: `app/cli/local.bat`, `app/cli/local-debug.bat`, @@ -352,7 +314,7 @@ Reference any secrets that were hardcoded in those scripts via `%env(...)%` in t the root `config.json` — the `db:*` commands and the request lifecycle both resolve them. Keep the actual values in the process environment, Docker/Kubernetes secrets, or a gitignored `.env` file (per-variant values for the `db:*` commands go in gitignored -the config.json `environments` section). See **[Environment variables in config](environment-variables.md)** +See **[Environment variables in config](environment-variables.md)** and **[Migrating a v6 app to v7](#migrating-a-v6-app-to-v7)** above. For example, instead of a plaintext URI: diff --git a/src/cli/appContext.php b/src/cli/appContext.php index 8e2f821..6d7f572 100644 --- a/src/cli/appContext.php +++ b/src/cli/appContext.php @@ -2,7 +2,6 @@ namespace gcgov\framework\cli; -use gcgov\framework\models\config\variantEnvironment; use gcgov\framework\models\unifiedConfig; /** @@ -111,11 +110,6 @@ public function getAppDir(): string { } - public function getConfigDir(): string { - return $this->rootDir . '/app/config'; - } - - /** The unified {root}/config.json read by loadConfig(). */ public function getConfigPath(): string { return $this->rootDir . '/config.json'; @@ -160,16 +154,14 @@ public function getServiceNamespaces(): array { /** - * Load and resolve the ACTIVE configuration from the unified {root}/config.json — - * no \app boot, no ext-mongodb. {root}/.env is loaded first; the real process - * environment wins. The CLI-only `environments` section is stripped before - * resolution (see loadVariantEnvironment()). + * Load and resolve {root}/config.json — no \app boot, no ext-mongodb. + * {root}/.env is loaded first; the real process environment wins. * * @throws \gcgov\framework\cli\cliException */ public function loadConfig(): unifiedConfig { if( !file_exists( $this->getConfigPath() ) ) { - throw new cliException( 'Missing config file: ' . $this->getConfigPath() . '. Commit a config.json at the application root that references environment variables with %env(...) and supply values via the process environment or a .env file.' . $this->legacyConfigHint() ); + throw new cliException( 'Missing config file: ' . $this->getConfigPath() . '. Commit a config.json at the application root that references environment variables with %env(...) and supply values via the process environment or a .env file. Migrating a v6 application? Run `gf migrate`.' ); } try { @@ -182,71 +174,24 @@ public function loadConfig(): unifiedConfig { /** - * Load and resolve ONE entry of config.json's `environments` section — a - * foreign-environment read (db:restore --from, db:run --env, gf env ). - * The entry's %env() references should use environment-prefixed variable names - * (e.g. PROD_MONGO_URI, defined in {root}/.env), so a missing value fails - * loudly instead of resolving to a local value. + * Every variable config.json references, and whether each is a secret. Read without + * resolving anything, so it works on a fresh clone with no .env. * + * @return array * @throws \gcgov\framework\cli\cliException */ - public function loadVariantEnvironment( string $name ): variantEnvironment { - if( !file_exists( $this->getConfigPath() ) ) { - throw new cliException( 'Missing config file: ' . $this->getConfigPath() . '.' . $this->legacyConfigHint( $name ) ); - } - + public function configReferences(): array { try { - return \gcgov\framework\services\environment\configLoader::loadVariantEnvironment( $this->rootDir, $name ); + return \gcgov\framework\services\environment\configLoader::references( $this->rootDir ); } catch( \gcgov\framework\services\environment\environmentException $e ) { - throw new cliException( $e->getMessage() . $this->legacyConfigHint( $name ), 0, $e ); - } - } - - - /** - * Migration hint when pre-v7 config layouts are present: the v6 split - * app/config/app.json + environment{-variant}.json files, or a pre-release - * {root}/{variant}.env overlay file. - */ - private function legacyConfigHint( string $variant = '' ): string { - $legacyFiles = [ - $this->getConfigDir() . '/environment.json' => 'app/config/environment.json', - $this->getConfigDir() . '/app.json' => 'app/config/app.json', - ]; - if( $variant!=='' ) { - $legacyFiles[ $this->getConfigDir() . '/environment-' . $variant . '.json' ] = 'app/config/environment-' . $variant . '.json'; - $legacyFiles[ $this->rootDir . '/' . $variant . '.env' ] = $variant . '.env'; - } - foreach( $legacyFiles as $legacyFile => $label ) { - if( file_exists( $legacyFile ) ) { - return ' A legacy ' . $label . ' exists — this framework version reads a single {root}/config.json whose `environments` section (with environment-prefixed variables like PROD_MONGO_URI in .env) replaces per-environment files; see readme/gf.md "Migrating a v6 app to v7".'; - } - } - - return ''; - } - - - /** Human-readable description of where an environment's config comes from, for error/guard messages. */ - public function describeConfigSource( string $variant = '' ): string { - if( $variant==='' ) { - return $this->getConfigPath(); + throw new cliException( $e->getMessage(), 0, $e ); } - - return $this->getConfigPath() . ' (environments.' . $variant . ')'; } - /** - * Environment names declared in config.json's `environments` section — committed - * literals, so discovery and tab completion work on a fresh clone without any - * resolution or .env loading. - * - * @return string[] - */ - public function getEnvironmentVariants(): array { - return \gcgov\framework\services\environment\configLoader::variantNames( $this->rootDir ); + public function getEnvFilePath(): string { + return $this->rootDir . '/.env'; } } diff --git a/src/cli/application.php b/src/cli/application.php index 85dc0af..68ac0b7 100644 --- a/src/cli/application.php +++ b/src/cli/application.php @@ -32,11 +32,10 @@ public function __construct( ?string $composerAutoloadPath = null ) { new commands\chromeInstallCommand(), new commands\chromeUpdateCommand(), new commands\chromeStatusCommand(), - new commands\dbRestoreCommand(), new commands\dbRunCommand(), new commands\envCommand(), - new commands\setupCommand(), - new commands\deployCommand(), + new commands\initCommand(), + new commands\migrateCommand(), new commands\completionPowershellCommand(), ] ); diff --git a/src/cli/commands/cliCommand.php b/src/cli/commands/cliCommand.php index 356bc9a..68a5c5d 100644 --- a/src/cli/commands/cliCommand.php +++ b/src/cli/commands/cliCommand.php @@ -25,7 +25,7 @@ protected function configure(): void { $this->addOption( 'debug', null, InputOption::VALUE_NONE, 'Run the route with Xdebug step debugging enabled (replaces local-debug.bat)' ); $this->addOption( 'debug-host', null, InputOption::VALUE_REQUIRED, 'Xdebug client host', '127.0.0.1' ); $this->addOption( 'debug-port', null, InputOption::VALUE_REQUIRED, 'Xdebug client port', '9003' ); - $this->addOption( 'php', null, InputOption::VALUE_REQUIRED, 'PHP binary (or its directory) to run the route with. Defaults to GF_PHP, then config.json phpPath, then the PHP running gf.' ); + $this->addOption( 'php', null, InputOption::VALUE_REQUIRED, 'PHP binary (or its directory) to run the route with. Defaults to GF_PHP, then the PHP running gf.' ); $this->setHelp( 'Executes the route through the full framework lifecycle in a fresh PHP process, exactly like the legacy app/cli/index.php entry. Exit code is 0 on success and 1 when the response status is 400 or higher.' ); } @@ -45,16 +45,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $context = appContext::require(); $context->assertAppLoadable(); - // A missing config.json is tolerated (the child process reports it through the - // framework lifecycle), but a PRESENT config that fails to resolve must surface - // loudly here — swallowing it would silently discard the configured phpPath and - // run the route under the wrong interpreter. - $unifiedConfig = null; - if( file_exists( $context->getConfigPath() ) ) { - $unifiedConfig = $context->loadConfig(); - } - - $commandLine = array_merge( phpProcess::findPhpBinary( $input->getOption( 'php' ), $unifiedConfig ), phpProcess::requiredIniFlags() ); + $commandLine = array_merge( phpProcess::findPhpBinary( $input->getOption( 'php' ) ), phpProcess::requiredIniFlags() ); if( $input->getOption( 'debug' ) ) { $commandLine = array_merge( $commandLine, phpProcess::xdebugFlags( (string)$input->getOption( 'debug-host' ), (int)$input->getOption( 'debug-port' ) ) ); diff --git a/src/cli/commands/dbRestoreCommand.php b/src/cli/commands/dbRestoreCommand.php deleted file mode 100644 index c28e6ae..0000000 --- a/src/cli/commands/dbRestoreCommand.php +++ /dev/null @@ -1,247 +0,0 @@ -addOption( 'from', null, InputOption::VALUE_REQUIRED, 'Source environment (resolves the environments.{from} entry of config.json)', 'prod', envCommand::suggestEnvironments( ... ) ); - $this->addOption( 'to', null, InputOption::VALUE_REQUIRED, 'Target environment (resolves the environments.{to} entry of config.json). Omit to use the active configuration.', '', envCommand::suggestEnvironments( ... ) ); - $this->addOption( 'db', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Restrict to the named database(s). Repeatable. Default: every database in the source config.' ); - $this->addOption( 'dump-dir', null, InputOption::VALUE_REQUIRED, 'Directory to write the mongodump output to. Default: srv/tmp/mongodump-{timestamp}.' ); - $this->addOption( 'keep-dump', null, InputOption::VALUE_NONE, 'Keep the dump directory after a successful restore' ); - $this->addOption( 'yes', 'y', InputOption::VALUE_NONE, 'Skip the confirmation prompt' ); - $this->addOption( 'allow-prod', null, InputOption::VALUE_NONE, 'Allow restoring INTO an environment whose type is "prod" (refused otherwise)' ); - $this->setHelp( 'Cross-platform replacement for the per-app restore-live-to-local.ps1: connection strings come from config.json — the active mongoDatabases for the local side, and the environments.{name} entries (environment-prefixed variables like PROD_MONGO_URI, defined in .env) for foreign environments. Validate an environment first with `gf env `. Requires the MongoDB Database Tools (mongodump/mongorestore) on PATH.' ); - } - - - protected function execute( InputInterface $input, OutputInterface $output ): int { - $context = appContext::require(); - $io = new SymfonyStyle( $input, $output ); - - $fromVariant = (string)$input->getOption( 'from' ); - $toVariant = (string)$input->getOption( 'to' ); - if( $fromVariant==='' ) { - throw new cliException( '--from requires an environment variant name (e.g. --from=prod)' ); - } - - $sourceEnvironment = $context->loadVariantEnvironment( $fromVariant ); - $sourceDatabases = $sourceEnvironment->mongoDatabases; - - if( $toVariant==='' ) { - $activeConfig = $context->loadConfig(); - $targetType = $activeConfig->type; - $targetDatabases = $activeConfig->mongoDatabases; - } - else { - $targetEnvironment = $context->loadVariantEnvironment( $toVariant ); - $targetType = $targetEnvironment->type; - $targetDatabases = $targetEnvironment->mongoDatabases; - } - - // Guard by environment NAME as well as by type: type comes from a committed - // literal in environments.{name}, but an entry could omit it. - if( $toVariant==='prod' && !$input->getOption( 'allow-prod' ) ) { - throw new cliException( 'Refusing to restore into the environment named "prod". Pass --allow-prod if you really mean it.' ); - } - if( $targetType==='prod' && !$input->getOption( 'allow-prod' ) ) { - throw new cliException( 'Refusing to restore into an environment with type "prod" (' . $context->describeConfigSource( $toVariant ) . '). Pass --allow-prod if you really mean it.' ); - } - - $pairs = self::pairDatabases( $sourceDatabases, $targetDatabases, $input->getOption( 'db' ) ); - if( count( $pairs[ 'matched' ] )===0 ) { - throw new cliException( 'No database pairs to restore. Source environment databases: ' . implode( ', ', array_map( fn( mongoDatabase $db ) => $db->database, $sourceDatabases ) ) ); - } - - $identicalPairs = self::findIdenticalPairs( $pairs[ 'matched' ] ); - if( count( $identicalPairs )>0 ) { - [ $sourceDb ] = $identicalPairs[ 0 ]; - throw new cliException( 'Source and target resolve to the same database (' . $sourceDb->database . ' @ ' . mongoTools::redactUri( $sourceDb->uri ) . '). Check that environments.' . $fromVariant . ' in config.json references its own variables (e.g. ' . strtoupper( $fromVariant ) . '_MONGO_URI) with the right values in .env. Validate with `gf env ' . $fromVariant . '`.' ); - } - foreach( $pairs[ 'unmatched' ] as $unmatchedName ) { - $io->warning( 'Source database "' . $unmatchedName . '" has no matching database in the target config — skipped.' ); - } - - // resolve the tools before doing anything - $mongodumpBinary = mongoTools::findBinary( 'mongodump' ); - $mongorestoreBinary = mongoTools::findBinary( 'mongorestore' ); - - $io->section( 'Restore plan (' . $fromVariant . ' -> ' . ( $toVariant===''?'active configuration':$toVariant ) . ')' ); - foreach( $pairs[ 'matched' ] as [ $sourceDb, $targetDb ] ) { - $io->text( ' ' . $sourceDb->database . ' @ ' . mongoTools::redactUri( $sourceDb->uri ) . ' -> ' . $targetDb->database . ' @ ' . mongoTools::redactUri( $targetDb->uri ) . ' (--drop)' ); - } - - if( !$input->getOption( 'yes' ) && !$io->confirm( 'The target database(s) will be DROPPED and replaced. Continue?', false ) ) { - $io->text( 'Aborted. No changes made.' ); - - return Command::FAILURE; - } - - $dumpDir = (string)( $input->getOption( 'dump-dir' ) ?? '' ); - if( $dumpDir==='' ) { - $dumpDir = $context->getSrvDir() . '/tmp/mongodump-' . date( 'Ymd-His' ); - } - if( !is_dir( $dumpDir ) && !mkdir( $dumpDir, 0775, true ) ) { - throw new cliException( 'Failed to create dump directory ' . $dumpDir ); - } - - foreach( $pairs[ 'matched' ] as [ $sourceDb, $targetDb ] ) { - $io->section( 'Dumping ' . $sourceDb->database ); - $exitCode = $this->stream( new Process( self::buildDumpCommand( $mongodumpBinary, $sourceDb, $dumpDir ), $context->rootDir, null, null, null ), $output ); - if( $exitCode!==0 ) { - throw new cliException( 'mongodump exited with code ' . $exitCode . ' — aborting before restore. Dump directory: ' . $dumpDir ); - } - - $io->section( 'Restoring into ' . $targetDb->database ); - $exitCode = $this->stream( new Process( self::buildRestoreCommand( $mongorestoreBinary, $sourceDb, $targetDb, $dumpDir ), $context->rootDir, null, null, null ), $output ); - if( $exitCode!==0 ) { - throw new cliException( 'mongorestore exited with code ' . $exitCode . '. Dump directory kept for inspection: ' . $dumpDir ); - } - } - - if( $input->getOption( 'keep-dump' ) ) { - $io->text( 'Dump kept at ' . $dumpDir ); - } - else { - self::deleteDirectory( $dumpDir ); - } - - $io->success( 'Restore complete.' ); - - return Command::SUCCESS; - } - - - /** - * Pair source databases with target databases by database name; when the source or - * target has exactly one default database and no name match exists, fall back to - * pairing the two default databases. - * - * @param mongoDatabase[] $sourceDatabases - * @param mongoDatabase[] $targetDatabases - * @param string[] $onlyDatabases - * - * @return array{matched: array, unmatched: string[]} - */ - public static function pairDatabases( array $sourceDatabases, array $targetDatabases, array $onlyDatabases = [] ): array { - $matched = []; - $unmatched = []; - - $targetsByName = []; - foreach( $targetDatabases as $targetDb ) { - $targetsByName[ $targetDb->database ] = $targetDb; - } - $defaultTarget = null; - foreach( $targetDatabases as $targetDb ) { - if( $targetDb->default ) { - $defaultTarget = $targetDb; - break; - } - } - - foreach( $sourceDatabases as $sourceDb ) { - if( count( $onlyDatabases )>0 && !in_array( $sourceDb->database, $onlyDatabases, true ) ) { - continue; - } - - if( isset( $targetsByName[ $sourceDb->database ] ) ) { - $matched[] = [ $sourceDb, $targetsByName[ $sourceDb->database ] ]; - } - elseif( $sourceDb->default && $defaultTarget!==null ) { - $matched[] = [ $sourceDb, $defaultTarget ]; - } - else { - $unmatched[] = $sourceDb->database; - } - } - - return [ 'matched' => $matched, 'unmatched' => $unmatched ]; - } - - - /** - * Pairs whose source and target are the SAME database (same normalized uri AND same - * database name) — dumping and restoring onto itself is never useful and usually means - * an incomplete {variant}.env overlay fell back to the local environment's values. - * Same-cluster clones under a different database name stay legal. - * - * @param array $matchedPairs - * - * @return array - */ - public static function findIdenticalPairs( array $matchedPairs ): array { - return array_values( array_filter( $matchedPairs, function( array $pair ): bool { - [ $sourceDb, $targetDb ] = $pair; - - return rtrim( $sourceDb->uri, '/' )===rtrim( $targetDb->uri, '/' ) && $sourceDb->database===$targetDb->database; - } ) ); - } - - - /** - * @return string[] - */ - public static function buildDumpCommand( string $mongodumpBinary, mongoDatabase $sourceDb, string $dumpDir ): array { - $command = [ $mongodumpBinary, '--uri=' . $sourceDb->uri ]; - if( $sourceDb->database!=='' ) { - $command[] = '--db=' . $sourceDb->database; - } - $command[] = '--out=' . $dumpDir; - - return $command; - } - - - /** - * @return string[] - */ - public static function buildRestoreCommand( string $mongorestoreBinary, mongoDatabase $sourceDb, mongoDatabase $targetDb, string $dumpDir ): array { - $command = [ $mongorestoreBinary, '--uri=' . $targetDb->uri, '--drop' ]; - if( $sourceDb->database!=='' && $targetDb->database!=='' && $sourceDb->database!==$targetDb->database ) { - $command[] = '--nsFrom=' . $sourceDb->database . '.*'; - $command[] = '--nsTo=' . $targetDb->database . '.*'; - } - $command[] = $dumpDir . '/' . $sourceDb->database; - - return $command; - } - - - private function stream( Process $process, OutputInterface $output ): int { - $process->setTimeout( null ); - $process->run( function( string $type, string $buffer ) use ( $output ): void { - $output->write( $buffer ); - } ); - - return $process->getExitCode() ?? 1; - } - - - private static function deleteDirectory( string $directory ): void { - if( !is_dir( $directory ) ) { - return; - } - $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); - foreach( $iterator as $file ) { - /** @var \SplFileInfo $file */ - $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); - } - rmdir( $directory ); - } - -} diff --git a/src/cli/commands/dbRunCommand.php b/src/cli/commands/dbRunCommand.php index cd6813d..669468b 100644 --- a/src/cli/commands/dbRunCommand.php +++ b/src/cli/commands/dbRunCommand.php @@ -19,9 +19,8 @@ final class dbRunCommand extends Command { protected function configure(): void { $this->addArgument( 'script', InputArgument::REQUIRED, 'Path to the .js script to execute with mongosh' ); $this->addArgument( 'mongoshArgs', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Extra arguments passed through to mongosh (prefix with --)' ); - $this->addOption( 'env', null, InputOption::VALUE_REQUIRED, 'Environment to read the connection from (resolves the environments.{env} entry of config.json). Omit to use the active configuration.', '', envCommand::suggestEnvironments( ... ) ); $this->addOption( 'db', null, InputOption::VALUE_REQUIRED, 'Database name from the mongoDatabases config to run against. Default: the entry marked default (or the only entry).' ); - $this->setHelp( 'Replaces per-script mongosh invocations with hardcoded connection strings, e.g.: gf db:run db/create-admin.js --env=local. Everything after -- is forwarded to mongosh.' ); + $this->setHelp( 'Replaces per-script mongosh invocations with hardcoded connection strings, e.g.: gf db:run db/create-admin.js. Everything after -- is forwarded to mongosh.' ); } @@ -33,8 +32,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in throw new cliException( 'Script not found: ' . $scriptPath ); } - $environment = (string)$input->getOption( 'env' ); - $mongoDatabases = $environment==='' ? $context->loadConfig()->mongoDatabases : $context->loadVariantEnvironment( $environment )->mongoDatabases; + $mongoDatabases = $context->loadConfig()->mongoDatabases; $databaseName = (string)( $input->getOption( 'db' ) ?? '' ); $mongoDatabase = null; diff --git a/src/cli/commands/deployCommand.php b/src/cli/commands/deployCommand.php deleted file mode 100644 index e6efbfd..0000000 --- a/src/cli/commands/deployCommand.php +++ /dev/null @@ -1,130 +0,0 @@ -addOption( 'tag', null, InputOption::VALUE_REQUIRED, 'Tag to deploy. Omit to pick interactively from the most recent tags.' ); - $this->addOption( 'tags', null, InputOption::VALUE_REQUIRED, 'How many recent tags to offer in the interactive picker', '15' ); - $this->addOption( 'no-composer', null, InputOption::VALUE_NONE, 'Skip the composer update step' ); - $this->addOption( 'yes', 'y', InputOption::VALUE_NONE, 'Skip the confirmation prompts' ); - } - - - protected function execute( InputInterface $input, OutputInterface $output ): int { - $context = appContext::require(); - $io = new SymfonyStyle( $input, $output ); - - $gitBinary = ( new ExecutableFinder() )->find( 'git' ); - if( $gitBinary===null ) { - throw new cliException( 'git was not found on PATH.' ); - } - - $isWorkTree = $this->capture( [ $gitBinary, 'rev-parse', '--is-inside-work-tree' ], $context->rootDir ); - if( trim( $isWorkTree )!=='true' ) { - throw new cliException( $context->rootDir . ' is not a git working tree.' ); - } - - $composerBinary = null; - if( !$input->getOption( 'no-composer' ) ) { - $composerBinary = ( new ExecutableFinder() )->find( 'composer' ) ?? ( new ExecutableFinder() )->find( 'composer.phar' ); - if( $composerBinary===null ) { - throw new cliException( 'composer was not found on PATH. Install it or pass --no-composer to skip dependency updates.' ); - } - } - - $io->section( 'Fetching' ); - $this->runStep( [ $gitBinary, 'fetch', '--all', '--tags', '--prune' ], $context->rootDir, $output ); - $this->runStep( [ $gitBinary, 'pull' ], $context->rootDir, $output ); - - $tag = (string)( $input->getOption( 'tag' ) ?? '' ); - if( $tag==='' ) { - $tagList = array_values( array_filter( explode( "\n", $this->capture( [ $gitBinary, 'tag', '--sort=-creatordate' ], $context->rootDir ) ) ) ); - if( count( $tagList )===0 ) { - throw new cliException( 'No git tags found — create a release tag before deploying, or pass --tag.' ); - } - $tagList = array_slice( $tagList, 0, max( 1, (int)$input->getOption( 'tags' ) ) ); - $tag = (string)$io->choice( 'Select the tag to deploy', $tagList, $tagList[ 0 ] ); - } - - $dirtyFiles = trim( $this->capture( [ $gitBinary, 'status', '--porcelain' ], $context->rootDir ) ); - if( $dirtyFiles!=='' ) { - $io->warning( "The working tree has uncommitted changes:\n" . $dirtyFiles ); - } - - if( !$input->getOption( 'yes' ) && !$io->confirm( 'Deploy tag ' . $tag . '?', false ) ) { - $io->text( 'Aborted. No changes made.' ); - - return Command::FAILURE; - } - - $io->section( 'Checking out ' . $tag ); - $this->runStep( [ $gitBinary, 'checkout', 'tags/' . $tag ], $context->rootDir, $output ); - $io->text( '(A detached HEAD at the tag is expected.)' ); - - $io->section( 'Submodules' ); - $this->runStep( [ $gitBinary, 'submodule', 'sync', '--recursive' ], $context->rootDir, $output ); - $this->runStep( [ $gitBinary, 'submodule', 'update', '--init', '--recursive' ], $context->rootDir, $output ); - - file_put_contents( $context->rootDir . '/version.json', json_encode( [ 'version' => $tag, 'inherit' => true ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); - $io->text( 'Wrote version.json (version ' . $tag . ')' ); - - if( $composerBinary!==null ) { - $io->section( 'composer update' ); - $this->runStep( [ $composerBinary, 'update', '--no-interaction' ], $context->rootDir, $output ); - } - - $io->success( 'Deployed ' . $tag . '.' ); - - return Command::SUCCESS; - } - - - /** - * Run a step, streaming output; abort the deploy on a non-zero exit. - * - * @param string[] $commandLine - * - * @throws \gcgov\framework\cli\cliException - */ - private function runStep( array $commandLine, string $workingDirectory, OutputInterface $output ): void { - $process = new Process( $commandLine, $workingDirectory, null, null, null ); - $process->run( function( string $type, string $buffer ) use ( $output ): void { - $output->write( $buffer ); - } ); - - if( !$process->isSuccessful() ) { - throw new cliException( implode( ' ', $commandLine ) . ' exited with code ' . (string)$process->getExitCode() . ' — deploy aborted.' ); - } - } - - - /** - * @param string[] $commandLine - * - * @throws \gcgov\framework\cli\cliException - */ - private function capture( array $commandLine, string $workingDirectory ): string { - $process = new Process( $commandLine, $workingDirectory ); - $process->run(); - if( !$process->isSuccessful() ) { - throw new cliException( implode( ' ', $commandLine ) . ' failed: ' . trim( $process->getErrorOutput() ) ); - } - - return $process->getOutput(); - } - -} diff --git a/src/cli/commands/envCommand.php b/src/cli/commands/envCommand.php index 1947ef4..cf8e576 100644 --- a/src/cli/commands/envCommand.php +++ b/src/cli/commands/envCommand.php @@ -7,18 +7,35 @@ use gcgov\framework\cli\mongoTools; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Completion\CompletionInput; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -#[AsCommand( name: 'env', description: 'List config.json environments and validate that %env(...) references resolve (active config, or an environments.{name} entry)' )] +#[AsCommand( name: 'env', description: 'Validate that config.json resolves; list the variables it needs, or write a .env skeleton' )] final class envCommand extends Command { protected function configure(): void { - $this->addArgument( 'environment', InputArgument::OPTIONAL, 'environments.{name} entry of config.json to validate. Omit to list environments and check the active configuration.', null, self::suggestEnvironments( ... ) ); - $this->setHelp( 'Environment selection is environment-variable driven: the root config.json references variables with %env(...), and the process environment / {root}/.env supplies the values. This command validates that resolution. `gf env ` resolves the environments.{name} entry of config.json — the per-environment connection info used by db:restore/db:run, referencing environment-prefixed variables (e.g. PROD_MONGO_URI in .env) — and fails naming the first unresolvable variable.' ); + $this->addOption( 'list', null, InputOption::VALUE_NONE, 'Print the variables config.json references, marking which are secrets, and exit' ); + $this->addOption( 'init', null, InputOption::VALUE_NONE, 'Write a .env skeleton containing every variable config.json references' ); + $this->addOption( 'force', null, InputOption::VALUE_NONE, 'With --init, overwrite an existing .env' ); + $this->setHelp( <<<'HELP' + Configuration is one committed config.json whose environment-varying values are + %env(...) references. Every reference is required — there are no defaults — so this + command is how you find out what an environment is missing before the application does. + + gf env validate: resolve config.json against the current environment + gf env --list the variables config.json references, and which are secrets + gf env --init write a .env skeleton from that same list + + The manifest is derived from config.json rather than hand-maintained, so it cannot + drift. Note that .env also carries variables config.json knows nothing about (docker + compose ports, CORS origins); --init leaves anything already in the file alone. + + A secret reference (%env(secret:NAME)%) is satisfied either by NAME or by NAME_FILE + pointing at a provisioned file — which is how one config.json serves both a developer + machine and production. + HELP ); } @@ -26,43 +43,37 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $context = appContext::require(); $io = new SymfonyStyle( $input, $output ); - $environment = (string)( $input->getArgument( 'environment' ) ?? '' ); - - if( $environment==='' ) { - $variants = $context->getEnvironmentVariants(); - $io->text( count( $variants )===0 - ? 'No environments section in config.json (define environments.{name} with type + mongoDatabases to enable gf db:restore/db:run against other environments).' - : 'Environments defined in config.json: ' . implode( ', ', $variants ) ); - - $io->section( 'Active configuration (config.json + ambient environment)' ); - - return $this->validateActive( $context, $io ); + if( $input->getOption( 'list' ) ) { + return $this->listReferences( $context, $io ); } - $io->section( 'Environment "' . $environment . '" (' . $context->describeConfigSource( $environment ) . ')' ); + if( $input->getOption( 'init' ) ) { + return $this->writeEnvFile( $context, $io, (bool)$input->getOption( 'force' ) ); + } - return $this->validateVariant( $context, $environment, $io ); + return $this->validate( $context, $io ); } - private function validateActive( appContext $context, SymfonyStyle $io ): int { + private function validate( appContext $context, SymfonyStyle $io ): int { + $io->section( 'config.json + the current environment' ); + try { - $unifiedConfig = $context->loadConfig(); + $config = $context->loadConfig(); } catch( cliException $e ) { $io->error( $e->getMessage() ); + $io->text( 'Run `gf env --list` to see every variable config.json needs, or `gf env --init` to write a .env skeleton.' ); return Command::FAILURE; } - $io->text( 'type: ' . $unifiedConfig->type ); - if( $unifiedConfig->serverName!=='' ) { - $io->text( 'serverName: ' . $unifiedConfig->serverName ); - } - if( $unifiedConfig->rootUrl!=='' ) { - $io->text( 'rootUrl: ' . $unifiedConfig->rootUrl . ' basePath: ' . $unifiedConfig->getBasePath() ); + $io->text( 'type: ' . $config->type ); + if( $config->rootUrl!=='' ) { + $io->text( 'rootUrl: ' . $config->rootUrl . ' basePath: ' . $config->getBasePath() ); } - foreach( $unifiedConfig->mongoDatabases as $mongoDatabase ) { + $io->text( 'logging: ' . $config->logging->destination ); + foreach( $config->mongoDatabases as $mongoDatabase ) { $io->text( 'mongo: ' . $mongoDatabase->database . ' @ ' . mongoTools::redactUri( $mongoDatabase->uri ) . ( $mongoDatabase->default ? ' (default)' : '' ) ); } @@ -72,42 +83,87 @@ private function validateActive( appContext $context, SymfonyStyle $io ): int { } - private function validateVariant( appContext $context, string $environment, SymfonyStyle $io ): int { - try { - $variantEnvironment = $context->loadVariantEnvironment( $environment ); + private function listReferences( appContext $context, SymfonyStyle $io ): int { + $references = $context->configReferences(); + + if( count( $references )===0 ) { + $io->text( 'config.json references no environment variables.' ); + + return Command::SUCCESS; } - catch( cliException $e ) { - $io->error( $e->getMessage() ); - return Command::FAILURE; + $rows = []; + foreach( $references as $name => $isSecret ) { + $rows[] = [ $name, $isSecret ? 'secret' : '', $this->isSet( $name ) ? 'set' : 'MISSING' ]; } + $io->table( [ 'Variable', 'Kind', 'Current environment' ], $rows ); - $io->text( 'type: ' . $variantEnvironment->type ); - foreach( $variantEnvironment->mongoDatabases as $mongoDatabase ) { - $io->text( 'mongo: ' . $mongoDatabase->database . ' @ ' . mongoTools::redactUri( $mongoDatabase->uri ) . ( $mongoDatabase->default ? ' (default)' : '' ) ); + return Command::SUCCESS; + } + + + private function writeEnvFile( appContext $context, SymfonyStyle $io, bool $force ): int { + $envPath = $context->getEnvFilePath(); + + if( file_exists( $envPath ) && !$force ) { + throw new cliException( $envPath . ' already exists. Pass --force to overwrite it, or `gf env --list` to see what it should contain. (A .env usually holds values this command cannot know — overwriting is deliberately opt-in.)' ); } - if( $variantEnvironment->type==='' ) { - $io->warning( 'environments.' . $environment . ' has no "type" — set it to a committed literal (e.g. "prod"); the db:restore prod guard relies on it.' ); + + $references = $context->configReferences(); + $contents = $this->renderEnvFile( $references ); + + if( file_put_contents( $envPath, $contents )===false ) { + throw new cliException( 'Failed writing ' . $envPath ); } - $io->success( 'Resolved successfully — every %env(...) reference has a value.' ); + $io->success( 'Wrote ' . $envPath . ' with ' . count( $references ) . ' variable(s). Fill in the values — the application will not start until every one has one.' ); return Command::SUCCESS; } /** - * @return string[] + * @param array $references variable name => is a secret */ - public static function suggestEnvironments( CompletionInput $completionInput ): array { - try { - $context = appContext::locate(); + public function renderEnvFile( array $references ): string { + $lines = [ + '# Generated by `gf env --init` from config.json. Never commit this file.', + '#', + '# Every variable below is REQUIRED: config.json has no defaults, and a variable', + '# set to the empty string counts as unset. Re-run `gf env` to check.', + '', + ]; + + $secrets = array_keys( array_filter( $references ) ); + $plain = array_keys( array_filter( $references, static fn( bool $isSecret ): bool => !$isSecret ) ); + + foreach( $plain as $name ) { + $lines[] = $name . '='; + } - return $context===null ? [] : $context->getEnvironmentVariants(); + if( count( $secrets )>0 ) { + $lines[] = ''; + $lines[] = '# Secrets. In production these are provisioned as files and read through'; + $lines[] = '# the companion {NAME}_FILE variable instead — set one or the other, never both.'; + foreach( $secrets as $name ) { + $lines[] = $name . '='; + $lines[] = '# ' . $name . '_FILE=/run/secrets/' . strtolower( $name ); + } } - catch( \Throwable ) { - return []; + + return implode( "\n", $lines ) . "\n"; + } + + + /** Whether a variable currently has a value, by either the plain or the _FILE name. */ + private function isSet( string $name ): bool { + foreach( [ $name, $name . \gcgov\framework\services\environment\envVarResolver::SECRET_FILE_SUFFIX ] as $candidate ) { + if( ( $_ENV[ $candidate ] ?? $_SERVER[ $candidate ] ?? getenv( $candidate ) ?: '' )!=='' ) { + return true; + } } + + return false; } } diff --git a/src/cli/commands/initCommand.php b/src/cli/commands/initCommand.php new file mode 100644 index 0000000..b4c14e5 --- /dev/null +++ b/src/cli/commands/initCommand.php @@ -0,0 +1,150 @@ +addOption( 'title', null, InputOption::VALUE_REQUIRED, 'Human readable application title (e.g. "Timesheet API")' ); + $this->addOption( 'guid', null, InputOption::VALUE_REQUIRED, 'Application guid. Omit to mint one. This is the OAuth client_id, so an application being re-initialised must keep its existing value.' ); + $this->addOption( 'skip-env', null, InputOption::VALUE_NONE, 'Do not write .env' ); + $this->addOption( 'skip-keys', null, InputOption::VALUE_NONE, 'Do not generate JWT signing keys' ); + $this->addOption( 'skip-chrome', null, InputOption::VALUE_NONE, 'Do not download chrome-headless-shell' ); + $this->setHelp( <<<'HELP' + Run once after scaffolding a project from gcgov/framework-app-template. + + Deliberately non-interactive, so it can run from a scaffolding script, a devcontainer + postCreateCommand, or CI — which is where project bootstrap belongs. It replaces the + v6 `gf setup` wizard, whose prompts filled {placeholder} tokens in php.ini and + web.config files that no longer exist. + + gf init --title="Timesheet API" + + It writes the title and guid into config.json, writes a .env skeleton from the + variables config.json references, generates JWT signing keypairs, and installs + chrome-headless-shell. Everything else about an application's configuration is either + a committed literal or an environment variable you supply. + HELP ); + } + + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $context = appContext::locateScaffold(); + if( $context===null ) { + throw new cliException( 'gf init must be run from inside a scaffolded application (a directory containing composer.json and an app/ directory).' ); + } + + $io = new SymfonyStyle( $input, $output ); + $io->title( 'gcgov/framework application setup' ); + $io->text( 'Application root: ' . $context->rootDir ); + + $this->writeIdentity( $context, $io, (string)( $input->getOption( 'title' ) ?? '' ), (string)( $input->getOption( 'guid' ) ?? '' ) ); + + if( !$input->getOption( 'skip-env' ) ) { + $io->section( '.env' ); + if( file_exists( $context->getEnvFilePath() ) ) { + $io->text( 'Kept the existing .env. Run `gf env --list` to check it against config.json.' ); + } + else { + $contents = ( new envCommand() )->renderEnvFile( $context->configReferences() ); + if( file_put_contents( $context->getEnvFilePath(), $contents )===false ) { + throw new cliException( 'Failed writing ' . $context->getEnvFilePath() ); + } + $io->text( 'Wrote ' . $context->getEnvFilePath() . ' — fill in the values.' ); + } + } + + if( !$input->getOption( 'skip-keys' ) ) { + $io->section( 'JWT signing keys' ); + $this->runSubCommand( 'cert:generate-auth', [ '--yes' => true ], $output, $io ); + } + + if( !$input->getOption( 'skip-chrome' ) ) { + $io->section( 'chrome-headless-shell' ); + try { + ( new \gcgov\framework\cli\chromeInstaller( $context->getSrvDir() ) )->install( $io ); + } + catch( \Throwable $e ) { + $io->warning( 'chrome-headless-shell was not installed: ' . $e->getMessage() . ' Install it later with `vendor/bin/gf chrome:install`.' ); + } + } + + $io->success( 'Initialised. Next: fill in .env, then `gf env` to check it resolves.' ); + + return Command::SUCCESS; + } + + + /** + * Write app.title and app.guid into config.json, touching nothing else. + * + * @throws \gcgov\framework\cli\cliException + */ + private function writeIdentity( appContext $context, SymfonyStyle $io, string $title, string $guid ): void { + $configPath = $context->getConfigPath(); + if( !file_exists( $configPath ) ) { + throw new cliException( 'Missing ' . $configPath . '. Scaffold from gcgov/framework-app-template, which ships one.' ); + } + + $raw = (string)file_get_contents( $configPath ); + $decoded = json_decode( $raw, true ); + if( !is_array( $decoded ) ) { + throw new cliException( 'Failed to parse ' . $configPath . ': the file is not a valid JSON object.' ); + } + + $existingGuid = (string)( $decoded[ 'app' ][ 'guid' ] ?? '' ); + // The guid is the OAuth client_id: minting a new one for an application that already + // has one invalidates every registered client. + $decoded[ 'app' ][ 'guid' ] = $guid!=='' ? $guid : ( $existingGuid!=='' ? $existingGuid : guid::create() ); + if( $title!=='' ) { + $decoded[ 'app' ][ 'title' ] = $title; + } + + $encoded = json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + if( $encoded===false || file_put_contents( $configPath, $encoded . "\n" )===false ) { + throw new cliException( 'Failed writing ' . $configPath ); + } + + $io->section( 'Identity' ); + $io->text( 'title: ' . ( $decoded[ 'app' ][ 'title' ] ?? '' ) ); + $io->text( 'guid: ' . $decoded[ 'app' ][ 'guid' ] . ( $existingGuid!=='' && $existingGuid===$decoded[ 'app' ][ 'guid' ] ? ' (kept)' : '' ) ); + } + + + /** + * @param array $arguments + * + * @throws \gcgov\framework\cli\cliException + */ + private function runSubCommand( string $name, array $arguments, OutputInterface $output, SymfonyStyle $io ): void { + $application = $this->getApplication(); + if( $application===null ) { + return; + } + + try { + $exitCode = $application->find( $name )->run( new \Symfony\Component\Console\Input\ArrayInput( $arguments ), $output ); + } + catch( \Throwable $e ) { + $io->warning( $name . ' did not complete: ' . $e->getMessage() . ' Run `vendor/bin/gf ' . $name . '` yourself.' ); + + return; + } + + if( $exitCode!==Command::SUCCESS ) { + $io->warning( $name . ' exited with code ' . $exitCode . '. Run `vendor/bin/gf ' . $name . '` yourself.' ); + } + } + +} diff --git a/src/cli/commands/migrateCommand.php b/src/cli/commands/migrateCommand.php new file mode 100644 index 0000000..ca5256e --- /dev/null +++ b/src/cli/commands/migrateCommand.php @@ -0,0 +1,372 @@ + + */ + public const array REMOVED_KEYS = [ + 'serverName' => 'nothing read it', + 'cookieUrl' => 'nothing read it', + 'phpPath' => 'a property of a developer machine, not of the application — use GF_PHP', + 'baseUrl' => 'deprecated in v6; derived from rootUrl + basePath', + ]; + + /** + * Scalar config paths that become environment references, and the variable each uses. + * `secret` entries resolve through `%env(secret:NAME)%`, so production can supply them + * as provisioned files instead. + * + * @var array + */ + public const array EXTRACTED = [ + 'type' => [ 'var' => 'APP_TYPE', 'secret' => false ], + 'rootUrl' => [ 'var' => 'APP_ROOT_URL', 'secret' => false ], + 'basePath' => [ 'var' => 'APP_BASE_PATH', 'secret' => false ], + 'jwtAuth.redirectAfterLoginUrl' => [ 'var' => 'APP_REDIRECT_AFTER_LOGIN', 'secret' => false ], + 'jwtAuth.redirectAfterLogoutUrl' => [ 'var' => 'APP_REDIRECT_AFTER_LOGOUT', 'secret' => false ], + 'microsoft.clientId' => [ 'var' => 'MICROSOFT_CLIENT_ID', 'secret' => false ], + 'microsoft.clientSecret' => [ 'var' => 'MICROSOFT_CLIENT_SECRET', 'secret' => true ], + 'microsoft.tenant' => [ 'var' => 'MICROSOFT_TENANT', 'secret' => false ], + 'microsoft.driveId' => [ 'var' => 'MICROSOFT_DRIVE_ID', 'secret' => false ], + 'payjunction.username' => [ 'var' => 'PAYJUNCTION_USERNAME', 'secret' => false ], + 'payjunction.password' => [ 'var' => 'PAYJUNCTION_PASSWORD', 'secret' => true ], + 'payjunction.apiKey' => [ 'var' => 'PAYJUNCTION_API_KEY', 'secret' => true ], + 'email.SMTPUsername' => [ 'var' => 'SMTP_USERNAME', 'secret' => false ], + 'email.SMTPPassword' => [ 'var' => 'SMTP_PASSWORD', 'secret' => true ], + ]; + + /** Files that only made sense under IIS or the v6 config layout. */ + public const array DEAD_PATHS = [ + 'app/config/app.json', + 'app/config/environment.json', + 'app/cli/local.bat', + 'app/cli/local-debug.bat', + 'app/cli/prod.bat', + 'update-production.ps1', + 'scripts/setup.ps1', + 'scripts/create-jwt-keys.ps1', + 'www/web-local.config', + 'www/web-prod.config', + 'composer-local.json', + 'composer-prod.json', + ]; + + + protected function configure(): void { + $this->addOption( 'dry-run', null, InputOption::VALUE_NONE, 'Show what would change without writing anything' ); + $this->addOption( 'force', null, InputOption::VALUE_NONE, 'Proceed even though config.json already exists (it will be overwritten)' ); + $this->addOption( 'keep-dead-files', null, InputOption::VALUE_NONE, 'Leave the v6 IIS/batch/config files in place' ); + $this->setHelp( <<<'HELP' + Converts the configuration half of a v6 application to v7. Run it on a clean working + tree so the result is reviewable as a diff, and read that diff before committing. + + gf migrate --dry-run see the plan + gf migrate apply it + + It writes {root}/config.json, writes {root}/.env with the values it extracted, and + deletes the v6 IIS and batch files. It does NOT write a Dockerfile, choose a Zone, + or decide what belongs in your secret store — those need judgement, and the + companion skill covers them. + + Anything it cannot convert safely is reported rather than guessed at. + HELP ); + } + + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $context = appContext::locateScaffold(); + if( $context===null ) { + throw new cliException( 'gf migrate must be run from inside an application (a directory containing composer.json and an app/ directory).' ); + } + + $io = new SymfonyStyle( $input, $output ); + $dryRun = (bool)$input->getOption( 'dry-run' ); + + $appJsonPath = $context->rootDir . '/app/config/app.json'; + $environmentJsonPath = $context->rootDir . '/app/config/environment.json'; + if( !file_exists( $appJsonPath ) && !file_exists( $environmentJsonPath ) ) { + throw new cliException( 'Neither app/config/app.json nor app/config/environment.json exists — this does not look like a v6 application. Nothing to migrate.' ); + } + + if( file_exists( $context->getConfigPath() ) && !$input->getOption( 'force' ) && !$dryRun ) { + throw new cliException( $context->getConfigPath() . ' already exists — this application appears to be migrated. Pass --force to overwrite it.' ); + } + + $plan = self::plan( + self::readJson( $appJsonPath ), + self::readJson( $environmentJsonPath ) + ); + + $io->title( 'v6 → v7 migration' . ( $dryRun ? ' (dry run)' : '' ) ); + + $io->section( 'config.json' ); + $io->text( 'Extracted ' . count( $plan[ 'env' ] ) . ' value(s) into environment references.' ); + + $io->section( '.env' ); + foreach( $plan[ 'env' ] as $name => $value ) { + $io->text( ' ' . $name . '=' . ( $plan[ 'secrets' ][ $name ] ?? false ? '•••••••• (secret — provision as a file in production)' : $value ) ); + } + + if( count( $plan[ 'warnings' ] )>0 ) { + $io->section( 'Review these yourself' ); + foreach( $plan[ 'warnings' ] as $warning ) { + $io->text( ' · ' . $warning ); + } + } + + $deadFiles = self::deadFilesPresent( $context->rootDir ); + if( count( $deadFiles )>0 && !$input->getOption( 'keep-dead-files' ) ) { + $io->section( 'Deleting' ); + foreach( $deadFiles as $deadFile ) { + $io->text( ' ' . $deadFile ); + } + } + + if( $dryRun ) { + $io->note( 'Dry run — nothing was written.' ); + + return Command::SUCCESS; + } + + $encoded = json_encode( $plan[ 'config' ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + if( $encoded===false || file_put_contents( $context->getConfigPath(), $encoded . "\n" )===false ) { + throw new cliException( 'Failed writing ' . $context->getConfigPath() ); + } + + $this->writeEnvFile( $context->getEnvFilePath(), $plan[ 'env' ], $plan[ 'secrets' ] ); + + if( !$input->getOption( 'keep-dead-files' ) ) { + foreach( $deadFiles as $deadFile ) { + @unlink( $context->rootDir . '/' . $deadFile ); + } + } + + $io->success( 'Migrated. Review the diff, then run `gf env` to confirm the configuration resolves.' ); + $io->text( 'Still to do by hand: the Dockerfile and compose entry, the Zone this application belongs in, and moving its secrets into the ops repository.' ); + + return Command::SUCCESS; + } + + + /** + * The whole conversion, as a pure function of the two v6 documents — which is what + * makes it testable against all thirty applications without touching a filesystem. + * + * @param array $appJson Decoded app/config/app.json + * @param array $environmentJson Decoded app/config/environment.json + * + * @return array{config: array, env: array, secrets: array, warnings: string[]} + */ + public static function plan( array $appJson, array $environmentJson ): array { + $config = $environmentJson; + $env = []; + $secrets = []; + $warnings = []; + + // app.json's three sections move in wholesale — they never varied by environment. + foreach( [ 'app', 'email', 'settings' ] as $section ) { + if( isset( $appJson[ $section ] ) && is_array( $appJson[ $section ] ) ) { + $config[ $section ] = array_merge( $config[ $section ] ?? [], $appJson[ $section ] ); + } + } + + foreach( self::REMOVED_KEYS as $key => $reason ) { + if( array_key_exists( $key, $config ) ) { + unset( $config[ $key ] ); + $warnings[] = 'Dropped "' . $key . '" (' . $reason . '). Grep the application for it before committing.'; + } + } + + foreach( self::EXTRACTED as $path => $extraction ) { + $value = self::readPath( $config, $path ); + if( $value===null || !is_scalar( $value ) || (string)$value==='' ) { + continue; + } + $env[ $extraction[ 'var' ] ] = (string)$value; + $secrets[ $extraction[ 'var' ] ] = $extraction[ 'secret' ]; + self::writePath( $config, $path, self::reference( $extraction[ 'var' ], $extraction[ 'secret' ] ) ); + } + + // Mongo connections: one variable pair per database, suffixed past the first so + // an application with several keeps them distinct. + $databases = $config[ 'mongoDatabases' ] ?? []; + if( is_array( $databases ) ) { + foreach( array_values( $databases ) as $index => $database ) { + if( !is_array( $database ) ) { + continue; + } + $suffix = $index===0 ? '' : '_' . ( $index + 1 ); + foreach( [ 'uri' => [ 'MONGO_URI' . $suffix, true ], 'database' => [ 'MONGO_DATABASE' . $suffix, false ] ] as $key => [ $varName, $isSecret ] ) { + if( !isset( $database[ $key ] ) || !is_scalar( $database[ $key ] ) || (string)$database[ $key ]==='' ) { + continue; + } + $env[ $varName ] = (string)$database[ $key ]; + $secrets[ $varName ] = $isSecret; + $config[ 'mongoDatabases' ][ $index ][ $key ] = self::reference( $varName, $isSecret ); + } + } + } + + if( isset( $config[ 'sqlDatabases' ] ) && is_array( $config[ 'sqlDatabases' ] ) && count( $config[ 'sqlDatabases' ] )>0 ) { + $warnings[] = 'sqlDatabases was left as-is: its read/write accounts hold credentials that must become %env(secret:...) references and move to the ops repository. Convert them by hand.'; + } + + // Logging: v6 had no destination and always wrote files. Say so explicitly rather + // than letting an IIS application silently change behaviour on upgrade. + $config[ 'logging' ] = is_array( $config[ 'logging' ] ?? null ) ? $config[ 'logging' ] : []; + $config[ 'logging' ][ 'destination' ] = 'file'; + $warnings[] = 'logging.destination was set to "file" to preserve v6 behaviour. Change it to "stderr" when this application moves into a container — a container filesystem does not survive a deploy.'; + + if( ( $config[ 'app' ][ 'guid' ] ?? '' )==='' ) { + $warnings[] = 'app.guid is empty. The oauth server uses it as the OAuth client_id, so set it before deploying.'; + } + + ksort( $env ); + + return [ 'config' => $config, 'env' => $env, 'secrets' => $secrets, 'warnings' => $warnings ]; + } + + + public static function reference( string $varName, bool $isSecret ): string { + return '%env(' . ( $isSecret ? 'secret:' : '' ) . $varName . ')%'; + } + + + /** + * @return string[] Dead v6 files that actually exist, relative to the root + */ + public static function deadFilesPresent( string $rootDir ): array { + $present = []; + foreach( self::DEAD_PATHS as $path ) { + if( file_exists( $rootDir . '/' . $path ) ) { + $present[] = $path; + } + } + // Every environment-{variant}.json, whatever the variant is called. + foreach( glob( $rootDir . '/app/config/environment-*.json' ) ?: [] as $variantFile ) { + $present[] = 'app/config/' . basename( $variantFile ); + } + + return $present; + } + + + /** + * @param array $data + * + * @return mixed + */ + private static function readPath( array $data, string $path ): mixed { + $cursor = $data; + foreach( explode( '.', $path ) as $segment ) { + if( !is_array( $cursor ) || !array_key_exists( $segment, $cursor ) ) { + return null; + } + $cursor = $cursor[ $segment ]; + } + + return $cursor; + } + + + /** + * @param array $data + */ + private static function writePath( array &$data, string $path, string $value ): void { + $segments = explode( '.', $path ); + $cursor = &$data; + foreach( $segments as $index => $segment ) { + if( $index===count( $segments ) - 1 ) { + $cursor[ $segment ] = $value; + break; + } + if( !isset( $cursor[ $segment ] ) || !is_array( $cursor[ $segment ] ) ) { + return; + } + $cursor = &$cursor[ $segment ]; + } + } + + + /** + * @return array + * @throws \gcgov\framework\cli\cliException + */ + private static function readJson( string $path ): array { + if( !file_exists( $path ) ) { + return []; + } + $decoded = json_decode( (string)file_get_contents( $path ), true ); + if( !is_array( $decoded ) ) { + throw new cliException( 'Failed to parse ' . $path . ': the file is not a valid JSON object.' ); + } + + return $decoded; + } + + + /** + * @param array $env + * @param array $secrets + * + * @throws \gcgov\framework\cli\cliException + */ + private function writeEnvFile( string $path, array $env, array $secrets ): void { + $lines = [ + '# Written by `gf migrate` from the v6 configuration. Never commit this file.', + '#', + '# Values marked as secrets below are provisioned as files in production and read', + '# through the companion {NAME}_FILE variable — see the ops repository.', + '', + ]; + foreach( $env as $name => $value ) { + if( $secrets[ $name ] ?? false ) { + $lines[] = '# secret'; + } + $lines[] = $name . '=' . $value; + } + + if( file_exists( $path ) ) { + $lines[] = ''; + $lines[] = '# --- appended by gf migrate; the pre-existing contents are above ---'; + $existing = (string)file_get_contents( $path ); + if( file_put_contents( $path, $existing . "\n" . implode( "\n", $lines ) . "\n" )===false ) { + throw new cliException( 'Failed appending to ' . $path ); + } + + return; + } + + if( file_put_contents( $path, implode( "\n", $lines ) . "\n" )===false ) { + throw new cliException( 'Failed writing ' . $path ); + } + } + +} diff --git a/src/cli/commands/setupCommand.php b/src/cli/commands/setupCommand.php deleted file mode 100644 index 9fada84..0000000 --- a/src/cli/commands/setupCommand.php +++ /dev/null @@ -1,249 +0,0 @@ - prompt key => label */ - private const array PROMPTS = [ - 'app_title' => 'Human readable title of application (ex: Timesheet API)', - 'app_root_url' => 'DEV Root url of app (ex: https://local-app.garrettcountymd.gov/)', - 'app_base_path' => 'DEV Base url path of app (ex: /api/, Or: / if site is at url root)', - 'app_frontend_root_url' => 'DEV If using this app as an API and you have a separate frontend, enter the root of the frontend app (ex: https://localhost:8080/)', - 'app_redirect_after_login' => 'DEV If appConfig.enableAuthRoutes==true, user will be redirected to this url after successful login (ex: https://localhost:8080/auth/sign-in)', - 'app_redirect_after_logout' => 'DEV If appConfig.enableAuthRoutes==true, user will be redirected to this url after successful logout (ex: https://localhost:8080/auth/sign-out)', - 'app_smtp_server' => 'DEV SMTP server address (ex: tenant-com.mail.protection.outlook.com)', - 'app_smtp_sendmail_from_address' => 'DEV Default email address to send emails from (ex: noreply@tenant.com)', - 'app_smtp_sendmail_from_name' => 'DEV Default human-readable name that will appear as the sender of emails (ex: Tenant Company)', - 'app_ssl_path' => 'DEV Absolute path to a current cacert.pem file for CURL and OpenSSL extensions (path only)', - 'app_php_path' => 'DEV Absolute path to the PHP executable root directory', - 'prod_app_root_url' => 'PROD Root url of app (ex: https://app.garrettcountymd.gov/)', - 'prod_app_base_path' => 'PROD Base url path of app (ex: /api/, Or: / if site is at url root)', - 'prod_app_frontend_root_url' => 'PROD If using this app as an API and you have a separate frontend, enter the root of the frontend app (ex: https://app.garrettcountymd.gov/)', - 'prod_app_redirect_after_login' => 'PROD If appConfig.enableAuthRoutes==true, user will be redirected to this url after successful login (ex: https://app.garrettcountymd.gov/app/auth/sign-in)', - 'prod_app_redirect_after_logout' => 'PROD If appConfig.enableAuthRoutes==true, user will be redirected to this url after successful logout (ex: https://app.garrettcountymd.gov/app/auth/sign-out)', - 'prod_app_absolute_path' => 'PROD Production absolute path to root directory (ex: E:\Web\api)', - 'prod_app_ssl_path' => 'PROD Absolute path to a current cacert.pem file for CURL and OpenSSL extensions (path only)', - 'prod_app_php_path' => 'PROD Absolute path to the PHP executable root directory', - ]; - - /** @var array */ - private const array MICROSOFT_PROMPTS = [ - 'app_microsoft_client_id' => 'DEV Microsoft Azure App client id', - 'app_microsoft_client_secret' => 'DEV Microsoft Azure App client secret', - 'app_microsoft_tenant' => 'DEV Microsoft Azure App tenant', - 'app_microsoft_drive_id' => 'DEV Microsoft Azure App drive id', - 'app_microsoft_default_from_address' => 'DEV Microsoft Azure App default from address', - 'prod_app_microsoft_client_id' => 'PROD Microsoft Azure App client id', - 'prod_app_microsoft_client_secret' => 'PROD Microsoft Azure App client secret', - 'prod_app_microsoft_tenant' => 'PROD Microsoft Azure App tenant', - 'prod_app_microsoft_drive_id' => 'PROD Microsoft Azure App drive id', - 'prod_app_microsoft_default_from_address' => 'PROD Microsoft Azure App default from address', - ]; - - - protected function configure(): void { - $this->addOption( 'skip-chrome', null, \Symfony\Component\Console\Input\InputOption::VALUE_NONE, 'Skip downloading chrome-headless-shell' ); - $this->setHelp( 'Run once after scaffolding a project from gcgov/framework-app-template. Prompts for the project configuration values and replaces the {placeholder} tokens across the project files. Press enter at any prompt to skip that value (the token stays in place for a later re-run). Also downloads chrome-headless-shell into srv/chrome (skip with --skip-chrome).' ); - } - - - protected function execute( InputInterface $input, OutputInterface $output ): int { - if( !$input->isInteractive() ) { - throw new cliException( 'gf setup is interactive — run it from a terminal without --no-interaction.' ); - } - - $context = appContext::locateScaffold(); - if( $context===null ) { - throw new cliException( 'gf setup must be run from inside a scaffolded application (a directory containing composer.json and an app/ directory).' ); - } - - $io = new SymfonyStyle( $input, $output ); - $io->title( 'gcgov/framework application setup' ); - $io->text( [ 'Application root: ' . $context->rootDir, 'To skip replacing a value, press enter.', '' ] ); - - // Only prompt for values whose {token} actually appears in the project tree, so - // templates that no longer carry a token (e.g. the prod_* config set) stop asking - // for it. The haystack (one full tree read) is built once and shared. - $haystack = self::buildTokenHaystack( $context->rootDir ); - $prompts = self::filterPromptsToPresentTokens( self::PROMPTS, $context->rootDir, $haystack ); - $microsoftPrompts = self::filterPromptsToPresentTokens( self::MICROSOFT_PROMPTS, $context->rootDir, $haystack ); - if( count( $prompts )===0 && count( $microsoftPrompts )===0 ) { - $io->text( 'No {placeholder} tokens found in the project — it appears to be already set up.' ); - } - if( count( $microsoftPrompts )>0 && $io->confirm( 'Do you want to define Microsoft Azure App ids during set up?', false ) ) { - $prompts = array_merge( $prompts, $microsoftPrompts ); - } - elseif( count( $microsoftPrompts )>0 && count( $prompts )===0 ) { - $io->text( 'Skipping the Microsoft Azure prompts — their {tokens} remain in place for a later re-run. No other tokens to replace.' ); - } - - $inputs = []; - foreach( $prompts as $key => $label ) { - $inputs[ $key ] = (string)( $io->ask( $label ) ?? '' ); - } - - // review/edit loop - while( count( $prompts )>0 ) { - $io->section( 'Review' ); - $index = 1; - $keysByIndex = []; - foreach( $prompts as $key => $label ) { - $io->text( $index . '. ' . $key . ': ' . $inputs[ $key ] ); - $keysByIndex[ $index ] = $key; - $index++; - } - - $selection = (int)( $io->ask( 'Enter the number of a value to edit, or 0 to confirm all', '0' ) ?? '0' ); - if( $selection===0 ) { - break; - } - if( isset( $keysByIndex[ $selection ] ) ) { - $key = $keysByIndex[ $selection ]; - $inputs[ $key ] = (string)( $io->ask( 'Enter the new value for ' . $key ) ?? '' ); - } - else { - $io->error( 'Invalid selection. Enter a number between 1 and ' . count( $prompts ) . ', or 0 to finish.' ); - } - } - - $replacements = $this->buildReplacementTable( $inputs, $context->rootDir ); - $modifiedFiles = tokenReplacer::replaceInTree( $context->rootDir, $replacements ); - - if( count( $modifiedFiles )===0 ) { - $io->text( 'No files contained tokens to replace. (Already set up, or all values were skipped.)' ); - } - else { - $io->section( 'Updated files' ); - foreach( $modifiedFiles as $file ) { - $io->text( ' ' . $file ); - } - } - - if( !$input->getOption( 'skip-chrome' ) ) { - $io->section( 'chrome-headless-shell' ); - try { - ( new \gcgov\framework\cli\chromeInstaller( $context->getSrvDir() ) )->install( $io ); - } - catch( \Throwable $e ) { - $io->warning( 'chrome-headless-shell was not installed: ' . $e->getMessage() . ' You can install it later with `vendor/bin/gf chrome:install`.' ); - } - } - - $io->success( 'Setup complete. Next: `cp .env.example .env`, then `gf cert:generate-auth`.' ); - - return Command::SUCCESS; - } - - - /** - * The {token}s a prompt key feeds. Most keys map 1:1; the base-path prompts also - * produce the derived relative-url token (see buildReplacementTable()). - * - * @return string[] - */ - public static function tokensForPromptKey( string $key ): array { - return match ( $key ) { - 'app_base_path' => [ '{app_base_path}', '{app_relative_url}' ], - 'prod_app_base_path' => [ '{prod_app_base_path}', '{prod_app_relative_url}' ], - default => [ '{' . $key . '}' ], - }; - } - - - /** - * Concatenated contents of every token-eligible file — the haystack prompt - * filtering searches. Build it once per run and pass it to each - * filterPromptsToPresentTokens() call (the tree walk + reads are not cheap). - */ - public static function buildTokenHaystack( string $rootDir ): string { - $haystack = ''; - foreach( tokenReplacer::findEligibleFiles( $rootDir ) as $filePath ) { - $contents = file_get_contents( $filePath ); - if( $contents!==false ) { - $haystack .= $contents; - } - } - - return $haystack; - } - - - /** - * Keep only the prompts whose token(s) actually appear somewhere in the project's - * token-eligible files, so setup never asks for values it cannot place. - * - * @param array $prompts prompt key => label - * @param ?string $haystack pass buildTokenHaystack() when filtering multiple sets - * - * @return array - */ - public static function filterPromptsToPresentTokens( array $prompts, string $rootDir, ?string $haystack = null ): array { - $haystack ??= self::buildTokenHaystack( $rootDir ); - - return array_filter( $prompts, function( string $key ) use ( $haystack ): bool { - foreach( self::tokensForPromptKey( $key ) as $token ) { - if( str_contains( $haystack, $token ) ) { - return true; - } - } - - return false; - }, ARRAY_FILTER_USE_KEY ); - } - - - /** - * @param array $inputs - * - * @return array token => replacement value (empty values are dropped by tokenReplacer) - */ - public function buildReplacementTable( array $inputs, string $rootDir ): array { - $value = fn( string $key ): string => $inputs[ $key ] ?? ''; - $trimmedValue = fn( string $key ): string => rtrim( $value( $key ), '/\\' ); - - $replacements = [ - '{app_guid}' => guid::create(), - '{app_title}' => $value( 'app_title' ), - '{app_root_url}' => $trimmedValue( 'app_root_url' ), - '{app_base_path}' => $value( 'app_base_path' )==='' ? '' : tokenReplacer::formatRelativeUrl( $value( 'app_base_path' ) ), - '{app_relative_url}' => $value( 'app_base_path' )==='' ? '' : tokenReplacer::formatRelativeUrl( $value( 'app_base_path' ), true, false ), - '{app_frontend_root_url}' => $trimmedValue( 'app_frontend_root_url' ), - '{app_redirect_after_login}' => $value( 'app_redirect_after_login' ), - '{app_redirect_after_logout}' => $value( 'app_redirect_after_logout' ), - '{app_absolute_path}' => rtrim( $rootDir, '/\\' ), - '{app_smtp_server}' => $value( 'app_smtp_server' ), - '{app_smtp_sendmail_from_address}' => $value( 'app_smtp_sendmail_from_address' ), - '{app_smtp_sendmail_from_name}' => $value( 'app_smtp_sendmail_from_name' ), - '{app_ssl_path}' => $trimmedValue( 'app_ssl_path' ), - '{app_php_path}' => $trimmedValue( 'app_php_path' ), - '{prod_app_root_url}' => $trimmedValue( 'prod_app_root_url' ), - '{prod_app_base_path}' => $value( 'prod_app_base_path' )==='' ? '' : tokenReplacer::formatRelativeUrl( $value( 'prod_app_base_path' ) ), - '{prod_app_relative_url}' => $value( 'prod_app_base_path' )==='' ? '' : tokenReplacer::formatRelativeUrl( $value( 'prod_app_base_path' ), true, false ), - '{prod_app_frontend_root_url}' => $trimmedValue( 'prod_app_frontend_root_url' ), - '{prod_app_redirect_after_login}' => $value( 'prod_app_redirect_after_login' ), - '{prod_app_redirect_after_logout}' => $value( 'prod_app_redirect_after_logout' ), - '{prod_app_absolute_path}' => $trimmedValue( 'prod_app_absolute_path' ), - '{prod_app_ssl_path}' => $trimmedValue( 'prod_app_ssl_path' ), - '{prod_app_php_path}' => $trimmedValue( 'prod_app_php_path' ), - ]; - - foreach( array_keys( self::MICROSOFT_PROMPTS ) as $microsoftKey ) { - $replacements[ '{' . $microsoftKey . '}' ] = $value( $microsoftKey ); - } - - return $replacements; - } - -} diff --git a/src/cli/phpProcess.php b/src/cli/phpProcess.php index 73256c6..58c6365 100644 --- a/src/cli/phpProcess.php +++ b/src/cli/phpProcess.php @@ -2,7 +2,6 @@ namespace gcgov\framework\cli; -use gcgov\framework\models\unifiedConfig; use Symfony\Component\Process\PhpExecutableFinder; /** @@ -25,9 +24,10 @@ final class phpProcess { * Priority: * 1. --php option * 2. GF_PHP environment variable - * 3. config.json phpPath (a directory per the setup convention — php/php.exe appended; - * a full binary path, optionally followed by CLI arguments, is also accepted) - * 4. Symfony PhpExecutableFinder / PHP_BINARY (the interpreter running gf) + * 3. Symfony PhpExecutableFinder / PHP_BINARY (the interpreter running gf) + * + * The interpreter is a property of the machine, not of the application, so it is + * deliberately not configurable in the committed config.json. * * Sources 1-3 may include trailing arguments after the binary, e.g. * `C:\path\php.exe -c C:\path\php.ini` — the binary and each argument become separate @@ -40,7 +40,7 @@ final class phpProcess { * @return string[] Command array — first element is the binary, remaining elements are arguments. * @throws \gcgov\framework\cli\cliException */ - public static function findPhpBinary( ?string $optionValue = null, ?unifiedConfig $unifiedConfig = null ): array { + public static function findPhpBinary( ?string $optionValue = null ): array { $candidates = []; if( $optionValue!==null && $optionValue!=='' ) { @@ -52,10 +52,6 @@ public static function findPhpBinary( ?string $optionValue = null, ?unifiedConfi $candidates[ $envValue ] = 'GF_PHP environment variable'; } - if( $unifiedConfig!==null && $unifiedConfig->phpPath!=='' ) { - $candidates[ $unifiedConfig->phpPath ] = 'config.json phpPath'; - } - foreach( $candidates as $candidate => $sourceDescription ) { $resolved = self::resolveBinary( (string)$candidate ); if( $resolved!==null ) { @@ -87,7 +83,7 @@ public static function requiredIniFlags(): array { /** * Ensure the resolved command runs the CLI interpreter. php-cgi/php-fpm/php-win are - * silently swapped for the php/php.exe beside them (a phpPath copied from an IIS FastCGI + * silently swapped for the php/php.exe beside them (a path copied from an IIS FastCGI * handler mapping is the common case); when no CLI binary is there, fail with an * actionable message instead of letting the child process die on undefined $argv/STDERR. * @@ -109,7 +105,7 @@ private static function requireCliBinary( array $command, string $sourceDescript return $command; } - throw new cliException( 'PHP binary from ' . $sourceDescription . ' is not the CLI interpreter: ' . $binary . '. gf runs application code through the PHP CLI binary (php/php.exe) — php-cgi, php-fpm, and php-win cannot run CLI routes ($argv and STDERR are unavailable there). No CLI binary was found beside it, so point --php, GF_PHP, or config.json phpPath at php.exe or the directory containing it.' ); + throw new cliException( 'PHP binary from ' . $sourceDescription . ' is not the CLI interpreter: ' . $binary . '. gf runs application code through the PHP CLI binary (php/php.exe) — php-cgi, php-fpm, and php-win cannot run CLI routes ($argv and STDERR are unavailable there). No CLI binary was found beside it, so point --php or GF_PHP at php.exe or the directory containing it.' ); } diff --git a/src/cli/tokenReplacer.php b/src/cli/tokenReplacer.php deleted file mode 100644 index 35a8983..0000000 --- a/src/cli/tokenReplacer.php +++ /dev/null @@ -1,113 +0,0 @@ - $replacements token => value, e.g. '{app_title}' => 'Timesheet API' - * - * @return string[] Paths of files that were modified - */ - public static function replaceInTree( string $rootDir, array $replacements ): array { - $replacements = array_filter( $replacements, fn( string $value ) => $value!=='' ); - if( count( $replacements )===0 ) { - return []; - } - - $modifiedFiles = []; - - foreach( self::findEligibleFiles( $rootDir ) as $filePath ) { - $contents = file_get_contents( $filePath ); - if( $contents===false ) { - continue; - } - - $isJson = strtolower( pathinfo( $filePath, PATHINFO_EXTENSION ) )==='json'; - $newContents = $contents; - - foreach( $replacements as $token => $value ) { - $replacementValue = $isJson ? str_replace( '\\', '\\\\', $value ) : $value; - $newContents = str_replace( $token, $replacementValue, $newContents ); - } - - if( $newContents!==$contents ) { - file_put_contents( $filePath, $newContents ); - $modifiedFiles[] = $filePath; - } - } - - return $modifiedFiles; - } - - - /** - * @return string[] - */ - public static function findEligibleFiles( string $rootDir ): array { - $rootDir = rtrim( str_replace( '\\', '/', $rootDir ), '/' ); - if( !is_dir( $rootDir ) ) { - return []; - } - - $directoryIterator = new \RecursiveDirectoryIterator( $rootDir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS ); - $filterIterator = new \RecursiveCallbackFilterIterator( $directoryIterator, function( \SplFileInfo $file ): bool { - if( $file->isDir() ) { - return !in_array( $file->getFilename(), self::EXCLUDED_DIRECTORIES, true ); - } - - return in_array( strtolower( $file->getExtension() ), self::EXTENSIONS, true ); - } ); - - $files = []; - foreach( new \RecursiveIteratorIterator( $filterIterator ) as $file ) { - /** @var \SplFileInfo $file */ - $files[] = $file->getPathname(); - } - sort( $files ); - - return $files; - } - - - /** - * Normalize a url path segment: '/api/' style with configurable leading/trailing slashes. - * Port of setup.ps1's FormatRelativeUrl. - */ - public static function formatRelativeUrl( string $path, bool $trailingSlash = true, bool $leadingSlash = true ): string { - $path = trim( $path ); - $path = str_replace( '\\', '/', $path ); - $path = trim( $path, '/' ); - - if( $path==='' ) { - return $leadingSlash || $trailingSlash ? '/' : ''; - } - - if( $trailingSlash ) { - $path .= '/'; - } - if( $leadingSlash ) { - $path = '/' . $path; - } - - return (string)preg_replace( '#//+#', '/', $path ); - } - -} diff --git a/src/config.php b/src/config.php index 668274b..134b22d 100644 --- a/src/config.php +++ b/src/config.php @@ -227,12 +227,6 @@ public static function isLocal(): bool { } - /** @throws \gcgov\framework\exceptions\configException */ - public static function getServerName(): string { - return self::unifiedConfig()->serverName; - } - - /** Normalized (no trailing slash). @throws \gcgov\framework\exceptions\configException */ public static function getRootUrl(): string { return self::unifiedConfig()->getRootUrl(); @@ -251,18 +245,6 @@ public static function getBasePath(): string { } - /** @throws \gcgov\framework\exceptions\configException */ - public static function getCookieUrl(): string { - return self::unifiedConfig()->cookieUrl; - } - - - /** @throws \gcgov\framework\exceptions\configException */ - public static function getPhpPath(): string { - return self::unifiedConfig()->phpPath; - } - - /** @throws \gcgov\framework\exceptions\configException */ public static function getLogging(): logging { return self::unifiedConfig()->logging; @@ -311,6 +293,31 @@ public static function getJwtAuth(): jwtAuth { } + /** Token issuer, defaulting to the application's root url. @throws \gcgov\framework\exceptions\configException */ + public static function getTokenIssuedBy(): string { + return self::unifiedConfig()->getTokenIssuedBy(); + } + + + /** Token audience, defaulting to the application's base path. @throws \gcgov\framework\exceptions\configException */ + public static function getTokenPermittedFor(): string { + return self::unifiedConfig()->getTokenPermittedFor(); + } + + + /** + * Directory holding the JWT signing keypairs — the configured jwtAuth.keyPath, or + * the default {root}/srv/jwtCertificates. Always returned with a trailing slash. + * + * @throws \gcgov\framework\exceptions\configException + */ + public static function getJwtKeyPath(): string { + $configured = trim( self::unifiedConfig()->jwtAuth->keyPath ); + + return rtrim( $configured!=='' ? str_replace( '\\', '/', $configured ) : self::getSrvDir() . 'jwtCertificates', '/' ) . '/'; + } + + /** @throws \gcgov\framework\exceptions\configException */ public static function getPayjunction(): payjunction { return self::unifiedConfig()->payjunction; diff --git a/src/models/config/environment/jwtAuth.php b/src/models/config/environment/jwtAuth.php index 065ed0c..e896580 100644 --- a/src/models/config/environment/jwtAuth.php +++ b/src/models/config/environment/jwtAuth.php @@ -2,18 +2,32 @@ namespace gcgov\framework\models\config\environment; -use gcgov\framework\exceptions\configException; - class jwtAuth extends \andrewsauder\jsonDeserialize\jsonDeserialize { + /** + * Token issuer. Leave empty to derive from the application's rootUrl — they are the + * same value in every deployment we have, and configuring both invites them to drift. + */ public string $tokenIssuedBy = ""; + /** Token audience. Leave empty to derive from the application's basePath. */ public string $tokenPermittedFor = ""; public string $redirectAfterLoginUrl = ""; public string $redirectAfterLogoutUrl = ""; + /** + * Directory holding the RSA signing keypairs and guids.json. + * + * Empty means the default `{root}/srv/jwtCertificates`, which is where + * `gf cert:generate-auth` writes them. Containers must point this at a + * provisioned, read-only location (e.g. /run/secrets/jwt): the keys are + * secrets, they are gitignored so they are never in a built image, and every + * replica has to sign with the same set. + */ + public string $keyPath = ""; + public function __construct() { } diff --git a/src/models/config/environment/logging.php b/src/models/config/environment/logging.php index 748fec4..d34d2ee 100644 --- a/src/models/config/environment/logging.php +++ b/src/models/config/environment/logging.php @@ -4,9 +4,39 @@ class logging extends \andrewsauder\jsonDeserialize\jsonDeserialize { - public bool $lifecycle = false; - public bool $renderer = false; + /** Write log records to stderr as JSON lines — the default, and what a container needs. */ + public const string DESTINATION_STDERR = 'stderr'; + + /** Write log records to {root}/logs/{channel}.log, as v6 did. */ + public const string DESTINATION_FILE = 'file'; + + /** Both of the above. */ + public const string DESTINATION_BOTH = 'both'; + + public bool $lifecycle = false; + + public bool $renderer = false; + + /** + * Where log records go: 'stderr' (default), 'file', or 'both'. + * + * stderr is the default because a container's filesystem does not survive a + * deploy — file logs would be per-replica and destroyed on every release. + * Applications still hosted on IIS set 'file'. + */ + public string $destination = self::DESTINATION_STDERR; public function __construct() { } + + + public function writesToStderr(): bool { + return $this->destination===self::DESTINATION_STDERR || $this->destination===self::DESTINATION_BOTH; + } + + + public function writesToFile(): bool { + return $this->destination===self::DESTINATION_FILE || $this->destination===self::DESTINATION_BOTH; + } + } diff --git a/src/models/config/variantEnvironment.php b/src/models/config/variantEnvironment.php deleted file mode 100644 index 06a0ca6..0000000 --- a/src/models/config/variantEnvironment.php +++ /dev/null @@ -1,26 +0,0 @@ -jwtAuth->tokenIssuedBy!=='' ? $this->jwtAuth->tokenIssuedBy : $this->getRootUrl(); + } + + + /** Token audience, defaulting to the application's own base path. */ + public function getTokenPermittedFor(): string { + return $this->jwtAuth->tokenPermittedFor!=='' ? $this->jwtAuth->tokenPermittedFor : $this->getBasePath(); + } + + public function isLocal(): bool { return $this->type=='local'; } diff --git a/src/router.php b/src/router.php index 101d969..757a776 100644 --- a/src/router.php +++ b/src/router.php @@ -23,6 +23,11 @@ public function __construct( array $serviceNamespaces ) { log::debug( 'Framework Lifecycle', '-Router- constructing framework\router' ); log::debug( 'Framework Lifecycle', '-Router- check for routers in services' ); } + + // The framework's own routes (health checks) come first and are not opt-in: a + // deploy pipeline cannot gate on an endpoint an application chose not to have. + $this->serviceRouters[] = new \gcgov\framework\services\health\router(); + foreach($serviceNamespaces as $serviceNamespace) { try { $reflectionClassOfServiceRouter = new ReflectionClass( $serviceNamespace . '\router' ); diff --git a/src/services/environment/configLoader.php b/src/services/environment/configLoader.php index da81b0e..060beb0 100644 --- a/src/services/environment/configLoader.php +++ b/src/services/environment/configLoader.php @@ -4,7 +4,6 @@ namespace gcgov\framework\services\environment; -use gcgov\framework\models\config\variantEnvironment; use gcgov\framework\models\unifiedConfig; /** @@ -15,11 +14,6 @@ * * All failures are thrown as the neutral environmentException; each caller wraps * it in its layer's exception type (configException / cliException). - * - * The `environments` section of config.json is CLI-only (foreign-environment - * connection info) and is stripped before the active configuration is resolved, - * so its environment-prefixed `%env()` references (e.g. PROD_MONGO_URI) never - * have to be set for the app to run. */ final class configLoader { @@ -32,8 +26,7 @@ public static function configFilePath( string $rootDir ): string { /** - * Load and resolve the ACTIVE configuration (ambient environment; the - * `environments` section is stripped). + * Load and resolve the configuration. * * @throws \gcgov\framework\services\environment\environmentException */ @@ -47,7 +40,6 @@ public static function load( string $rootDir ): unifiedConfig { return self::hydrate( unifiedConfig::class, $decoded, $configFile ); } - unset( $decoded->environments ); envVarResolver::resolveDecoded( $decoded, $configFile ); return self::hydrate( unifiedConfig::class, $decoded, $configFile ); @@ -55,54 +47,25 @@ public static function load( string $rootDir ): unifiedConfig { /** - * Load and resolve ONE entry of the `environments` section — a - * foreign-environment read for the gf CLI. + * Every variable config.json references, WITHOUT resolving any of them — so it works + * on a machine where none are set yet. Backs `gf env --list` and `gf env --init`, which + * is what keeps the .env manifest from drifting away from config.json. * + * @return array variable name => is a secret * @throws \gcgov\framework\services\environment\environmentException */ - public static function loadVariantEnvironment( string $rootDir, string $name ): variantEnvironment { - $configFile = self::configFilePath( $rootDir ); - $decoded = self::readAndDecode( $rootDir, $configFile ); - - if( is_string( $decoded ) ) { - throw new environmentException( 'Failed to parse ' . $configFile . ': the file is not a valid JSON object.' ); - } - - $environments = $decoded->environments ?? null; - if( !$environments instanceof \stdClass || !isset( $environments->{$name} ) || !$environments->{$name} instanceof \stdClass ) { - $available = $environments instanceof \stdClass ? array_keys( get_object_vars( $environments ) ) : []; - throw new environmentException( 'No "' . $name . '" entry in the environments section of ' . $configFile . '. ' . ( count( $available )>0 ? 'Defined environments: ' . implode( ', ', $available ) . '.' : 'Define one, e.g. "environments": { "' . $name . '": { "type": "' . $name . '", "mongoDatabases": [ { "default": true, "database": "%env(' . strtoupper( $name ) . '_MONGO_DATABASE)%", "uri": "%env(' . strtoupper( $name ) . '_MONGO_URI)%" } ] } } with the variable values in your .env.' ) ); - } - - $source = $configFile . ' (environments.' . $name . ')'; - envVarResolver::resolveDecoded( $environments->{$name}, $source ); - - return self::hydrate( variantEnvironment::class, $environments->{$name}, $source ); - } - - - /** - * Environment names declared in config.json's `environments` section. - * Read WITHOUT resolution or .env loading — the keys are literals — so this - * is safe for tab completion in any state. - * - * @return string[] - */ - public static function variantNames( string $rootDir ): array { + public static function references( string $rootDir ): array { $configFile = self::configFilePath( $rootDir ); if( !file_exists( $configFile ) ) { - return []; + throw new environmentException( 'Missing config file: ' . $configFile ); } $decoded = json_decode( (string)file_get_contents( $configFile ), false ); - if( !$decoded instanceof \stdClass || !( $decoded->environments ?? null ) instanceof \stdClass ) { - return []; + if( !$decoded instanceof \stdClass ) { + throw new environmentException( 'Failed to parse ' . $configFile . ': the file is not a valid JSON object.' ); } - $names = array_keys( get_object_vars( $decoded->environments ) ); - sort( $names ); - - return $names; + return envVarResolver::collectReferences( $decoded, $configFile ); } diff --git a/src/services/environment/envVarResolver.php b/src/services/environment/envVarResolver.php index da2ba3e..7ce2fc9 100644 --- a/src/services/environment/envVarResolver.php +++ b/src/services/environment/envVarResolver.php @@ -5,13 +5,18 @@ namespace gcgov\framework\services\environment; /** - * Resolves Symfony-style `%env(...)%` references inside the unified {root}/config.json. + * Resolves `%env(...)%` references inside the unified {root}/config.json. * * This is a small, standalone, directly-testable resolver — it is intentionally * NOT coupled to Symfony's dependency-injection container (where Symfony's own * env processors live). The framework applies it to config.json before the JSON - * is handed to `jsonDeserialize()` (see configLoader); the gf CLI applies it to - * the `environments.{name}` subtree for foreign-environment reads. + * is handed to `jsonDeserialize()` (see configLoader). + * + * ## Every reference is required + * There is no fallback mechanism. A referenced variable that is unset — or set + * to the empty string, which counts as unset — is a startup failure naming the + * variable. A configuration value that does not vary between Environments is + * written as a literal in config.json rather than referenced. * * ## Backwards compatibility * A config string that contains no `%env(` substring is returned byte-for-byte @@ -26,23 +31,24 @@ * - Preceding segments form a processor chain, applied right-to-left (Symfony * order): `%env(trim:file:DB_PASS_FILE)%` = `trim(file(env(DB_PASS_FILE)))`. * - When the whole string is a single `%env(...)%`, the typed result - * (int/bool/float/array/stdClass/string) replaces the value. When `%env(...)%` + * (int/bool/array/stdClass/string) replaces the value. When `%env(...)%` * appears embedded inside a larger string, its result is substituted as a * string (a non-scalar embedded result throws). * * ## Processors - * string, bool, not, int, float, trim, file, base64, json, default. + * secret, file, trim, int, bool, json. + * + * ## The `secret` lookup + * `%env(secret:MONGO_URI)%` implements the conventional `_FILE` indirection used + * by the official database images: if `MONGO_URI_FILE` is set, its value is a path + * whose (trimmed) contents are the result; otherwise `MONGO_URI` is read directly. + * A `_FILE` variable pointing at a missing or unreadable file is an error and never + * falls back to the plain variable — falling back would silently substitute a stale + * environment value for a secret that failed to mount. * - * ## The `default` processor (deliberate deviation from Symfony) - * Unlike Symfony — where `default:` names a fallback *parameter* — here `default` - * takes a **literal** fallback value. It must be innermost (closest to the var), - * and its argument is greedy: everything between `default:` and the final `:VAR`, - * so colons are legal in the fallback (a `)` is not — the reference syntax ends - * at the first `)`): - * `%env(default:mongodb://mongodb:27017:MONGO_URI)%` - * The fallback applies only when the variable is unset: - * `%env(default::VAR)%` → '' when VAR is unset - * `%env(int:default:587:SMTP_PORT)%` → int 587 when SMTP_PORT is unset + * This is what lets one committed config.json serve both a developer's machine + * (plain variables in .env) and production (files provisioned to /run/secrets). + * `secret` must be the innermost element of a processor chain. * * ## Reserved (blocked) variable names — request-data injection guard * In web SAPIs, request data leaks into the ambient lookup sources: CGI/FastCGI @@ -51,18 +57,23 @@ * additionally carries request-derived CGI meta-variables (SERVER_NAME, * PHP_AUTH_PW, QUERY_STRING, …). To guarantee a `%env(...)%` reference can never * be satisfied by request data, names matching the CGI meta-variable set are - * treated as UNSET in every ambient source — `default:` applies, otherwise the - * reference fails loudly. Do not name real configuration variables after CGI - * meta-variables. + * treated as UNSET in every ambient source, so the reference fails loudly. Do not + * name real configuration variables after CGI meta-variables. */ final class envVarResolver { + /** Suffix of the companion variable naming a secret's file, per the conventional `_FILE` indirection. */ + public const string SECRET_FILE_SUFFIX = '_FILE'; + /** Name prefixes never resolved from the ambient environment (request-derived under web SAPIs). */ private const array BLOCKED_NAME_PREFIXES = [ 'HTTP_', 'SERVER_', 'REQUEST_', 'REMOTE_', 'PHP_AUTH_', 'SCRIPT_', 'DOCUMENT_' ]; /** Exact names never resolved from the ambient environment (request-derived under web SAPIs). */ private const array BLOCKED_NAMES = [ 'HTTPS', 'QUERY_STRING', 'CONTENT_TYPE', 'CONTENT_LENGTH', 'AUTH_TYPE', 'GATEWAY_INTERFACE', 'PHP_SELF', 'PATH_INFO', 'PATH_TRANSLATED' ]; + /** Every supported processor. `secret` changes the lookup; the rest transform the value. */ + private const array PROCESSORS = [ 'secret', 'file', 'trim', 'int', 'bool', 'json' ]; + /** * Resolve every `%env(...)%` reference in $json. @@ -93,8 +104,6 @@ public static function resolveJson( string $json, string $sourceDescription ): s /** * Resolve every `%env(...)%` reference in an already-decoded config tree, in place. - * Used by configLoader so the `environments` subtree can be stripped/extracted - * before resolution without a re-encode round trip. * * @throws \gcgov\framework\services\environment\environmentException */ @@ -105,6 +114,37 @@ public static function resolveDecoded( \stdClass $decoded, string $sourceDescrip } + /** + * Every variable referenced by a decoded config tree, WITHOUT resolving any of them — + * so this works on a machine that has none of them set. Used by `gf env --list` and + * `gf env --init` to generate the .env manifest from config.json itself, which is what + * keeps the two from drifting. + * + * A `secret` reference reports the variable's own name; the companion `{NAME}_FILE` + * variable is implied by the `secret` flag rather than listed separately. + * + * @return array variable name => is a secret, ordered by first appearance + * @throws \gcgov\framework\services\environment\environmentException On a malformed reference + */ + public static function collectReferences( \stdClass $decoded, string $sourceDescription ): array { + $references = []; + self::walkStrings( $decoded, function( string $value ) use ( &$references, $sourceDescription ): void { + if( !str_contains( $value, '%env(' ) ) { + return; + } + preg_match_all( '/%env\(([^)]+)\)%/', $value, $matches ); + foreach( $matches[ 1 ] as $expression ) { + [ $varName, $processors ] = self::parseExpression( $expression, $sourceDescription ); + $isSecret = in_array( 'secret', $processors, true ); + // A name referenced both ways is a secret: the stricter reading wins. + $references[ $varName ] = ( $references[ $varName ] ?? false ) || $isSecret; + } + } ); + + return $references; + } + + /** * Recursively resolve string leaves within the decoded tree. * @@ -135,6 +175,35 @@ private static function resolveNode( mixed $node, string $sourceDescription ): m } + /** + * Visit every string leaf of a decoded tree without modifying it. + * + * @param mixed $node + * @param callable(string):void $visitor + */ + private static function walkStrings( mixed $node, callable $visitor ): void { + if( $node instanceof \stdClass ) { + foreach( get_object_vars( $node ) as $value ) { + self::walkStrings( $value, $visitor ); + } + + return; + } + + if( is_array( $node ) ) { + foreach( $node as $value ) { + self::walkStrings( $value, $visitor ); + } + + return; + } + + if( is_string( $node ) ) { + $visitor( $node ); + } + } + + /** * Resolve `%env(...)%` occurrences in a single string leaf. * @@ -157,9 +226,6 @@ private static function resolveString( string $value, string $sourceDescription if( is_bool( $resolved ) ) { return $resolved ? 'true' : 'false'; } - if( $resolved===null ) { - return ''; - } if( is_scalar( $resolved ) ) { return (string)$resolved; } @@ -167,10 +233,10 @@ private static function resolveString( string $value, string $sourceDescription }, $value ) ?? $value; // Fail loud instead of silently shipping an unresolved reference: a leftover - // '%env(' means malformed syntax (e.g. a ')' inside a default: literal) or a - // literal '%env(' in a config value — neither is supported. + // '%env(' means an unterminated reference or a literal '%env(' in a config + // value — neither is supported. if( str_contains( $result, '%env(' ) ) { - throw new environmentException( 'Unresolvable %env(...) reference in ' . $sourceDescription . ': "' . $value . '". The reference syntax ends at the first ")" — a ")" inside a default: literal is not supported, and a config value cannot contain the literal text "%env(".' ); + throw new environmentException( 'Unresolvable %env(...) reference in ' . $sourceDescription . ': "' . $value . '". A reference ends at the first ")", and a config value cannot contain the literal text "%env(".' ); } return $result; @@ -178,59 +244,57 @@ private static function resolveString( string $value, string $sourceDescription /** - * Resolve one `%env(...)%` expression (the text between the parentheses). + * Split one `%env(...)%` expression (the text between the parentheses) into its + * variable name and its processor chain, outermost first. * - * @return mixed + * @return array{0: string, 1: string[]} * @throws \gcgov\framework\services\environment\environmentException */ - private static function resolveExpression( string $expression, string $sourceDescription ): mixed { - $lastColon = strrpos( $expression, ':' ); - if( $lastColon===false ) { - $varName = $expression; - $processorSpec = ''; - } - else { - $varName = substr( $expression, $lastColon + 1 ); - $processorSpec = substr( $expression, 0, $lastColon ); - } + private static function parseExpression( string $expression, string $sourceDescription ): array { + $segments = explode( ':', $expression ); + $varName = (string)array_pop( $segments ); if( preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $varName )!==1 ) { throw new environmentException( 'Invalid environment variable reference "%env(' . $expression . ')%" in ' . $sourceDescription . ': "' . $varName . '" is not a valid variable name.' ); } - // Parse the processor chain left-to-right (outer → inner). `default` is greedy: - // it consumes the remainder of the spec as its literal fallback and is innermost. - $processors = []; - $default = null; - $remainingSpec = $processorSpec; - while( $remainingSpec!=='' ) { - $colon = strpos( $remainingSpec, ':' ); - $token = $colon===false ? $remainingSpec : substr( $remainingSpec, 0, $colon ); - $rest = $colon===false ? '' : substr( $remainingSpec, $colon + 1 ); - - if( $token==='default' ) { - $default = $rest; - $remainingSpec = ''; - break; + foreach( $segments as $index => $processor ) { + if( !in_array( $processor, self::PROCESSORS, true ) ) { + throw new environmentException( 'Unknown environment processor "' . $processor . '" in "%env(' . $expression . ')%" (' . $sourceDescription . '). Supported: ' . implode( ', ', self::PROCESSORS ) . '.' ); + } + if( $processor==='secret' && $index!==count( $segments ) - 1 ) { + throw new environmentException( '"secret" must be the innermost processor in "%env(' . $expression . ')%" (' . $sourceDescription . '), i.e. immediately before the variable name — it selects where the value is read from, so nothing can come between it and the variable.' ); } + } + + return [ $varName, $segments ]; + } - $processors[] = $token; - $remainingSpec = $rest; + + /** + * Resolve one `%env(...)%` expression. + * + * @return mixed + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function resolveExpression( string $expression, string $sourceDescription ): mixed { + [ $varName, $processors ] = self::parseExpression( $expression, $sourceDescription ); + + $useSecretLookup = false; + if( count( $processors )>0 && end( $processors )==='secret' ) { + array_pop( $processors ); + $useSecretLookup = true; } - // Environment lookup (with optional literal default fallback). - $raw = self::lookupEnv( $varName ); - if( $raw===null ) { - if( $default===null ) { - if( self::isBlockedName( $varName ) ) { - throw new environmentException( '"' . $varName . '" is a reserved CGI meta-variable name and is never resolved from the environment (referenced as "%env(' . $expression . ')%" in ' . $sourceDescription . '). Rename the configuration variable.' ); - } - throw new environmentException( 'Required environment variable "' . $varName . '" is not set (referenced as "%env(' . $expression . ')%" in ' . $sourceDescription . '). Set it in the process environment, a Docker secret, or a .env file.' ); + $value = $useSecretLookup + ? self::lookupSecret( $varName, $expression, $sourceDescription ) + : self::lookupEnv( $varName ); + + if( $value===null ) { + if( self::isBlockedName( $varName ) ) { + throw new environmentException( '"' . $varName . '" is a reserved CGI meta-variable name and is never resolved from the environment (referenced as "%env(' . $expression . ')%" in ' . $sourceDescription . '). Rename the configuration variable.' ); } - $value = $default; - } - else { - $value = $raw; + throw new environmentException( 'Required environment variable "' . $varName . '" is not set' . ( $useSecretLookup ? ' (and neither is "' . $varName . self::SECRET_FILE_SUFFIX . '")' : '' ) . ' (referenced as "%env(' . $expression . ')%" in ' . $sourceDescription . '). Set it in the process environment, a provisioned secret file, or a .env file.' ); } // Apply processors right-to-left (inner → outer). @@ -242,6 +306,36 @@ private static function resolveExpression( string $expression, string $sourceDes } + /** + * The `secret` lookup: prefer the file named by `{NAME}_FILE`, else the plain variable. + * A `_FILE` variable that is set but unreadable is an error, never a fall-back — see the + * class docblock. + * + * @throws \gcgov\framework\services\environment\environmentException + */ + private static function lookupSecret( string $varName, string $expression, string $sourceDescription ): ?string { + $fileVarName = $varName . self::SECRET_FILE_SUFFIX; + $path = self::lookupEnv( $fileVarName ); + + if( $path===null ) { + return self::lookupEnv( $varName ); + } + + if( !is_file( $path ) || !is_readable( $path ) ) { + throw new environmentException( 'Secret file for "%env(' . $expression . ')%" in ' . $sourceDescription . ' does not exist or is not readable: "' . $path . '" (from ' . $fileVarName . '). The secret is not falling back to ' . $varName . ' — fix the mount or unset ' . $fileVarName . '.' ); + } + + $contents = file_get_contents( $path ); + if( $contents===false ) { + throw new environmentException( 'Failed reading the secret file for "%env(' . $expression . ')%" in ' . $sourceDescription . ': "' . $path . '" (from ' . $fileVarName . ').' ); + } + + // Provisioned secret files conventionally end in a newline; a credential never + // legitimately has surrounding whitespace. + return trim( $contents ); + } + + /** * @param mixed $value * @@ -250,14 +344,10 @@ private static function resolveExpression( string $expression, string $sourceDes */ private static function applyProcessor( string $processor, mixed $value, string $expression, string $sourceDescription ): mixed { switch( $processor ) { - case 'string': - return (string)$value; - case 'bool': - return self::toBool( $value ); + $bool = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); - case 'not': - return !self::toBool( $value ); + return $bool ?? (bool)$value; case 'int': if( !is_numeric( trim( (string)$value ) ) ) { @@ -266,13 +356,6 @@ private static function applyProcessor( string $processor, mixed $value, string return (int)$value; - case 'float': - if( !is_numeric( trim( (string)$value ) ) ) { - throw new environmentException( 'Cannot apply "float" to non-numeric value for "%env(' . $expression . ')%" in ' . $sourceDescription . '.' ); - } - - return (float)$value; - case 'trim': return trim( (string)$value ); @@ -288,20 +371,6 @@ private static function applyProcessor( string $processor, mixed $value, string return $contents; - case 'base64': - // URL-safe tolerant: accept the URL-safe alphabet and missing padding. - $normalized = strtr( (string)$value, '-_', '+/' ); - $padding = strlen( $normalized ) % 4; - if( $padding>0 ) { - $normalized .= str_repeat( '=', 4 - $padding ); - } - $decoded = base64_decode( $normalized, true ); - if( $decoded===false ) { - throw new environmentException( 'Cannot apply "base64" for "%env(' . $expression . ')%" in ' . $sourceDescription . ': value is not valid base64.' ); - } - - return $decoded; - case 'json': $decoded = json_decode( (string)$value, false ); if( json_last_error()!==JSON_ERROR_NONE ) { @@ -311,24 +380,13 @@ private static function applyProcessor( string $processor, mixed $value, string return $decoded; default: - throw new environmentException( 'Unknown environment processor "' . $processor . '" in "%env(' . $expression . ')%" (' . $sourceDescription . '). Supported: string, bool, not, int, float, trim, file, base64, json, default.' ); + // parseExpression() has already rejected unknown processors, and `secret` + // is consumed before the chain is applied. + throw new environmentException( 'Environment processor "' . $processor . '" cannot be applied to a value in "%env(' . $expression . ')%" (' . $sourceDescription . ').' ); } } - /** - * @param mixed $value - */ - private static function toBool( mixed $value ): bool { - $bool = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); - if( $bool===null ) { - return (bool)$value; - } - - return $bool; - } - - /** Request-derived under web SAPIs — never satisfiable from the ambient environment. */ private static function isBlockedName( string $name ): bool { if( in_array( $name, self::BLOCKED_NAMES, true ) ) { @@ -350,28 +408,32 @@ private static function isBlockedName( string $name ): bool { * three sources by name (not per source): under CGI/FastCGI SAPIs request headers * reach the real process environment (getenv) and — with `variables_order=E` — * $_ENV, so filtering only $_SERVER would be bypassable. - * Returns null only when the variable is genuinely unset (a set-but-empty - * variable resolves to '', which also suppresses `default:`). + * + * A variable set to the empty string is reported as unset. Every reference is + * required, so "" is never a meaningful configured value — treating it as one + * would let a blank line in a .env satisfy a required secret. */ private static function lookupEnv( string $name ): ?string { if( self::isBlockedName( $name ) ) { return null; } + $value = null; + if( array_key_exists( $name, $_ENV ) ) { - return (string)$_ENV[ $name ]; + $value = (string)$_ENV[ $name ]; } - - if( array_key_exists( $name, $_SERVER ) && is_scalar( $_SERVER[ $name ] ) ) { - return (string)$_SERVER[ $name ]; + elseif( array_key_exists( $name, $_SERVER ) && is_scalar( $_SERVER[ $name ] ) ) { + $value = (string)$_SERVER[ $name ]; } - - $value = getenv( $name ); - if( $value!==false ) { - return $value; + else { + $fromGetenv = getenv( $name ); + if( $fromGetenv!==false ) { + $value = $fromGetenv; + } } - return null; + return ( $value===null || $value==='' ) ? null : $value; } } diff --git a/src/services/health/controllers/health.php b/src/services/health/controllers/health.php new file mode 100644 index 0000000..4e4a60f --- /dev/null +++ b/src/services/health/controllers/health.php @@ -0,0 +1,96 @@ + 'ok', + 'version' => self::version(), + ] ); + } + + + /** + * Readiness. Pings every configured Mongo database. Backs the deploy gate and the + * reverse proxy's load-balancing decision. Returns 503 when a dependency is down, so + * a deploy that cannot reach its database fails loudly instead of reporting success. + */ + public function ready(): controllerDataResponse { + $checks = []; + $healthy = true; + + try { + foreach( config::getMongoDatabases() as $mongoDatabase ) { + $checks[ 'mongo:' . $mongoDatabase->database ] = self::pingMongo( $mongoDatabase->database ); + if( $checks[ 'mongo:' . $mongoDatabase->database ]!=='ok' ) { + $healthy = false; + } + } + } + catch( \Throwable $e ) { + $checks[ 'config' ] = 'failed: ' . $e->getMessage(); + $healthy = false; + } + + $response = new controllerDataResponse( [ + 'status' => $healthy ? 'ok' : 'unavailable', + 'version' => self::version(), + 'checks' => $checks, + ] ); + if( !$healthy ) { + $response->setHttpStatus( 503 ); + } + + return $response; + } + + + /** + * The deployed release, for confirming that a deploy actually landed — half the + * reason this endpoint exists. Written into the image at build time. + */ + private static function version(): string { + $version = getenv( 'APP_VERSION' ); + + return is_string( $version ) && $version!=='' ? $version : 'unknown'; + } + + + /** @return string 'ok', or a description of why not — never a thrown exception. */ + private static function pingMongo( string $databaseName ): string { + try { + ( new \gcgov\framework\services\mongodb\tools\mdb( database: $databaseName ) )->db->command( [ 'ping' => 1 ] ); + + return 'ok'; + } + catch( \Throwable $e ) { + return 'failed: ' . $e->getMessage(); + } + } + +} diff --git a/src/services/health/router.php b/src/services/health/router.php new file mode 100644 index 0000000..6782389 --- /dev/null +++ b/src/services/health/router.php @@ -0,0 +1,50 @@ +keyPath = config::getSrvDir().'/jwtCertificates/'; - } - else { - $this->keyPath = dirname( __FILE__ ) . '/jwtCertificates/'; + // Signing keys: jwtAuth.keyPath when configured, else {root}/srv/jwtCertificates. + // A container points keyPath at a provisioned read-only mount — the keys are + // secrets and are never baked into an image. + $this->keyPath = config::getJwtKeyPath(); + if( !is_dir( $this->keyPath ) ) { + throw new configException( 'JWT key directory does not exist: ' . $this->keyPath . '. Generate keys with `vendor/bin/gf cert:generate-auth`, or point "jwtAuth.keyPath" in config.json at the directory they are provisioned to.' ); } - //jwt config - $jwtAuthConfig = config::getJwtAuth(); - if( empty( $jwtAuthConfig->tokenIssuedBy ) || empty( $jwtAuthConfig->tokenPermittedFor ) ) { - throw new configException( 'Missing "jwtAuth" section of /config.json' ); + // Issuer and audience default to the application's own rootUrl / basePath. + $this->issuedBy = config::getTokenIssuedBy(); + $this->permittedFor = config::getTokenPermittedFor(); + if( $this->issuedBy==='' || $this->permittedFor==='' ) { + throw new configException( 'Cannot determine the JWT issuer and audience: set "rootUrl" and "basePath" in config.json, or set "jwtAuth.tokenIssuedBy" and "jwtAuth.tokenPermittedFor" explicitly.' ); } - $this->issuedBy = $jwtAuthConfig->tokenIssuedBy; - $this->permittedFor = $jwtAuthConfig->tokenPermittedFor; //guid config if( !file_exists( $this->keyPath . 'guids.json' ) ) { @@ -99,12 +98,12 @@ private function init( string $guid ): void { private function getPrivateKeyPath(): string { - return $this->keyPath . '/private-' . $this->guid . '.pem'; + return $this->keyPath . 'private-' . $this->guid . '.pem'; } private function getPublicKeyPath(): string { - return $this->keyPath . '/public-' . $this->guid . '.pem'; + return $this->keyPath . 'public-' . $this->guid . '.pem'; } @@ -355,7 +354,7 @@ public function getJwksKeys() { $jwksKeys = []; foreach( $this->guids as $guid ) { - $pub_key = openssl_pkey_get_public( file_get_contents( $this->keyPath . '/public-' . $guid . '.pem' ) ); + $pub_key = openssl_pkey_get_public( file_get_contents( $this->keyPath . 'public-' . $guid . '.pem' ) ); $keyData = openssl_pkey_get_details( $pub_key ); $jwksKeys[] = [ 'alg' => 'RS512', diff --git a/src/services/log.php b/src/services/log.php index edd3217..09d1138 100644 --- a/src/services/log.php +++ b/src/services/log.php @@ -2,6 +2,7 @@ namespace gcgov\framework\services; use Monolog\Logger; +use Monolog\Formatter\JsonFormatter; use Monolog\Handler\StreamHandler; final class log { @@ -75,13 +76,52 @@ private static function getLogger( string $channel = '' ): Logger { } } - $handlers = [ - new StreamHandler( \gcgov\framework\config::getRootDir() . '/logs/' . $channel . '.log' ) - ]; - - self::$loggers[ $channel ] = new Logger( $channel, $handlers ); + self::$loggers[ $channel ] = new Logger( $channel, self::buildHandlers( $channel ) ); return self::$loggers[ $channel ]; } + + /** + * Handlers for the configured destination. + * + * stderr is the default and what a container needs: its filesystem does not survive + * a deploy, so file logs would be per-replica and destroyed on every release. Records + * go out as JSON lines so a collector can query them. Applications still hosted on + * IIS set logging.destination to "file". + * + * Configuration may itself be unreadable when something fails early in the lifecycle, + * and a logger that throws while reporting an error is worse than a misplaced log — + * so an unreadable config falls back to stderr. + * + * @return \Monolog\Handler\HandlerInterface[] + */ + private static function buildHandlers( string $channel ): array { + try { + $logging = \gcgov\framework\config::getLogging(); + } + catch( \gcgov\framework\exceptions\configException ) { + return [ self::stderrHandler() ]; + } + + $handlers = []; + if( $logging->writesToStderr() ) { + $handlers[] = self::stderrHandler(); + } + if( $logging->writesToFile() ) { + $handlers[] = new StreamHandler( \gcgov\framework\config::getRootDir() . '/logs/' . $channel . '.log' ); + } + + // An unrecognised destination still has to log somewhere. + return count( $handlers )>0 ? $handlers : [ self::stderrHandler() ]; + } + + + private static function stderrHandler(): StreamHandler { + $handler = new StreamHandler( 'php://stderr' ); + $handler->setFormatter( new JsonFormatter() ); + + return $handler; + } + } \ No newline at end of file diff --git a/tests/Unit/Cli/AppContextTest.php b/tests/Unit/Cli/AppContextTest.php index 9797769..e5b3f41 100644 --- a/tests/Unit/Cli/AppContextTest.php +++ b/tests/Unit/Cli/AppContextTest.php @@ -90,7 +90,6 @@ public function testDirectoryAccessors(): void { $this->assertNotNull( $context ); $root = str_replace( '\\', '/', $this->tempRootDir ); $this->assertSame( $root . '/app', $context->getAppDir() ); - $this->assertSame( $root . '/app/config', $context->getConfigDir() ); $this->assertSame( $root . '/srv', $context->getSrvDir() ); $this->assertSame( $root . '/vendor/autoload.php', $context->getVendorAutoloadPath() ); } @@ -150,117 +149,20 @@ public function testLoadConfigThrowsCliExceptionWhenEnvVarMissing(): void { } - public function testActiveConfigStripsEnvironmentsSection(): void { - // The CLI-only environments section must not have to resolve for the active - // config to load — its PROD_* variables are unset here. - file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ - 'type' => 'local', - 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => 'mongodb://local:27017' ] ], - 'environments' => [ 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(PROD_MONGO_URI)%' ] ] ] ], - ] ) ); - $context = appContext::locate( $this->tempRootDir ); - $this->assertNotNull( $context ); - $active = $context->loadConfig(); - $this->assertSame( 'local', $active->type ); - $this->assertSame( 'mongodb://local:27017', $active->mongoDatabases[ 0 ]->uri ); - } - public function testLoadVariantEnvironmentResolvesEntry(): void { - $_ENV[ 'PROD_MONGO_URI' ] = 'mongodb://prod:27017/widgets'; - putenv( 'PROD_MONGO_URI=mongodb://prod:27017/widgets' ); - try { - file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ - 'type' => 'local', - 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => 'mongodb://local:27017' ] ], - 'environments' => [ 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(PROD_MONGO_URI)%' ] ] ] ], - ] ) ); - $context = appContext::locate( $this->tempRootDir ); - $this->assertNotNull( $context ); - $prod = $context->loadVariantEnvironment( 'prod' ); - $this->assertSame( 'prod', $prod->type ); - $this->assertSame( 'mongodb://prod:27017/widgets', $prod->mongoDatabases[ 0 ]->uri ); - } - finally { - unset( $_ENV[ 'PROD_MONGO_URI' ] ); - putenv( 'PROD_MONGO_URI' ); - } - } - public function testLoadVariantEnvironmentThrowsCliExceptionWhenPrefixedVarMissing(): void { - unset( $_ENV[ 'PROD_MONGO_URI' ] ); - putenv( 'PROD_MONGO_URI' ); - file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ - 'type' => 'local', - 'environments' => [ 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'widgets', 'uri' => '%env(PROD_MONGO_URI)%' ] ] ] ], - ] ) ); - $context = appContext::locate( $this->tempRootDir ); - $this->assertNotNull( $context ); - try { - $context->loadVariantEnvironment( 'prod' ); - $this->fail( 'Expected cliException' ); - } - catch( cliException $e ) { - $this->assertStringContainsString( 'PROD_MONGO_URI', $e->getMessage() ); - } - } - public function testLoadVariantEnvironmentThrowsWhenEntryMissing(): void { - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local","environments":{"staging":{"type":"staging"}}}' ); - $context = appContext::locate( $this->tempRootDir ); - $this->assertNotNull( $context ); - try { - $context->loadVariantEnvironment( 'prod' ); - $this->fail( 'Expected cliException' ); - } - catch( cliException $e ) { - $this->assertStringContainsString( 'No "prod" entry', $e->getMessage() ); - $this->assertStringContainsString( 'staging', $e->getMessage() ); - } - } - public function testLoadVariantEnvironmentMentionsMigrationWhenLegacyFileExists(): void { - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local"}' ); - file_put_contents( $this->tempRootDir . '/app/config/environment-prod.json', '{"type":"prod"}' ); - $context = appContext::locate( $this->tempRootDir ); - $this->assertNotNull( $context ); - try { - $context->loadVariantEnvironment( 'prod' ); - $this->fail( 'Expected cliException' ); - } - catch( cliException $e ) { - $this->assertStringContainsString( 'environment-prod.json', $e->getMessage() ); - $this->assertStringContainsString( 'Migrating a v6 app to v7', $e->getMessage() ); - } - } - public function testDescribeConfigSource(): void { - $context = appContext::locate( $this->tempRootDir ); - $this->assertNotNull( $context ); - $root = str_replace( '\\', '/', $this->tempRootDir ); - $this->assertSame( $root . '/config.json', $context->describeConfigSource() ); - $this->assertSame( $root . '/config.json (environments.prod)', $context->describeConfigSource( 'prod' ) ); - } - public function testGetEnvironmentVariantsListsEnvironmentsSection(): void { - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local","environments":{"prod":{"type":"prod"},"staging":{"type":"staging"}}}' ); - $context = appContext::locate( $this->tempRootDir ); - $this->assertNotNull( $context ); - $this->assertSame( [ 'prod', 'staging' ], $context->getEnvironmentVariants() ); - } - public function testGetEnvironmentVariantsEmptyWithoutEnvironmentsSection(): void { - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local"}' ); - $context = appContext::locate( $this->tempRootDir ); - $this->assertNotNull( $context ); - $this->assertSame( [], $context->getEnvironmentVariants() ); - } private function deleteDirectory( string $directory ): void { if( !is_dir( $directory ) ) { diff --git a/tests/Unit/Cli/ApplicationTest.php b/tests/Unit/Cli/ApplicationTest.php index d1c90ca..59239cd 100644 --- a/tests/Unit/Cli/ApplicationTest.php +++ b/tests/Unit/Cli/ApplicationTest.php @@ -14,7 +14,7 @@ final class ApplicationTest extends TestCase { public function testAllBuiltInCommandsAreRegistered(): void { $application = new application(); - foreach( [ 'cli', 'cli:list', 'cert:generate-auth', 'db:restore', 'db:run', 'env', 'setup', 'deploy', 'completion:powershell', 'completion' ] as $commandName ) { + foreach( [ 'cli', 'cli:list', 'cert:generate-auth', 'db:run', 'env', 'init', 'migrate', 'completion:powershell', 'completion' ] as $commandName ) { $this->assertTrue( $application->has( $commandName ), 'missing command: ' . $commandName ); } } @@ -22,7 +22,7 @@ public function testAllBuiltInCommandsAreRegistered(): void { public function testNormalizeArgvJoinsSpaceSeparatedCommandNames(): void { $application = new application(); - $this->assertSame( [ 'gf', 'db:restore', '--from=prod' ], $application->normalizeArgv( [ 'gf', 'db', 'restore', '--from=prod' ] ) ); + $this->assertSame( [ 'gf', 'db:run', 'db/seed.js' ], $application->normalizeArgv( [ 'gf', 'db', 'run', 'db/seed.js' ] ) ); $this->assertSame( [ 'gf', 'cert:generate-auth' ], $application->normalizeArgv( [ 'gf', 'cert', 'generate-auth' ] ) ); } diff --git a/tests/Unit/Cli/CommandsTest.php b/tests/Unit/Cli/CommandsTest.php index 1fb5393..222bef7 100644 --- a/tests/Unit/Cli/CommandsTest.php +++ b/tests/Unit/Cli/CommandsTest.php @@ -10,7 +10,6 @@ use gcgov\framework\cli\commands\cliListCommand; use gcgov\framework\cli\commands\completionPowershellCommand; use gcgov\framework\cli\commands\envCommand; -use gcgov\framework\cli\commands\setupCommand; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; @@ -58,72 +57,10 @@ public function testCliListShowsCliRoutesWithDescriptions(): void { $this->assertStringNotContainsString( '/widget', $display ); } - public function testEnvCommandValidatesEnvironmentEntry(): void { - putenv( 'TEST_ENVCMD_URI=mongodb://user:secret@prod:27017' ); - $_ENV[ 'TEST_ENVCMD_URI' ] = 'mongodb://user:secret@prod:27017'; - try { - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local","environments":{"prod":{"type":"prod","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_URI)%"}]}}}' ); - - $commandTester = new CommandTester( new envCommand() ); - $exitCode = $commandTester->execute( [ 'environment' => 'prod' ] ); - - $this->assertSame( 0, $exitCode ); - $display = $commandTester->getDisplay(); - $this->assertStringContainsString( 'type: prod', $display ); - $this->assertStringContainsString( 'widgets', $display ); - $this->assertStringNotContainsString( 'secret', $display, 'mongo uri credentials must be redacted' ); - $this->assertStringContainsString( 'Resolved successfully', $display ); - } - finally { - unset( $_ENV[ 'TEST_ENVCMD_URI' ] ); - putenv( 'TEST_ENVCMD_URI' ); - } - } - - public function testEnvCommandFailsNamingTheMissingVariable(): void { - unset( $_ENV[ 'TEST_ENVCMD_MISSING_URI' ] ); - putenv( 'TEST_ENVCMD_MISSING_URI' ); - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local","environments":{"prod":{"type":"prod","mongoDatabases":[{"default":true,"database":"widgets","uri":"%env(TEST_ENVCMD_MISSING_URI)%"}]}}}' ); - - $commandTester = new CommandTester( new envCommand() ); - $exitCode = $commandTester->execute( [ 'environment' => 'prod' ] ); - $this->assertSame( 1, $exitCode ); - $this->assertStringContainsString( 'TEST_ENVCMD_MISSING_URI', $commandTester->getDisplay() ); - } - public function testEnvCommandBareListsEnvironmentsAndChecksActive(): void { - file_put_contents( $this->tempRootDir . '/config.json', '{"type":"local","environments":{"prod":{"type":"prod"},"staging":{"type":"staging"}}}' ); - $commandTester = new CommandTester( new envCommand() ); - $exitCode = $commandTester->execute( [] ); - $this->assertSame( 0, $exitCode ); - $display = $commandTester->getDisplay(); - $this->assertStringContainsString( 'prod', $display ); - $this->assertStringContainsString( 'staging', $display ); - $this->assertStringContainsString( 'Resolved successfully', $display ); - } - - public function testSetupPromptFilteringKeepsOnlyPresentTokens(): void { - file_put_contents( $this->tempRootDir . '/app/config/app.json', '{"title":"{app_title}"}' ); - file_put_contents( $this->tempRootDir . '/app/router.php', ' 'Title', - 'app_base_path' => 'Base path', // present via derived {app_relative_url} - 'prod_app_root_url' => 'PROD root url', // absent - 'prod_app_base_path' => 'PROD base path', // absent - ], $this->tempRootDir ); - - $this->assertSame( [ 'app_title', 'app_base_path' ], array_keys( $filtered ) ); - } - - public function testTokensForPromptKeyMapsBasePathToBothTokens(): void { - $this->assertSame( [ '{app_base_path}', '{app_relative_url}' ], setupCommand::tokensForPromptKey( 'app_base_path' ) ); - $this->assertSame( [ '{prod_app_base_path}', '{prod_app_relative_url}' ], setupCommand::tokensForPromptKey( 'prod_app_base_path' ) ); - $this->assertSame( [ '{app_title}' ], setupCommand::tokensForPromptKey( 'app_title' ) ); - } public function testCertGenerateAuthCreatesKeypairsAndGuidsJson(): void { if( !extension_loaded( 'openssl' ) ) { @@ -181,34 +118,7 @@ public function testCompletionPowershellPrintsBridgeWithApiVersion(): void { $this->assertStringContainsString( '-a' . \Symfony\Component\Console\Command\CompleteCommand::COMPLETION_API_VERSION, $display ); } - public function testSetupRefusesNonInteractiveMode(): void { - $commandTester = new CommandTester( new setupCommand() ); - - $this->expectException( \gcgov\framework\cli\cliException::class ); - $commandTester->execute( [], [ 'interactive' => false ] ); - } - public function testSetupBuildReplacementTableDerivesUrlTokens(): void { - $setupCommand = new setupCommand(); - - $replacements = $setupCommand->buildReplacementTable( [ - 'app_title' => 'Widget API', - 'app_base_path' => 'api', - 'prod_app_base_path' => '/api/', - 'app_root_url' => 'https://local.example.gov/', - 'prod_app_absolute_path' => 'E:\Web\api\\', - ], '/var/www/widget' ); - - $this->assertSame( 'Widget API', $replacements[ '{app_title}' ] ); - $this->assertSame( '/api/', $replacements[ '{app_base_path}' ] ); - $this->assertSame( 'api/', $replacements[ '{app_relative_url}' ] ); - $this->assertSame( '/api/', $replacements[ '{prod_app_base_path}' ] ); - $this->assertSame( 'api/', $replacements[ '{prod_app_relative_url}' ] ); - $this->assertSame( 'https://local.example.gov', $replacements[ '{app_root_url}' ] ); - $this->assertSame( 'E:\Web\api', $replacements[ '{prod_app_absolute_path}' ] ); - $this->assertSame( '/var/www/widget', $replacements[ '{app_absolute_path}' ] ); - $this->assertNotSame( '', $replacements[ '{app_guid}' ] ); - } public function testDynamicRouteCompletionSuggestsCliRoutes(): void { $suggestions = \gcgov\framework\cli\commands\cliCommand::suggestCliRoutes( \Symfony\Component\Console\Completion\CompletionInput::fromTokens( [ 'gf', 'cli', '' ], 2 ) ); diff --git a/tests/Unit/Cli/DbRestoreCommandTest.php b/tests/Unit/Cli/DbRestoreCommandTest.php deleted file mode 100644 index 7d852e9..0000000 --- a/tests/Unit/Cli/DbRestoreCommandTest.php +++ /dev/null @@ -1,118 +0,0 @@ -database = $database; - $mongoDatabase->uri = $uri; - $mongoDatabase->default = $default; - - return $mongoDatabase; - } - - public function testFindIdenticalPairsFlagsSameUriAndDatabase(): void { - $pairs = [ - [ $this->makeDatabase( 'widgets', 'mongodb://host:27017/' ), $this->makeDatabase( 'widgets', 'mongodb://host:27017' ) ], - [ $this->makeDatabase( 'audit', 'mongodb://prod:27017' ), $this->makeDatabase( 'audit', 'mongodb://local:27017' ) ], - ]; - - $identical = dbRestoreCommand::findIdenticalPairs( $pairs ); - - $this->assertCount( 1, $identical, 'trailing-slash uri difference must still count as identical' ); - $this->assertSame( 'widgets', $identical[ 0 ][ 0 ]->database ); - } - - public function testFindIdenticalPairsAllowsSameClusterDifferentDatabase(): void { - $pairs = [ - [ $this->makeDatabase( 'appProd', 'mongodb://host:27017' ), $this->makeDatabase( 'appLocal', 'mongodb://host:27017' ) ], - ]; - - $this->assertSame( [], dbRestoreCommand::findIdenticalPairs( $pairs ) ); - } - - public function testFindIdenticalPairsEmptyInput(): void { - $this->assertSame( [], dbRestoreCommand::findIdenticalPairs( [] ) ); - } - - public function testPairDatabasesMatchesByName(): void { - $source = [ $this->makeDatabase( 'widgets', 'mongodb://prod/widgets' ), $this->makeDatabase( 'audit', 'mongodb://prod/audit' ) ]; - $target = [ $this->makeDatabase( 'audit', 'mongodb://local/audit' ), $this->makeDatabase( 'widgets', 'mongodb://local/widgets' ) ]; - - $pairs = dbRestoreCommand::pairDatabases( $source, $target ); - - $this->assertCount( 2, $pairs[ 'matched' ] ); - $this->assertSame( [], $pairs[ 'unmatched' ] ); - $this->assertSame( 'mongodb://local/widgets', $pairs[ 'matched' ][0][1]->uri ); - } - - public function testPairDatabasesFallsBackToDefaults(): void { - $source = [ $this->makeDatabase( 'appProd', 'mongodb://prod/appProd', true ) ]; - $target = [ $this->makeDatabase( 'appLocal', 'mongodb://local/appLocal', true ) ]; - - $pairs = dbRestoreCommand::pairDatabases( $source, $target ); - - $this->assertCount( 1, $pairs[ 'matched' ] ); - $this->assertSame( 'appLocal', $pairs[ 'matched' ][0][1]->database ); - } - - public function testPairDatabasesReportsUnmatched(): void { - $source = [ $this->makeDatabase( 'reports', 'mongodb://prod/reports' ) ]; - $target = [ $this->makeDatabase( 'widgets', 'mongodb://local/widgets' ) ]; - - $pairs = dbRestoreCommand::pairDatabases( $source, $target ); - - $this->assertSame( [], $pairs[ 'matched' ] ); - $this->assertSame( [ 'reports' ], $pairs[ 'unmatched' ] ); - } - - public function testPairDatabasesHonorsDbFilter(): void { - $source = [ $this->makeDatabase( 'widgets', 'u' ), $this->makeDatabase( 'audit', 'u' ) ]; - $target = [ $this->makeDatabase( 'widgets', 'u' ), $this->makeDatabase( 'audit', 'u' ) ]; - - $pairs = dbRestoreCommand::pairDatabases( $source, $target, [ 'audit' ] ); - - $this->assertCount( 1, $pairs[ 'matched' ] ); - $this->assertSame( 'audit', $pairs[ 'matched' ][0][0]->database ); - } - - public function testBuildDumpCommand(): void { - $sourceDb = $this->makeDatabase( 'widgets', 'mongodb://u:p@prod:27017/widgets' ); - - $this->assertSame( - [ '/usr/bin/mongodump', '--uri=mongodb://u:p@prod:27017/widgets', '--db=widgets', '--out=/tmp/dump' ], - dbRestoreCommand::buildDumpCommand( '/usr/bin/mongodump', $sourceDb, '/tmp/dump' ) - ); - } - - public function testBuildRestoreCommandSameNameHasNoNsRemap(): void { - $sourceDb = $this->makeDatabase( 'widgets', 'mongodb://prod/widgets' ); - $targetDb = $this->makeDatabase( 'widgets', 'mongodb://local/widgets' ); - - $this->assertSame( - [ '/usr/bin/mongorestore', '--uri=mongodb://local/widgets', '--drop', '/tmp/dump/widgets' ], - dbRestoreCommand::buildRestoreCommand( '/usr/bin/mongorestore', $sourceDb, $targetDb, '/tmp/dump' ) - ); - } - - public function testBuildRestoreCommandRemapsDifferingNames(): void { - $sourceDb = $this->makeDatabase( 'appProd', 'mongodb://prod/appProd' ); - $targetDb = $this->makeDatabase( 'appLocal', 'mongodb://local/appLocal' ); - - $this->assertSame( - [ '/usr/bin/mongorestore', '--uri=mongodb://local/appLocal', '--drop', '--nsFrom=appProd.*', '--nsTo=appLocal.*', '/tmp/dump/appProd' ], - dbRestoreCommand::buildRestoreCommand( '/usr/bin/mongorestore', $sourceDb, $targetDb, '/tmp/dump' ) - ); - } - -} diff --git a/tests/Unit/Cli/EnvCommandTest.php b/tests/Unit/Cli/EnvCommandTest.php new file mode 100644 index 0000000..b3e44e0 --- /dev/null +++ b/tests/Unit/Cli/EnvCommandTest.php @@ -0,0 +1,53 @@ +renderEnvFile( [ 'APP_TYPE' => false, 'APP_ROOT_URL' => false ] ); + + self::assertStringContainsString( "APP_TYPE=\n", $rendered ); + self::assertStringContainsString( "APP_ROOT_URL=\n", $rendered ); + } + + + /** + * A secret gets its `_FILE` companion shown as a comment, because production supplies + * it that way and a developer reading the file should see that the option exists. + */ + public function testSecretsAreGroupedAndShowTheFileAlternative(): void { + $rendered = ( new envCommand() )->renderEnvFile( [ 'APP_TYPE' => false, 'MONGO_URI' => true ] ); + + self::assertStringContainsString( "MONGO_URI=\n", $rendered ); + self::assertStringContainsString( '# MONGO_URI_FILE=/run/secrets/mongo_uri', $rendered ); + self::assertStringContainsString( 'never commit', strtolower( $rendered ) ); + + // Plain variables come first, secrets in their own labelled block after. + self::assertLessThan( strpos( $rendered, 'MONGO_URI=' ), strpos( $rendered, 'APP_TYPE=' ) ); + } + + + public function testRenderedEnvSaysEveryVariableIsRequired(): void { + $rendered = ( new envCommand() )->renderEnvFile( [ 'APP_TYPE' => false ] ); + + self::assertStringContainsString( 'REQUIRED', $rendered ); + self::assertStringContainsString( 'empty string counts as unset', $rendered ); + } + + + public function testEmptyReferenceSetStillProducesAUsableFile(): void { + $rendered = ( new envCommand() )->renderEnvFile( [] ); + + self::assertStringStartsWith( '#', $rendered ); + self::assertStringEndsWith( "\n", $rendered ); + } + +} diff --git a/tests/Unit/Cli/GfBinSmokeTest.php b/tests/Unit/Cli/GfBinSmokeTest.php index 790660d..3d7a7a5 100644 --- a/tests/Unit/Cli/GfBinSmokeTest.php +++ b/tests/Unit/Cli/GfBinSmokeTest.php @@ -27,7 +27,7 @@ public function testGfListRunsOutsideAnApplication(): void { $this->assertSame( 0, $process->getExitCode(), $process->getOutput() . $process->getErrorOutput() ); $this->assertStringContainsString( 'cli:list', $process->getOutput() ); - $this->assertStringContainsString( 'db:restore', $process->getOutput() ); + $this->assertStringContainsString( 'db:run', $process->getOutput() ); } public function testGfSpaceSeparatedCommandNameResolves(): void { @@ -36,11 +36,11 @@ public function testGfSpaceSeparatedCommandNameResolves(): void { $this->markTestSkipped( 'framework vendor/ not installed' ); } - $process = new Process( [ PHP_BINARY, $frameworkRoot . '/bin/gf', 'db', 'restore', '--help', '--no-ansi' ], $frameworkRoot ); + $process = new Process( [ PHP_BINARY, $frameworkRoot . '/bin/gf', 'db', 'run', '--help', '--no-ansi' ], $frameworkRoot ); $process->run(); $this->assertSame( 0, $process->getExitCode(), $process->getOutput() . $process->getErrorOutput() ); - $this->assertStringContainsString( 'db:restore', $process->getOutput() ); + $this->assertStringContainsString( 'db:run', $process->getOutput() ); } } diff --git a/tests/Unit/Cli/MigrateCommandTest.php b/tests/Unit/Cli/MigrateCommandTest.php new file mode 100644 index 0000000..bf6de73 --- /dev/null +++ b/tests/Unit/Cli/MigrateCommandTest.php @@ -0,0 +1,190 @@ +, 1: array} */ + private function v6Fixture(): array { + $appJson = [ + 'app' => [ 'title' => 'Permits API', 'guid' => 'f1f2f3' ], + 'email' => [ 'fromAddress' => 'noreply@example.gov', 'fromName' => 'Permits' ], + 'settings' => [ 'useSession' => false ], + ]; + + $environmentJson = [ + 'type' => 'prod', + 'serverName' => 'permits.example.gov', + 'rootUrl' => 'https://permits.example.gov', + 'basePath' => '/api/', + 'baseUrl' => 'https://permits.example.gov/api/', + 'cookieUrl' => 'https://permits.example.gov', + 'phpPath' => 'E:\\php', + 'mongoDatabases' => [ + [ 'default' => true, 'database' => 'permits', 'uri' => 'mongodb+srv://user:hunter2@cluster/' ], + ], + 'microsoft' => [ 'clientId' => 'abc', 'clientSecret' => 'super-secret', 'tenant' => 'contoso' ], + 'jwtAuth' => [ 'tokenIssuedBy' => 'https://permits.example.gov', 'redirectAfterLoginUrl' => 'https://permits.example.gov/app/in' ], + ]; + + return [ $appJson, $environmentJson ]; + } + + + public function testAppJsonSectionsMergeIntoTheUnifiedConfig(): void { + [ $appJson, $environmentJson ] = $this->v6Fixture(); + + $plan = migrateCommand::plan( $appJson, $environmentJson ); + + self::assertSame( 'Permits API', $plan[ 'config' ][ 'app' ][ 'title' ] ); + self::assertSame( 'f1f2f3', $plan[ 'config' ][ 'app' ][ 'guid' ] ); + self::assertSame( 'noreply@example.gov', $plan[ 'config' ][ 'email' ][ 'fromAddress' ] ); + self::assertFalse( $plan[ 'config' ][ 'settings' ][ 'useSession' ] ); + } + + + public function testRemovedKeysAreDroppedAndReported(): void { + [ $appJson, $environmentJson ] = $this->v6Fixture(); + + $plan = migrateCommand::plan( $appJson, $environmentJson ); + + foreach( [ 'serverName', 'cookieUrl', 'phpPath', 'baseUrl' ] as $removed ) { + self::assertArrayNotHasKey( $removed, $plan[ 'config' ] ); + self::assertNotEmpty( + array_filter( $plan[ 'warnings' ], fn( string $warning ): bool => str_contains( $warning, '"' . $removed . '"' ) ), + $removed . ' should be reported, not silently dropped' + ); + } + } + + + /** The point of the whole exercise: credentials leave the committed file. */ + public function testCredentialsBecomeSecretReferencesAndTheValuesMoveToTheEnv(): void { + [ $appJson, $environmentJson ] = $this->v6Fixture(); + + $plan = migrateCommand::plan( $appJson, $environmentJson ); + + self::assertSame( '%env(secret:MONGO_URI)%', $plan[ 'config' ][ 'mongoDatabases' ][ 0 ][ 'uri' ] ); + self::assertSame( '%env(MONGO_DATABASE)%', $plan[ 'config' ][ 'mongoDatabases' ][ 0 ][ 'database' ] ); + self::assertSame( '%env(secret:MICROSOFT_CLIENT_SECRET)%', $plan[ 'config' ][ 'microsoft' ][ 'clientSecret' ] ); + + self::assertSame( 'mongodb+srv://user:hunter2@cluster/', $plan[ 'env' ][ 'MONGO_URI' ] ); + self::assertSame( 'permits', $plan[ 'env' ][ 'MONGO_DATABASE' ] ); + self::assertSame( 'super-secret', $plan[ 'env' ][ 'MICROSOFT_CLIENT_SECRET' ] ); + + self::assertTrue( $plan[ 'secrets' ][ 'MONGO_URI' ] ); + self::assertTrue( $plan[ 'secrets' ][ 'MICROSOFT_CLIENT_SECRET' ] ); + self::assertFalse( $plan[ 'secrets' ][ 'MONGO_DATABASE' ] ); + } + + + public function testNonSecretIdentityValuesBecomePlainReferences(): void { + [ $appJson, $environmentJson ] = $this->v6Fixture(); + + $plan = migrateCommand::plan( $appJson, $environmentJson ); + + self::assertSame( '%env(APP_TYPE)%', $plan[ 'config' ][ 'type' ] ); + self::assertSame( '%env(APP_ROOT_URL)%', $plan[ 'config' ][ 'rootUrl' ] ); + self::assertSame( '%env(APP_BASE_PATH)%', $plan[ 'config' ][ 'basePath' ] ); + self::assertSame( 'prod', $plan[ 'env' ][ 'APP_TYPE' ] ); + self::assertSame( '/api/', $plan[ 'env' ][ 'APP_BASE_PATH' ] ); + } + + + public function testEmptyValuesAreLeftAloneRatherThanBecomingRequiredReferences(): void { + $plan = migrateCommand::plan( [], [ 'type' => 'local', 'microsoft' => [ 'clientSecret' => '' ] ] ); + + self::assertSame( '', $plan[ 'config' ][ 'microsoft' ][ 'clientSecret' ] ); + self::assertArrayNotHasKey( 'MICROSOFT_CLIENT_SECRET', $plan[ 'env' ] ); + } + + + public function testSecondAndSubsequentDatabasesGetDistinctVariables(): void { + $plan = migrateCommand::plan( [], [ + 'mongoDatabases' => [ + [ 'database' => 'primary', 'uri' => 'mongodb://one' ], + [ 'database' => 'archive', 'uri' => 'mongodb://two' ], + ], + ] ); + + self::assertSame( '%env(secret:MONGO_URI)%', $plan[ 'config' ][ 'mongoDatabases' ][ 0 ][ 'uri' ] ); + self::assertSame( '%env(secret:MONGO_URI_2)%', $plan[ 'config' ][ 'mongoDatabases' ][ 1 ][ 'uri' ] ); + self::assertSame( 'mongodb://one', $plan[ 'env' ][ 'MONGO_URI' ] ); + self::assertSame( 'mongodb://two', $plan[ 'env' ][ 'MONGO_URI_2' ] ); + } + + + /** + * An IIS application that upgrades must not silently stop writing its log files — + * changing destination is a decision for whoever containerises it. + */ + public function testLoggingDestinationIsPinnedToFileToPreserveV6Behaviour(): void { + [ $appJson, $environmentJson ] = $this->v6Fixture(); + + $plan = migrateCommand::plan( $appJson, $environmentJson ); + + self::assertSame( 'file', $plan[ 'config' ][ 'logging' ][ 'destination' ] ); + self::assertNotEmpty( array_filter( $plan[ 'warnings' ], fn( string $w ): bool => str_contains( $w, 'logging.destination' ) ) ); + } + + + public function testSqlDatabasesAreReportedRatherThanGuessedAt(): void { + $plan = migrateCommand::plan( [], [ 'sqlDatabases' => [ [ 'name' => 'legacy', 'dsn' => 'pgsql:host=db' ] ] ] ); + + self::assertSame( [ [ 'name' => 'legacy', 'dsn' => 'pgsql:host=db' ] ], $plan[ 'config' ][ 'sqlDatabases' ] ); + self::assertNotEmpty( array_filter( $plan[ 'warnings' ], fn( string $w ): bool => str_contains( $w, 'sqlDatabases' ) ) ); + } + + + public function testMissingGuidIsReportedBecauseOauthUsesItAsTheClientId(): void { + $plan = migrateCommand::plan( [ 'app' => [ 'title' => 'No Guid' ] ], [ 'type' => 'local' ] ); + + self::assertNotEmpty( array_filter( $plan[ 'warnings' ], fn( string $w ): bool => str_contains( $w, 'app.guid' ) ) ); + } + + + public function testReferenceRendering(): void { + self::assertSame( '%env(FOO)%', migrateCommand::reference( 'FOO', false ) ); + self::assertSame( '%env(secret:FOO)%', migrateCommand::reference( 'FOO', true ) ); + } + + + public function testDeadFilesPresentFindsBothFixedPathsAndEnvironmentVariants(): void { + $root = sys_get_temp_dir() . '/gcgov-migrate-test-' . uniqid(); + mkdir( $root . '/app/config', 0777, true ); + mkdir( $root . '/www', 0777, true ); + touch( $root . '/app/config/app.json' ); + touch( $root . '/app/config/environment.json' ); + touch( $root . '/app/config/environment-prod.json' ); + touch( $root . '/www/web-prod.config' ); + + $present = migrateCommand::deadFilesPresent( $root ); + + self::assertContains( 'app/config/app.json', $present ); + self::assertContains( 'app/config/environment.json', $present ); + self::assertContains( 'app/config/environment-prod.json', $present ); + self::assertContains( 'www/web-prod.config', $present ); + self::assertNotContains( 'update-production.ps1', $present, 'only files that actually exist' ); + + foreach( [ $root . '/app/config/app.json', $root . '/app/config/environment.json', $root . '/app/config/environment-prod.json', $root . '/www/web-prod.config' ] as $file ) { + unlink( $file ); + } + rmdir( $root . '/app/config' ); + rmdir( $root . '/app' ); + rmdir( $root . '/www' ); + rmdir( $root ); + } + +} diff --git a/tests/Unit/Cli/RouteCatalogTest.php b/tests/Unit/Cli/RouteCatalogTest.php index cdc970b..143dc8c 100644 --- a/tests/Unit/Cli/RouteCatalogTest.php +++ b/tests/Unit/Cli/RouteCatalogTest.php @@ -34,9 +34,31 @@ protected function tearDown(): void { rmdir( $this->tempRootDir ); } - public function testGetMergedRoutesReturnsAppRoutes(): void { + public function testGetMergedRoutesReturnsFrameworkAndAppRoutes(): void { $routes = router::getMergedRoutes( [] ); - $this->assertCount( 3, $routes ); + + // 3 stub app routes + the framework's own two health routes, which every + // application gets whether it asked for them or not. + $this->assertCount( 5, $routes ); + } + + + public function testHealthRoutesAreContributedFirstAndUnauthenticated(): void { + $routes = router::getMergedRoutes( [] ); + + $healthRoutes = array_values( array_filter( $routes, fn( $route ) => str_contains( $route->route, '/health' ) ) ); + $this->assertCount( 2, $healthRoutes ); + + $paths = array_map( fn( $route ) => $route->route, $healthRoutes ); + $this->assertSame( [ '/api/health', '/api/health/ready' ], $paths ); + + foreach( $healthRoutes as $healthRoute ) { + $this->assertFalse( $healthRoute->authentication, 'a prober holds no token' ); + } + + // Contributed before anything else, so an application defining its own /health + // collides at boot rather than shadowing the deploy gate. + $this->assertSame( '/api/health', $routes[ 0 ]->route ); } public function testGetCliRoutesFiltersToCliMethodOnly(): void { diff --git a/tests/Unit/Cli/TokenReplacerTest.php b/tests/Unit/Cli/TokenReplacerTest.php deleted file mode 100644 index 98780d3..0000000 --- a/tests/Unit/Cli/TokenReplacerTest.php +++ /dev/null @@ -1,118 +0,0 @@ -tempRootDir = sys_get_temp_dir() . '/gcgov-tokenreplacer-test-' . uniqid(); - mkdir( $this->tempRootDir . '/app/config', 0777, true ); - mkdir( $this->tempRootDir . '/vendor/some/package', 0777, true ); - mkdir( $this->tempRootDir . '/srv', 0777, true ); - } - - protected function tearDown(): void { - $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $this->tempRootDir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); - foreach( $iterator as $file ) { - $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); - } - rmdir( $this->tempRootDir ); - } - - public function testReplacesTokensInEligibleExtensions(): void { - file_put_contents( $this->tempRootDir . '/app/config/app.json', '{"title":"{app_title}"}' ); - file_put_contents( $this->tempRootDir . '/setup.php', 'tempRootDir . '/readme.md', 'title: {app_title}' ); - - $modified = tokenReplacer::replaceInTree( $this->tempRootDir, [ '{app_title}' => 'Widget API' ] ); - - $this->assertCount( 2, $modified ); - $this->assertSame( '{"title":"Widget API"}', file_get_contents( $this->tempRootDir . '/app/config/app.json' ) ); - $this->assertSame( 'tempRootDir . '/setup.php' ) ); - // .md is not in the eligible extension list - $this->assertSame( 'title: {app_title}', file_get_contents( $this->tempRootDir . '/readme.md' ) ); - } - - public function testVendorIsExcluded(): void { - file_put_contents( $this->tempRootDir . '/vendor/some/package/file.json', '{"a":"{app_title}"}' ); - file_put_contents( $this->tempRootDir . '/web.config', '{app_title}' ); - - $modified = tokenReplacer::replaceInTree( $this->tempRootDir, [ '{app_title}' => 'X' ] ); - - $this->assertSame( [ str_replace( '\\', '/', $this->tempRootDir ) . '/web.config' ], $modified ); - $this->assertStringContainsString( '{app_title}', (string)file_get_contents( $this->tempRootDir . '/vendor/some/package/file.json' ) ); - } - - public function testSrvPhpIniFilesAreReplaced(): void { - // regression: the scaffold's per-environment php.ini files live under srv/ - // (srv/app.local-cli/php.ini etc.) and MUST receive token replacement - mkdir( $this->tempRootDir . '/srv/app.local-cli', 0777, true ); - file_put_contents( $this->tempRootDir . '/srv/app.local-cli/php.ini', 'xdebug.output_dir ="{app_absolute_path}\srv\profile"' . "\n" . 'guid={app_guid}' ); - - $modified = tokenReplacer::replaceInTree( $this->tempRootDir, [ '{app_absolute_path}' => 'E:\Web\api', '{app_guid}' => 'abc-123' ] ); - - $this->assertSame( [ str_replace( '\\', '/', $this->tempRootDir ) . '/srv/app.local-cli/php.ini' ], $modified ); - $contents = (string)file_get_contents( $this->tempRootDir . '/srv/app.local-cli/php.ini' ); - $this->assertStringContainsString( 'xdebug.output_dir ="E:\Web\api\srv\profile"', $contents ); - $this->assertStringContainsString( 'guid=abc-123', $contents ); - } - - public function testBackslashesAreEscapedInJsonFilesOnly(): void { - file_put_contents( $this->tempRootDir . '/a.json', '{"path":"{app_absolute_path}"}' ); - file_put_contents( $this->tempRootDir . '/a.ini', 'path={app_absolute_path}' ); - - tokenReplacer::replaceInTree( $this->tempRootDir, [ '{app_absolute_path}' => 'E:\Web\api' ] ); - - $this->assertSame( '{"path":"E:\\\\Web\\\\api"}', file_get_contents( $this->tempRootDir . '/a.json' ) ); - $this->assertSame( 'path=E:\Web\api', file_get_contents( $this->tempRootDir . '/a.ini' ) ); - } - - public function testEmptyValuesAreSkipped(): void { - file_put_contents( $this->tempRootDir . '/a.json', '{"title":"{app_title}"}' ); - - $modified = tokenReplacer::replaceInTree( $this->tempRootDir, [ '{app_title}' => '' ] ); - - $this->assertSame( [], $modified ); - $this->assertSame( '{"title":"{app_title}"}', file_get_contents( $this->tempRootDir . '/a.json' ) ); - } - - public function testDockerTemplateExtensionsAreEligible(): void { - file_put_contents( $this->tempRootDir . '/default.conf.template', 'server_name {app_server_name};' ); - file_put_contents( $this->tempRootDir . '/docker-compose.yml', 'image: {app_title}' ); - file_put_contents( $this->tempRootDir . '/config.yaml', 'title: {app_title}' ); - file_put_contents( $this->tempRootDir . '/.env.example', 'APP_TITLE={app_title}' ); - file_put_contents( $this->tempRootDir . '/nginx.conf', 'root {app_absolute_path};' ); - - $modified = tokenReplacer::replaceInTree( $this->tempRootDir, [ - '{app_server_name}' => 'api.example.com', - '{app_title}' => 'Widget API', - '{app_absolute_path}' => '/var/www/api', - ] ); - - $this->assertContains( str_replace( '\\', '/', $this->tempRootDir ) . '/default.conf.template', $modified ); - $this->assertContains( str_replace( '\\', '/', $this->tempRootDir ) . '/docker-compose.yml', $modified ); - $this->assertContains( str_replace( '\\', '/', $this->tempRootDir ) . '/config.yaml', $modified ); - $this->assertContains( str_replace( '\\', '/', $this->tempRootDir ) . '/.env.example', $modified ); - $this->assertContains( str_replace( '\\', '/', $this->tempRootDir ) . '/nginx.conf', $modified ); - $this->assertStringContainsString( 'server_name api.example.com;', (string)file_get_contents( $this->tempRootDir . '/default.conf.template' ) ); - } - - - public function testFormatRelativeUrl(): void { - $this->assertSame( '/api/', tokenReplacer::formatRelativeUrl( 'api' ) ); - $this->assertSame( '/api/', tokenReplacer::formatRelativeUrl( '/api/' ) ); - $this->assertSame( 'api/', tokenReplacer::formatRelativeUrl( 'api', true, false ) ); - $this->assertSame( '/', tokenReplacer::formatRelativeUrl( '/' ) ); - $this->assertSame( '/a/b/', tokenReplacer::formatRelativeUrl( 'a\b' ) ); - } - -} diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 43caee4..c1267dd 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -115,4 +115,78 @@ public function testIsFinalClass(): void { $this->assertTrue( ( new \ReflectionClass( config::class ) )->isFinal() ); } + + /** + * The keys are gitignored, so they are never in a built image and must be provisioned + * to a path outside the application tree. Before v7 the path was hard-coded. + */ + public function testJwtKeyPathDefaultsToSrvButHonoursTheConfiguredPath(): void { + $unified = config::getEnvironmentConfig(); + $original = $unified->jwtAuth->keyPath; + + try { + $unified->jwtAuth->keyPath = ''; + $this->assertSame( config::getSrvDir() . 'jwtCertificates/', config::getJwtKeyPath() ); + + $unified->jwtAuth->keyPath = '/run/secrets/jwt'; + $this->assertSame( '/run/secrets/jwt/', config::getJwtKeyPath(), 'always returned with a trailing slash' ); + + $unified->jwtAuth->keyPath = '/run/secrets/jwt/'; + $this->assertSame( '/run/secrets/jwt/', config::getJwtKeyPath(), 'a configured trailing slash is not doubled' ); + } + finally { + $unified->jwtAuth->keyPath = $original; + } + } + + + /** Configuring issuer and audience separately from rootUrl/basePath only invites drift. */ + public function testJwtIssuerAndAudienceDeriveFromTheApplicationUrlWhenNotSet(): void { + $unified = config::getEnvironmentConfig(); + $originalIssuer = $unified->jwtAuth->tokenIssuedBy; + $originalAudience = $unified->jwtAuth->tokenPermittedFor; + + try { + $unified->jwtAuth->tokenIssuedBy = ''; + $unified->jwtAuth->tokenPermittedFor = ''; + $this->assertSame( config::getRootUrl(), config::getTokenIssuedBy() ); + $this->assertSame( config::getBasePath(), config::getTokenPermittedFor() ); + + $unified->jwtAuth->tokenIssuedBy = 'https://explicit.example.gov'; + $this->assertSame( 'https://explicit.example.gov', config::getTokenIssuedBy() ); + } + finally { + $unified->jwtAuth->tokenIssuedBy = $originalIssuer; + $unified->jwtAuth->tokenPermittedFor = $originalAudience; + } + } + + + /** @return iterable */ + public static function removedAccessorProvider(): iterable { + yield 'getServerName' => [ 'getServerName' ]; + yield 'getCookieUrl' => [ 'getCookieUrl' ]; + yield 'getPhpPath' => [ 'getPhpPath' ]; + } + + + /** + * Confirmed unread by the framework and by all five framework services before removal. + * + */ + #[\PHPUnit\Framework\Attributes\DataProvider('removedAccessorProvider')] + public function testAccessorsForUnreadConfigValuesAreGone( string $accessor ): void { + $this->assertFalse( method_exists( config::class, $accessor ) ); + } + + + public function testUnreadConfigPropertiesAreGoneFromTheModel(): void { + $properties = array_keys( get_object_vars( new unifiedConfig() ) ); + + foreach( [ 'serverName', 'cookieUrl', 'phpPath' ] as $removed ) { + $this->assertNotContains( $removed, $properties ); + } + $this->assertContains( 'app', $properties, 'app.guid stays — the oauth server uses it as the client_id' ); + } + } diff --git a/tests/Unit/Services/Environment/ConfigLoaderTest.php b/tests/Unit/Services/Environment/ConfigLoaderTest.php index 2b9ad52..ece3886 100644 --- a/tests/Unit/Services/Environment/ConfigLoaderTest.php +++ b/tests/Unit/Services/Environment/ConfigLoaderTest.php @@ -56,19 +56,6 @@ public function testConfigFilePathIsRootConfigJson(): void { } - public function testLoadStripsEnvironmentsSectionBeforeResolving(): void { - // PROD_MONGO_URI is unset — the active load must succeed anyway because the - // environments section is removed before resolution. - $this->writeConfig( [ - 'type' => 'local', - 'mongoDatabases' => [ [ 'default' => true, 'database' => 'db', 'uri' => 'mongodb://local:27017' ] ], - 'environments' => [ 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'db', 'uri' => '%env(PROD_MONGO_URI)%' ] ] ] ], - ] ); - - $config = configLoader::load( $this->tempDir ); - $this->assertInstanceOf( unifiedConfig::class, $config ); - $this->assertSame( 'local', $config->type ); - } public function testLoadThrowsWhenConfigMissing(): void { @@ -77,60 +64,15 @@ public function testLoadThrowsWhenConfigMissing(): void { } - public function testLoadVariantEnvironmentResolvesPrefixedVariables(): void { - putenv( 'PROD_MONGO_URI=mongodb://prod:27017/db' ); - $_ENV[ 'PROD_MONGO_URI' ] = 'mongodb://prod:27017/db'; - $this->writeConfig( [ - 'type' => 'local', - 'environments' => [ 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'default' => true, 'database' => 'db', 'uri' => '%env(PROD_MONGO_URI)%' ] ] ] ], - ] ); - $variant = configLoader::loadVariantEnvironment( $this->tempDir, 'prod' ); - $this->assertSame( 'prod', $variant->type ); - $this->assertSame( 'mongodb://prod:27017/db', $variant->mongoDatabases[ 0 ]->uri ); - } - public function testLoadVariantEnvironmentThrowsWhenEntryMissing(): void { - $this->writeConfig( [ 'type' => 'local', 'environments' => [ 'staging' => [ 'type' => 'staging' ] ] ] ); - try { - configLoader::loadVariantEnvironment( $this->tempDir, 'prod' ); - $this->fail( 'Expected environmentException' ); - } - catch( environmentException $e ) { - $this->assertStringContainsString( 'No "prod" entry', $e->getMessage() ); - $this->assertStringContainsString( 'staging', $e->getMessage() ); - } - } - public function testLoadVariantEnvironmentThrowsWhenNoEnvironmentsSection(): void { - $this->writeConfig( [ 'type' => 'local' ] ); - $this->expectException( environmentException::class ); - configLoader::loadVariantEnvironment( $this->tempDir, 'prod' ); - } - - public function testVariantNamesListsSortedKeysWithoutResolution(): void { - // %env references present but unset — variantNames must not resolve them. - $this->writeConfig( [ - 'type' => 'local', - 'environments' => [ - 'staging' => [ 'type' => 'staging', 'mongoDatabases' => [ [ 'uri' => '%env(STAGING_UNSET)%' ] ] ], - 'prod' => [ 'type' => 'prod', 'mongoDatabases' => [ [ 'uri' => '%env(PROD_UNSET)%' ] ] ], - ], - ] ); - $this->assertSame( [ 'prod', 'staging' ], configLoader::variantNames( $this->tempDir ) ); - } - public function testVariantNamesEmptyWhenNoEnvironmentsOrNoFile(): void { - $this->assertSame( [], configLoader::variantNames( $this->tempDir ) ); - $this->writeConfig( [ 'type' => 'local' ] ); - $this->assertSame( [], configLoader::variantNames( $this->tempDir ) ); - } - private function deleteDirectory( string $directory ): void { if( !is_dir( $directory ) ) { diff --git a/tests/Unit/Services/Environment/EnvVarResolverTest.php b/tests/Unit/Services/Environment/EnvVarResolverTest.php index 1aa572c..5c348b3 100644 --- a/tests/Unit/Services/Environment/EnvVarResolverTest.php +++ b/tests/Unit/Services/Environment/EnvVarResolverTest.php @@ -7,296 +7,277 @@ use gcgov\framework\services\environment\environmentException; use gcgov\framework\services\environment\envVarResolver; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversClass(envVarResolver::class)] -#[CoversClass(environmentException::class)] final class EnvVarResolverTest extends TestCase { - /** @var array */ - private array $envSnapshot = []; - - /** @var array */ - private array $serverSnapshot = []; - - private string $tempDir = ''; - - - protected function setUp(): void { - $this->envSnapshot = $_ENV; - $this->serverSnapshot = $_SERVER; - $this->tempDir = sys_get_temp_dir() . '/gcgov-envresolver-test-' . uniqid(); - mkdir( $this->tempDir, 0777, true ); - } + /** @var array */ + private array $originalEnv = []; protected function tearDown(): void { - // Unset any variables the tests introduced before restoring snapshots. - foreach( array_keys( $_ENV ) as $key ) { - if( !array_key_exists( $key, $this->envSnapshot ) ) { - putenv( $key ); - } + foreach( array_keys( $this->originalEnv ) as $name ) { + unset( $_ENV[ $name ], $_SERVER[ $name ] ); + putenv( $name ); } - $_ENV = $this->envSnapshot; - $_SERVER = $this->serverSnapshot; + $this->originalEnv = []; - $this->deleteDirectory( $this->tempDir ); + parent::tearDown(); } private function setEnv( string $name, string $value ): void { - $_ENV[ $name ] = $value; + $this->originalEnv[ $name ] = getenv( $name ); + $_ENV[ $name ] = $value; putenv( $name . '=' . $value ); } - public function testFastPathReturnsIdenticalStringWhenNoEnvReference(): void { - $json = '{"type":"prod","serverName":"api.example.com","port":8080}'; - $this->assertSame( $json, envVarResolver::resolveJson( $json, 'test' ) ); + /** Track a name so tearDown cleans it, without setting it. */ + private function trackEnv( string $name ): void { + $this->originalEnv[ $name ] = getenv( $name ); } - public function testFastPathPreservesMalformedJson(): void { - $json = '{ this is not valid json '; - $this->assertSame( $json, envVarResolver::resolveJson( $json, 'test' ) ); + private function resolve( string $json ): \stdClass { + $resolved = envVarResolver::resolveJson( $json, 'test config' ); + self::assertInstanceOf( \stdClass::class, $resolved ); + + return $resolved; } - public function testInvalidJsonWithEnvReferenceIsPassedThrough(): void { - // Contains %env( so it leaves the fast path, but is not decodable → raw string back. - $json = '{ "uri": "%env(MONGO_URI)%" '; - $this->assertSame( $json, envVarResolver::resolveJson( $json, 'test' ) ); + // --- opting in ------------------------------------------------------------- + + public function testConfigWithoutAnyReferenceIsReturnedByteForByte(): void { + $json = '{ "not json at all'; + self::assertSame( $json, envVarResolver::resolveJson( $json, 'test config' ) ); } - public function testWholeStringResolvesToTypedString(): void { - $this->setEnv( 'MONGO_URI', 'mongodb://db:27017' ); - $result = envVarResolver::resolveJson( '{"uri":"%env(MONGO_URI)%"}', 'test' ); - $this->assertInstanceOf( \stdClass::class, $result ); - $this->assertSame( 'mongodb://db:27017', $result->uri ); + public function testMalformedJsonContainingAReferenceIsHandedBackForTheCallerToReport(): void { + $json = '{ "uri": "%env(ANYTHING)%"'; + self::assertSame( $json, envVarResolver::resolveJson( $json, 'test config' ) ); } - public function testIntProcessorYieldsInteger(): void { - $this->setEnv( 'SMTP_PORT', '2525' ); - $result = envVarResolver::resolveJson( '{"port":"%env(int:SMTP_PORT)%"}', 'test' ); - $this->assertIsInt( $result->port ); - $this->assertSame( 2525, $result->port ); - } + // --- required references --------------------------------------------------- + public function testWholeStringReferenceIsReplacedWithTheValue(): void { + $this->setEnv( 'GF_TEST_URI', 'mongodb://db:27017' ); - public function testFloatProcessorYieldsFloat(): void { - $this->setEnv( 'RATE', '1.5' ); - $result = envVarResolver::resolveJson( '{"rate":"%env(float:RATE)%"}', 'test' ); - $this->assertIsFloat( $result->rate ); - $this->assertSame( 1.5, $result->rate ); + self::assertSame( 'mongodb://db:27017', $this->resolve( '{"uri":"%env(GF_TEST_URI)%"}' )->uri ); } - public function testBoolAndNotProcessors(): void { - $this->setEnv( 'FLAG', 'true' ); - $result = envVarResolver::resolveJson( '{"on":"%env(bool:FLAG)%","off":"%env(not:FLAG)%"}', 'test' ); - $this->assertTrue( $result->on ); - $this->assertFalse( $result->off ); + public function testEmbeddedReferenceIsSubstitutedAsAString(): void { + $this->setEnv( 'GF_TEST_HOST', 'example.org' ); + + self::assertSame( 'https://example.org/api', $this->resolve( '{"url":"https://%env(GF_TEST_HOST)%/api"}' )->url ); } - public function testTrimProcessor(): void { - $this->setEnv( 'PADDED', " spaced \n" ); - $result = envVarResolver::resolveJson( '{"v":"%env(trim:PADDED)%"}', 'test' ); - $this->assertSame( 'spaced', $result->v ); + public function testMissingVariableThrowsNamingIt(): void { + $this->trackEnv( 'GF_TEST_ABSENT' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/GF_TEST_ABSENT/' ); + $this->resolve( '{"uri":"%env(GF_TEST_ABSENT)%"}' ); } - public function testJsonProcessorYieldsStructure(): void { - $this->setEnv( 'ROLES', '["a","b"]' ); - $result = envVarResolver::resolveJson( '{"roles":"%env(json:ROLES)%"}', 'test' ); - $this->assertSame( [ 'a', 'b' ], $result->roles ); + /** + * The behaviour that makes a copied .env full of blank placeholders fail loudly + * instead of silently configuring an application with empty credentials. + */ + public function testVariableSetToTheEmptyStringCountsAsUnset(): void { + $this->setEnv( 'GF_TEST_BLANK', '' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/GF_TEST_BLANK/' ); + $this->resolve( '{"secret":"%env(GF_TEST_BLANK)%"}' ); } - public function testBase64ProcessorUrlSafeTolerant(): void { - // URL-safe base64 of "secret?" without padding - $this->setEnv( 'SECRET_B64', 'c2VjcmV0Pw' ); - $result = envVarResolver::resolveJson( '{"s":"%env(base64:SECRET_B64)%"}', 'test' ); - $this->assertSame( 'secret?', $result->s ); + public function testDefaultProcessorNoLongerExists(): void { + $this->trackEnv( 'GF_TEST_ABSENT' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/Unknown environment processor "default"/' ); + $this->resolve( '{"type":"%env(default:local:GF_TEST_ABSENT)%"}' ); } - public function testEmbeddedReferenceIsStringSubstituted(): void { - $this->setEnv( 'HOST', 'db.internal' ); - $this->setEnv( 'PORT', '27017' ); - $result = envVarResolver::resolveJson( '{"uri":"mongodb://%env(HOST)%:%env(PORT)%/app"}', 'test' ); - $this->assertSame( 'mongodb://db.internal:27017/app', $result->uri ); + /** @return iterable */ + public static function removedProcessorProvider(): iterable { + yield 'not' => [ 'not' ]; + yield 'float' => [ 'float' ]; + yield 'base64' => [ 'base64' ]; + yield 'string' => [ 'string' ]; } - public function testEmbeddedNonScalarThrows(): void { - $this->setEnv( 'ROLES', '["a"]' ); + #[DataProvider('removedProcessorProvider')] + public function testRemovedProcessorsAreRejected( string $processor ): void { + $this->setEnv( 'GF_TEST_VALUE', '1' ); + $this->expectException( environmentException::class ); - envVarResolver::resolveJson( '{"v":"prefix-%env(json:ROLES)%"}', 'test' ); + $this->expectExceptionMessageMatches( '/Unknown environment processor "' . $processor . '"/' ); + $this->resolve( '{"v":"%env(' . $processor . ':GF_TEST_VALUE)%"}' ); } - public function testDefaultWithColonsInValue(): void { - // Var unset → greedy default containing colons is used. - $result = envVarResolver::resolveJson( '{"uri":"%env(default:mongodb://mongodb:27017:MONGO_URI)%"}', 'test' ); - $this->assertSame( 'mongodb://mongodb:27017', $result->uri ); - } + // --- surviving processors -------------------------------------------------- + public function testIntProcessorProducesATypedInt(): void { + $this->setEnv( 'GF_TEST_PORT', '587' ); - public function testDefaultEmptyValue(): void { - $result = envVarResolver::resolveJson( '{"secret":"%env(default::MICROSOFT_CLIENT_SECRET)%"}', 'test' ); - $this->assertSame( '', $result->secret ); + self::assertSame( 587, $this->resolve( '{"port":"%env(int:GF_TEST_PORT)%"}' )->port ); } - public function testDefaultIsIgnoredWhenVariableIsSet(): void { - $this->setEnv( 'MONGO_URI', 'mongodb://real:27017' ); - $result = envVarResolver::resolveJson( '{"uri":"%env(default:mongodb://fallback:27017:MONGO_URI)%"}', 'test' ); - $this->assertSame( 'mongodb://real:27017', $result->uri ); - } + public function testBoolProcessorProducesATypedBool(): void { + $this->setEnv( 'GF_TEST_FLAG', 'true' ); - - public function testComposedIntDefault(): void { - $result = envVarResolver::resolveJson( '{"port":"%env(int:default:587:SMTP_PORT)%"}', 'test' ); - $this->assertIsInt( $result->port ); - $this->assertSame( 587, $result->port ); + self::assertTrue( $this->resolve( '{"flag":"%env(bool:GF_TEST_FLAG)%"}' )->flag ); } - public function testTrimFileChainReadsSecretFile(): void { - $secretFile = $this->tempDir . '/mongo_uri'; - file_put_contents( $secretFile, "mongodb://secret:27017\n" ); - $this->setEnv( 'MONGO_URI_FILE', $secretFile ); - $result = envVarResolver::resolveJson( '{"uri":"%env(trim:file:MONGO_URI_FILE)%"}', 'test' ); - $this->assertSame( 'mongodb://secret:27017', $result->uri ); - } + public function testProcessorsApplyRightToLeft(): void { + $path = tempnam( sys_get_temp_dir(), 'gf' ); + self::assertIsString( $path ); + file_put_contents( $path, " 42\n" ); + $this->setEnv( 'GF_TEST_FILE', $path ); + // int(trim(file(env))) — innermost first. + self::assertSame( 42, $this->resolve( '{"n":"%env(int:trim:file:GF_TEST_FILE)%"}' )->n ); - public function testMissingVariableMessageContainsNameAndSource(): void { - try { - envVarResolver::resolveJson( '{"uri":"%env(MONGO_URI)%"}', '/app/config/environment.json' ); - $this->fail( 'Expected environmentException' ); - } - catch( environmentException $e ) { - $this->assertStringContainsString( 'MONGO_URI', $e->getMessage() ); - $this->assertStringContainsString( '/app/config/environment.json', $e->getMessage() ); - } + unlink( $path ); } - public function testUnknownProcessorThrows(): void { - $this->setEnv( 'X', 'y' ); + public function testUnknownProcessorIsRejected(): void { + $this->setEnv( 'GF_TEST_VALUE', 'x' ); + $this->expectException( environmentException::class ); - envVarResolver::resolveJson( '{"v":"%env(bogus:X)%"}', 'test' ); + $this->expectExceptionMessageMatches( '/Unknown environment processor "rot13"/' ); + $this->resolve( '{"v":"%env(rot13:GF_TEST_VALUE)%"}' ); } - public function testNestedAppDictionaryResolution(): void { - $this->setEnv( 'CRON_URL', 'https://monitor.example.com/hook' ); - $this->setEnv( 'MAX_ITEMS', '25' ); - $json = '{"appDictionary":{"cronMonitorUrl":"%env(CRON_URL)%","limits":{"maxItems":"%env(int:MAX_ITEMS)%"}}}'; - $result = envVarResolver::resolveJson( $json, 'test' ); - $this->assertSame( 'https://monitor.example.com/hook', $result->appDictionary->cronMonitorUrl ); - $this->assertSame( 25, $result->appDictionary->limits->maxItems ); + // --- the secret lookup ----------------------------------------------------- + + public function testSecretFallsBackToThePlainVariableWhenNoFileIsNamed(): void { + $this->setEnv( 'GF_TEST_MONGO', 'mongodb://localhost' ); + $this->trackEnv( 'GF_TEST_MONGO_FILE' ); + + self::assertSame( 'mongodb://localhost', $this->resolve( '{"uri":"%env(secret:GF_TEST_MONGO)%"}' )->uri ); } - public function testResolveDecodedResolvesInPlace(): void { - $this->setEnv( 'RD_URI', 'mongodb://rd:27017' ); - $decoded = json_decode( '{"a":{"uri":"%env(RD_URI)%"}}', false ); - $result = envVarResolver::resolveDecoded( $decoded, 'test' ); - $this->assertSame( $decoded, $result ); - $this->assertSame( 'mongodb://rd:27017', $result->a->uri ); + public function testSecretPrefersTheFileAndTrimsIt(): void { + $path = tempnam( sys_get_temp_dir(), 'gf' ); + self::assertIsString( $path ); + file_put_contents( $path, "mongodb://from-file\n" ); + + $this->setEnv( 'GF_TEST_MONGO', 'mongodb://from-environment' ); + $this->setEnv( 'GF_TEST_MONGO_FILE', $path ); + + self::assertSame( 'mongodb://from-file', $this->resolve( '{"uri":"%env(secret:GF_TEST_MONGO)%"}' )->uri ); + + unlink( $path ); } - // --- request-data injection guard (see BLOCKED_NAME_PREFIXES/BLOCKED_NAMES) --- + /** + * The rule that keeps a failed secret mount from silently resolving to whatever + * stale value happens to be in the environment. + */ + public function testSecretFileThatIsMissingIsAnErrorAndNeverFallsBack(): void { + $this->setEnv( 'GF_TEST_MONGO', 'mongodb://from-environment' ); + $this->setEnv( 'GF_TEST_MONGO_FILE', '/nonexistent/secret/mongo_uri' ); - public function testHttpPrefixedNameIsNeverResolvedFromServer(): void { - // A malicious request header exposed via $_SERVER must not satisfy an env reference. - $_SERVER[ 'HTTP_MONGO_URI' ] = 'mongodb://attacker'; $this->expectException( environmentException::class ); - try { - envVarResolver::resolveJson( '{"uri":"%env(HTTP_MONGO_URI)%"}', 'test' ); - } - finally { - unset( $_SERVER[ 'HTTP_MONGO_URI' ] ); - } + $this->expectExceptionMessageMatches( '/not falling back to GF_TEST_MONGO/' ); + $this->resolve( '{"uri":"%env(secret:GF_TEST_MONGO)%"}' ); } - public function testHttpPrefixedNameIsNeverResolvedFromGetenv(): void { - // Under CGI/FastCGI the header reaches the real process env; the guard must - // still hold at the getenv() fallback, not just $_SERVER. - putenv( 'HTTP_EVIL_VAR=attacker' ); - try { - envVarResolver::resolveJson( '{"v":"%env(HTTP_EVIL_VAR)%"}', 'test' ); - $this->fail( 'Expected environmentException' ); - } - catch( environmentException ) { - $this->addToAssertionCount( 1 ); - } - finally { - putenv( 'HTTP_EVIL_VAR' ); - } + public function testSecretReportsBothNamesWhenNeitherIsSet(): void { + $this->trackEnv( 'GF_TEST_MONGO' ); + $this->trackEnv( 'GF_TEST_MONGO_FILE' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/GF_TEST_MONGO_FILE/' ); + $this->resolve( '{"uri":"%env(secret:GF_TEST_MONGO)%"}' ); } - public function testServerMetaVariableNameIsNeverResolved(): void { - // $_SERVER['SERVER_NAME'] is request-derived (Host header); a %env(SERVER_NAME) - // reference must fail loud, not silently bind to the request value. - $_SERVER[ 'SERVER_NAME' ] = 'evil.host'; - try { - envVarResolver::resolveJson( '{"v":"%env(SERVER_NAME)%"}', 'test' ); - $this->fail( 'Expected environmentException' ); - } - catch( environmentException $e ) { - $this->assertStringContainsString( 'reserved', $e->getMessage() ); - } - // (leave $_SERVER['SERVER_NAME'] — it is part of the real server env; restored in tearDown) + public function testSecretMustBeTheInnermostProcessor(): void { + $this->setEnv( 'GF_TEST_MONGO', 'x' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/"secret" must be the innermost processor/' ); + $this->resolve( '{"uri":"%env(secret:trim:GF_TEST_MONGO)%"}' ); } - public function testBlockedNameStillAllowsDefaultFallback(): void { - // A blocked name is treated as unset, so an explicit default: still applies. - $_SERVER[ 'HTTP_X' ] = 'attacker'; + // --- request-data injection guard ----------------------------------------- + + public function testReservedCgiNamesAreNeverResolvedFromTheEnvironment(): void { + $_SERVER[ 'HTTP_X_INJECTED' ] = 'attacker-controlled'; + try { - $result = envVarResolver::resolveJson( '{"v":"%env(default:safe:HTTP_X)%"}', 'test' ); - $this->assertSame( 'safe', $result->v ); + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/reserved CGI meta-variable/' ); + $this->resolve( '{"v":"%env(HTTP_X_INJECTED)%"}' ); } finally { - unset( $_SERVER[ 'HTTP_X' ] ); + unset( $_SERVER[ 'HTTP_X_INJECTED' ] ); } } - // --- fail-loud on unresolvable references --- + // --- malformed syntax ------------------------------------------------------ - public function testParenInsideDefaultLiteralThrowsInsteadOfSilentPassthrough(): void { + public function testUnterminatedReferenceIsAnErrorRatherThanShippedVerbatim(): void { $this->expectException( environmentException::class ); - envVarResolver::resolveJson( '{"v":"%env(default:pa)ss:SOME_UNSET_VAR_X)%"}', 'test' ); + $this->expectExceptionMessageMatches( '/Unresolvable %env/' ); + $this->resolve( '{"v":"prefix %env(GF_TEST_UNTERMINATED"}' ); } - public function testLiteralEnvPrefixInValueThrows(): void { - // A value containing the literal '%env(' that isn't a valid reference must not - // ship unresolved. + public function testInvalidVariableNameIsRejected(): void { $this->expectException( environmentException::class ); - envVarResolver::resolveJson( '{"v":"prefix %env( not a ref"}', 'test' ); + $this->expectExceptionMessageMatches( '/is not a valid variable name/' ); + $this->resolve( '{"v":"%env(trim:not a name)%"}' ); } - private function deleteDirectory( string $directory ): void { - if( !is_dir( $directory ) ) { - return; - } - $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); - foreach( $iterator as $file ) { - $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); - } - rmdir( $directory ); + // --- reference enumeration ------------------------------------------------- + + public function testCollectReferencesFindsEveryVariableWithoutResolvingThem(): void { + $decoded = json_decode( '{ + "type": "%env(APP_TYPE)%", + "nested": { "uri": "%env(secret:MONGO_URI)%" }, + "list": [ { "url": "https://%env(APP_HOST)%/api" } ], + "literal": "no reference here" + }', false ); + self::assertInstanceOf( \stdClass::class, $decoded ); + + $references = envVarResolver::collectReferences( $decoded, 'test config' ); + + self::assertSame( [ 'APP_TYPE' => false, 'MONGO_URI' => true, 'APP_HOST' => false ], $references ); + } + + + public function testCollectReferencesTreatsANameUsedBothWaysAsASecret(): void { + $decoded = json_decode( '{"a":"%env(MONGO_URI)%","b":"%env(secret:MONGO_URI)%"}', false ); + self::assertInstanceOf( \stdClass::class, $decoded ); + + self::assertSame( [ 'MONGO_URI' => true ], envVarResolver::collectReferences( $decoded, 'test config' ) ); } } diff --git a/tests/Unit/Services/LogTest.php b/tests/Unit/Services/LogTest.php index fdaa205..279cbf5 100644 --- a/tests/Unit/Services/LogTest.php +++ b/tests/Unit/Services/LogTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use gcgov\framework\models\config\environment\logging; use gcgov\framework\services\log; #[CoversClass(log::class)] @@ -28,6 +29,28 @@ protected function setUp(): void { $rootDir = dirname( $this->logsDir ); $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'rootDir' ); $prop->setValue( null, $rootDir ); + + $this->setDestination( logging::DESTINATION_FILE ); + } + + + /** The seeded unifiedConfig is shared across tests; put it back. */ + protected function tearDown(): void { + $this->setDestination( logging::DESTINATION_STDERR ); + + parent::tearDown(); + } + + + private function setDestination( string $destination ): void { + $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); + $config = $prop->getValue(); + if( $config instanceof \gcgov\framework\models\unifiedConfig ) { + $config->logging->destination = $destination; + } + + $loggers = new \ReflectionProperty( log::class, 'loggers' ); + $loggers->setValue( null, [] ); } public function testDebugLogWritesToChannelFile(): void { @@ -71,4 +94,30 @@ public function testRepeatedCallsReuseSameLoggerInstance(): void { $this->assertArrayHasKey( 'reuse-channel', $loggers ); } + + /** + * The v7 default. A container's filesystem does not survive a deploy, so file logs + * would be per-replica and destroyed on every release. + */ + public function testStderrIsTheDefaultDestinationAndEmitsJsonLines(): void { + $this->setDestination( logging::DESTINATION_STDERR ); + + $before = $this->logsDir . '/stderr-channel.log'; + log::error( 'stderr-channel', 'to stderr' ); + + $this->assertFileDoesNotExist( $before, 'stderr destination must not write a log file' ); + $this->assertSame( logging::DESTINATION_STDERR, ( new logging() )->destination ); + $this->assertTrue( ( new logging() )->writesToStderr() ); + $this->assertFalse( ( new logging() )->writesToFile() ); + } + + + public function testBothDestinationWritesTheFileAsWell(): void { + $this->setDestination( logging::DESTINATION_BOTH ); + + log::error( 'both-channel', 'to both' ); + + $this->assertFileExists( $this->logsDir . '/both-channel.log' ); + } + } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d0a8fb4..c99a3c6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -42,7 +42,6 @@ public function authentication( \gcgov\framework\models\routeHandler $routeHandl // load a JSON file from disk. $envConfig = new \gcgov\framework\models\unifiedConfig(); $envConfig->basePath = 'api'; -$envConfig->serverName = 'test.local'; $envConfig->rootUrl = 'http://test.local'; $envConfig->type = 'local'; $prop = new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ); From f6dfc78d7d75226ff3d3f2e7f6ec36c86df19759 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 15:26:14 +0000 Subject: [PATCH 12/30] Fold the Framework Services into the framework, activated from config.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framework Services were separate Composer packages, switched on by returning their namespace strings from \app\app::registerFrameworkServiceNamespaces(). That put activation in PHP and configuration in config.json, so "how is auth set up here?" had two answers in two places; it also meant a forgotten namespace was a silent 404 rather than an error, since the router swallowed the ReflectionException. The five services now live in src/services/ and are enabled by a typed `services` section of config.json. Presence enables: a block that is absent is off, a block that is present — even empty — is on, and its contents are that service's settings. This reuses the nullable-section pattern already used by kmsProviders::$gcp, so no new hydration machinery was needed and %env(...) works throughout. registerFrameworkServiceNamespaces() is deleted rather than deprecated. No v7 application is deployed, so there is no installed base a dual mechanism would protect, and carrying both would reintroduce exactly the "which list won?" ambiguity ADR 0001 removed the default: processor to avoid. The two auth services merge into one, chosen by `provider`. Everything downstream of establishing an identity was already identical — the guard, the JWKS document, the short-lived file token — and existed as two near copies that could not be deduplicated while the packages were separate. One provider key also makes two auth providers unrepresentable, so no conflict check is needed. The standalone packages stay published for v6 applications. Since documentation and cronMonitor keep their namespaces, framework v7 declares a `conflict` against all five so an application cannot resolve both and get two definitions of the same class. Also in this change, because the code was being touched anyway: - Split interfaces\router. It required _before()/_after() of every router and the framework only ever called \app\router's, and \app\router's getRunFrameworkServiceRouteAuthentication() was duck-typed via method_exists() with no interface declaring it. Now interfaces\router is getRoutes()+authentication(), interfaces\appRouter adds the lifecycle hooks, and the opt-out is interfaces\router\skipsServiceAuthentication. - Refuse to boot when routes declare authentication:true and neither an auth service nor \app\router::providesAuthentication() will guard them. Such routes were reachable by anyone while looking protected, because the scaffolded authentication() returns true. - MFA enrollment QR codes render as SVG. BaconQrCodeProvider defaults to the Imagick backend, which would have made ext-imagick a hard requirement of the framework for every application in order to draw a square. - The MFA issuer label was the literal 'GCGOV Narcotics Tracking', so every application's authenticator showed that name. It is now the app title. - The Microsoft token exchange constructed a controllerException without throwing it, so a missing Authorization header fell through instead of returning 401. - The openid-configuration route pointed at method 'openid'; the controller method is openId, so calling it fataled. A new test asserts every route registered by a service resolves to a real method. - The documentation service derives the framework directory from its own location instead of hardcoding vendor/gcgov/framework. Under a path repository that hardcoded path did not exist and was silently dropped, so nothing of the framework was documented in development. Its src/services is now scanned too, so the services' own annotations reach the document for the first time. - Removed settings.useSession, which nothing read, and gave sqlDatabase the _afterJsonDeserialize guard unifiedConfig and mongoDatabase already have — without it a partial entry raised a raw PHP Error instead of a configException. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KmBBiV3fQaaRdrmspZarS5 --- composer.json | 13 +- phpstan-stubs/app.php | 13 +- src/cli/appContext.php | 17 +- src/cli/application.php | 20 +- src/cli/commandProvider.php | 14 +- src/cli/routeCatalog.php | 2 +- src/config.php | 19 + src/framework.php | 7 +- src/interfaces/app.php | 17 +- src/interfaces/appRouter.php | 27 + src/interfaces/router.php | 17 +- .../router/skipsServiceAuthentication.php | 27 + src/models/config/app/settings.php | 2 - src/models/config/environment/cronMonitor.php | 26 + src/models/config/environment/sqlDatabase.php | 17 +- src/models/config/services.php | 26 + src/models/config/services/auth.php | 85 +++ src/models/config/services/auth/msFront.php | 13 + src/models/config/services/auth/oauth.php | 15 + src/models/config/services/documentation.php | 13 + src/models/config/services/userCrud.php | 13 + src/models/unifiedConfig.php | 12 +- src/router.php | 104 ++- src/services/auth/controllers/auth.php | 67 ++ src/services/auth/guard.php | 84 +++ .../providers/msFront/controllers/auth.php | 106 +++ .../auth/providers/oauth/controllers/auth.php | 659 ++++++++++++++++++ .../oauth/models/configureMfaResponse.php | 41 ++ .../oauth/models/requireMfaResponse.php | 25 + .../oauth/models/stdAuthResponse.php | 37 + .../oauth/models/verifyMfaCodeRequest.php | 18 + .../oauth/models/verifyMfaSecretRequest.php | 22 + .../providers/oauth/services/multifactor.php | 172 +++++ src/services/auth/router.php | 60 ++ src/services/cronMonitor/cronMonitor.php | 54 ++ .../controllers/documentation.php | 113 +++ src/services/documentation/router.php | 28 + src/services/userCrud/controllers/user.php | 205 ++++++ src/services/userCrud/router.php | 41 ++ tests/Unit/FrameworkStructuralTest.php | 18 +- tests/Unit/Interfaces/InterfacesTest.php | 31 +- .../Models/Config/AppConfigModelsTest.php | 10 +- .../Unit/Models/Config/ServicesConfigTest.php | 143 ++++ .../RouterAuthenticationGuaranteeTest.php | 83 +++ tests/Unit/RouterServiceActivationTest.php | 137 ++++ tests/bootstrap.php | 14 +- 46 files changed, 2587 insertions(+), 100 deletions(-) create mode 100644 src/interfaces/appRouter.php create mode 100644 src/interfaces/router/skipsServiceAuthentication.php create mode 100644 src/models/config/environment/cronMonitor.php create mode 100644 src/models/config/services.php create mode 100644 src/models/config/services/auth.php create mode 100644 src/models/config/services/auth/msFront.php create mode 100644 src/models/config/services/auth/oauth.php create mode 100644 src/models/config/services/documentation.php create mode 100644 src/models/config/services/userCrud.php create mode 100644 src/services/auth/controllers/auth.php create mode 100644 src/services/auth/guard.php create mode 100644 src/services/auth/providers/msFront/controllers/auth.php create mode 100644 src/services/auth/providers/oauth/controllers/auth.php create mode 100644 src/services/auth/providers/oauth/models/configureMfaResponse.php create mode 100644 src/services/auth/providers/oauth/models/requireMfaResponse.php create mode 100644 src/services/auth/providers/oauth/models/stdAuthResponse.php create mode 100644 src/services/auth/providers/oauth/models/verifyMfaCodeRequest.php create mode 100644 src/services/auth/providers/oauth/models/verifyMfaSecretRequest.php create mode 100644 src/services/auth/providers/oauth/services/multifactor.php create mode 100644 src/services/auth/router.php create mode 100644 src/services/cronMonitor/cronMonitor.php create mode 100644 src/services/documentation/controllers/documentation.php create mode 100644 src/services/documentation/router.php create mode 100644 src/services/userCrud/controllers/user.php create mode 100644 src/services/userCrud/router.php create mode 100644 tests/Unit/Models/Config/ServicesConfigTest.php create mode 100644 tests/Unit/RouterAuthenticationGuaranteeTest.php create mode 100644 tests/Unit/RouterServiceActivationTest.php diff --git a/composer.json b/composer.json index aad47b5..a788cfe 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,18 @@ "ext-pdo": "*", "ext-sodium": "*", "ext-openssl": "*", - "spatie/typescript-transformer": "^2.4" + "spatie/typescript-transformer": "^2.4", + "doctrine/annotations": "^2.0", + "robthree/twofactorauth": "^3.0", + "bacon/bacon-qr-code": "^3.0", + "andrewsauder/microsoft-services": "^1.4" + }, + "conflict": { + "gcgov/framework-service-auth-oauth-server": "*", + "gcgov/framework-service-auth-ms-front": "*", + "gcgov/framework-service-user-crud": "*", + "gcgov/framework-service-documentation": "*", + "gcgov/framework-service-gcgov-cron-monitor": "*" }, "suggest": { "ext-zip": "Required by `gf chrome:install` / `gf chrome:update` to extract the chrome-headless-shell download" diff --git a/phpstan-stubs/app.php b/phpstan-stubs/app.php index 220cb3a..ad70fa0 100644 --- a/phpstan-stubs/app.php +++ b/phpstan-stubs/app.php @@ -12,19 +12,16 @@ class app implements \gcgov\framework\interfaces\app { public static function _before(): void {} public static function _after(): void {} - - - public function registerFrameworkServiceNamespaces(): array - { - return []; - } - } -class router implements \gcgov\framework\interfaces\router { +class router implements \gcgov\framework\interfaces\appRouter { public static function _before(): void {} public static function _after(): void {} + public function providesAuthentication(): bool { + return false; + } + /** * @return \gcgov\framework\models\route[] */ diff --git a/src/cli/appContext.php b/src/cli/appContext.php index 6d7f572..04701bc 100644 --- a/src/cli/appContext.php +++ b/src/cli/appContext.php @@ -11,7 +11,7 @@ * gf command tiers: * - no context needed: list, help, completion — work anywhere * - root only: env, db:*, cert:*, deploy — need locate() + config JSON - * - app boot: cli, cli:list — need assertAppLoadable() + getServiceNamespaces() + * - app boot: cli, cli:list — need assertAppLoadable() */ final class appContext { @@ -138,21 +138,6 @@ public function assertAppLoadable(): void { } - /** - * Service namespaces registered by the app. Instantiates \app\app but deliberately - * does NOT run \app\app::_before() — no lifecycle side effects for enumeration. - * - * @return string[] - * @throws \gcgov\framework\cli\cliException - */ - public function getServiceNamespaces(): array { - $this->assertAppLoadable(); - $app = new \app\app(); - - return $app->registerFrameworkServiceNamespaces(); - } - - /** * Load and resolve {root}/config.json — no \app boot, no ext-mongodb. * {root}/.env is loaded first; the real process environment wins. diff --git a/src/cli/application.php b/src/cli/application.php index 68ac0b7..57a432a 100644 --- a/src/cli/application.php +++ b/src/cli/application.php @@ -83,9 +83,12 @@ public function doRun( InputInterface $input, OutputInterface $output ): int { /** - * Register commands contributed by the app (\app\cli\commandProvider) and by each - * framework-service plugin ({serviceNamespace}\cli\commandProvider). Failures never - * break gf itself — built-in commands always remain available. + * Register commands contributed by the application (\app\cli\commandProvider). + * Failures never break gf itself — built-in commands always remain available. + * + * Framework Services no longer contribute commands through discovery: they live in + * the framework now, so a service command is registered in this constructor like any + * other built-in. */ private function discoverProviderCommands(): void { try { @@ -94,14 +97,9 @@ private function discoverProviderCommands(): void { return; } - $namespaces = $context->getServiceNamespaces(); - $namespaces[] = '\app'; - - foreach( $namespaces as $namespace ) { - $providerClass = '\\' . trim( $namespace, '\\' ) . '\cli\commandProvider'; - if( class_exists( $providerClass ) && is_a( $providerClass, commandProvider::class, true ) ) { - $this->addCommands( $providerClass::getCommands() ); - } + $providerClass = '\app\cli\commandProvider'; + if( class_exists( $providerClass ) && is_a( $providerClass, commandProvider::class, true ) ) { + $this->addCommands( $providerClass::getCommands() ); } } catch( \Throwable $e ) { diff --git a/src/cli/commandProvider.php b/src/cli/commandProvider.php index 2df65aa..fd3c0a0 100644 --- a/src/cli/commandProvider.php +++ b/src/cli/commandProvider.php @@ -5,14 +5,14 @@ /** * Contribute custom commands to the gf CLI. * - * Applications implement this as \app\cli\commandProvider (file: app/cli/commandProvider.php). - * Framework-service plugins implement it as \gcgov\framework\services\{name}\cli\commandProvider - * (file: src/cli/commandProvider.php in the plugin repo). gf discovers implementations - * automatically for the app namespace and every namespace returned by - * \app\app::registerFrameworkServiceNamespaces() — no additional registration required. + * Applications implement this as \app\cli\commandProvider (file: app/cli/commandProvider.php); + * gf discovers it automatically — no additional registration required. * - * Commands are ordinary symfony/console commands. Plugin commands should namespace their - * names to avoid collisions (e.g. 'docs:regenerate'). + * Framework Services do not use this. They live in the framework, so a command belonging + * to one is registered directly in \gcgov\framework\cli\application's constructor. + * + * Commands are ordinary symfony/console commands. Namespace their names to avoid + * collisions with the built-ins (e.g. 'widget:reindex'). */ interface commandProvider { diff --git a/src/cli/routeCatalog.php b/src/cli/routeCatalog.php index b07e559..c29ad1c 100644 --- a/src/cli/routeCatalog.php +++ b/src/cli/routeCatalog.php @@ -37,7 +37,7 @@ public static function getAllRoutes( appContext $context ): array { $context->assertAppLoadable(); try { - return \gcgov\framework\router::getMergedRoutes( $context->getServiceNamespaces() ); + return \gcgov\framework\router::getMergedRoutes(); } catch( \gcgov\framework\exceptions\configException $e ) { throw new cliException( 'Could not load routes: ' . $e->getMessage() . ' Ensure {root}/config.json exists and every %env(...) it references has a value (validate with `gf env`).', 0, $e ); diff --git a/src/config.php b/src/config.php index 134b22d..2d4b789 100644 --- a/src/config.php +++ b/src/config.php @@ -6,11 +6,13 @@ use gcgov\framework\models\config\app\app; use gcgov\framework\models\config\app\email; use gcgov\framework\models\config\app\settings; +use gcgov\framework\models\config\environment\cronMonitor; use gcgov\framework\models\config\environment\jwtAuth; use gcgov\framework\models\config\environment\logging; use gcgov\framework\models\config\environment\microsoft; use gcgov\framework\models\config\environment\payjunction; use gcgov\framework\models\config\environment\sqlDatabase; +use gcgov\framework\models\config\services; use gcgov\framework\models\unifiedConfig; @@ -329,4 +331,21 @@ public static function getAppDictionary(): array { return self::unifiedConfig()->appDictionary; } + + /** + * Which Framework Services this application runs. A service whose block is absent is + * not constructed and contributes no routes. + * + * @throws \gcgov\framework\exceptions\configException + */ + public static function getServices(): services { + return self::unifiedConfig()->services; + } + + + /** @throws \gcgov\framework\exceptions\configException */ + public static function getCronMonitor(): cronMonitor { + return self::unifiedConfig()->cronMonitor; + } + } diff --git a/src/framework.php b/src/framework.php index 5dbba4c..4407c01 100644 --- a/src/framework.php +++ b/src/framework.php @@ -19,13 +19,14 @@ public function runApp() : string { //appConfig \app\app::_before(); - $app = new \app\app(); - $serviceNamespaces = $app->registerFrameworkServiceNamespaces(); + // Constructed for its side effects only: since Framework Services moved into + // config.json, the instance itself has nothing the lifecycle needs to read. + new \app\app(); //router \app\router::_before(); try { - $router = new \gcgov\framework\router( $serviceNamespaces ); + $router = new \gcgov\framework\router(); $routeHandler = $router->route(); } catch( routeException $e ) { diff --git a/src/interfaces/app.php b/src/interfaces/app.php index ee23195..2ee0d60 100644 --- a/src/interfaces/app.php +++ b/src/interfaces/app.php @@ -3,12 +3,17 @@ namespace gcgov\framework\interfaces; +/** + * The application entry class — \app\app. + * + * It declares no methods of its own beyond the lifecycle hooks, but it is not optional: + * \gcgov\framework\config derives every path in the framework by reflecting on this + * class's file location, so an application without it cannot resolve its own root. + * + * Framework Services used to be registered here, by returning their namespaces from + * registerFrameworkServiceNamespaces(). They are now declared in the `services` section + * of config.json, so that activation and configuration are one statement rather than two. + */ interface app extends lifecycle\before, lifecycle\after { - /** - * Return an array of the namespaces where framework services installed via composer - * @return string[] - */ - public function registerFrameworkServiceNamespaces() : array; - } diff --git a/src/interfaces/appRouter.php b/src/interfaces/appRouter.php new file mode 100644 index 0000000..530bdcd --- /dev/null +++ b/src/interfaces/appRouter.php @@ -0,0 +1,27 @@ +url )!==''; + } + +} diff --git a/src/models/config/environment/sqlDatabase.php b/src/models/config/environment/sqlDatabase.php index a2bbcf2..408ab84 100644 --- a/src/models/config/environment/sqlDatabase.php +++ b/src/models/config/environment/sqlDatabase.php @@ -17,6 +17,21 @@ class sqlDatabase extends \andrewsauder\jsonDeserialize\jsonDeserialize { public function __construct() { + $this->readAccount = new sqlDatabaseUser(); + $this->writeAccount = new sqlDatabaseUser(); } -} \ No newline at end of file + + protected function _afterJsonDeserialize(): void { + // jsonDeserialize may instantiate this class without invoking the constructor, + // leaving these typed-non-nullable properties uninitialized. Without this guard an + // entry that omits readAccount/writeAccount raises "must not be accessed before + // initialization" at the point of use rather than a configException at load. + foreach( [ 'readAccount', 'writeAccount' ] as $property ) { + if( !( new \ReflectionProperty( $this, $property ) )->isInitialized( $this ) ) { + $this->$property = new sqlDatabaseUser(); + } + } + } + +} diff --git a/src/models/config/services.php b/src/models/config/services.php new file mode 100644 index 0000000..a16c481 --- /dev/null +++ b/src/models/config/services.php @@ -0,0 +1,26 @@ +provider, self::PROVIDERS, true ) ) { + throw new environmentException( 'services.auth.provider must be one of "' . implode( '", "', self::PROVIDERS ) . '"' . ( $this->provider==='' ? ', and is missing' : ', not "' . $this->provider . '"' ) . '.' ); + } + + // A block for the provider that is not selected is configuration that would never + // be read. Saying so is the whole point of a fail-closed configuration: a setting + // that appears to do something and does nothing is the failure mode to prevent. + foreach( self::PROVIDERS as $provider ) { + if( $provider!==$this->provider && $this->$provider!==null ) { + throw new environmentException( 'services.auth.' . $provider . ' is configured but services.auth.provider is "' . $this->provider . '", so nothing would read it. Remove the "' . $provider . '" block or change the provider.' ); + } + } + + // The selected provider's block may be omitted entirely; a missing section + // hydrating to its defaults is the established rule for every other section. + if( $this->provider===self::PROVIDER_OAUTH && $this->oauth===null ) { + $this->oauth = new oauth(); + } + if( $this->provider===self::PROVIDER_MS_FRONT && $this->msFront===null ) { + $this->msFront = new msFront(); + } + } + + + public function isOauth(): bool { + return $this->provider===self::PROVIDER_OAUTH; + } + + + public function isMsFront(): bool { + return $this->provider===self::PROVIDER_MS_FRONT; + } + +} diff --git a/src/models/config/services/auth/msFront.php b/src/models/config/services/auth/msFront.php new file mode 100644 index 0000000..5ec11cb --- /dev/null +++ b/src/models/config/services/auth/msFront.php @@ -0,0 +1,13 @@ + + */ + public array $authorizeUrlParameters = []; + +} diff --git a/src/models/config/services/documentation.php b/src/models/config/services/documentation.php new file mode 100644 index 0000000..d63ef38 --- /dev/null +++ b/src/models/config/services/documentation.php @@ -0,0 +1,13 @@ +jwtAuth = new jwtAuth(); $this->payjunction = new payjunction(); $this->logging = new logging(); + $this->cronMonitor = new cronMonitor(); + $this->services = new services(); } protected function _afterJsonDeserialize(): void { @@ -76,7 +86,7 @@ protected function _afterJsonDeserialize(): void { // constructor, leaving typed-non-nullable properties uninitialized. // Use reflection so we can ask the engine about init state without // PHPStan narrowing the check away. - foreach( [ 'app' => app::class, 'email' => email::class, 'settings' => settings::class, 'microsoft' => microsoft::class, 'jwtAuth' => jwtAuth::class, 'payjunction' => payjunction::class, 'logging' => logging::class ] as $property => $class ) { + foreach( [ 'app' => app::class, 'email' => email::class, 'settings' => settings::class, 'microsoft' => microsoft::class, 'jwtAuth' => jwtAuth::class, 'payjunction' => payjunction::class, 'logging' => logging::class, 'cronMonitor' => cronMonitor::class, 'services' => services::class ] as $property => $class ) { if( !( new \ReflectionProperty( $this, $property ) )->isInitialized( $this ) ) { $this->$property = new $class(); } diff --git a/src/router.php b/src/router.php index 757a776..c02ae88 100644 --- a/src/router.php +++ b/src/router.php @@ -4,46 +4,52 @@ use gcgov\framework\exceptions\routeException; use gcgov\framework\services\log; -use ReflectionClass; final class router { - private \gcgov\framework\interfaces\router $appRouter; + private \gcgov\framework\interfaces\appRouter $appRouter; /** @var \gcgov\framework\interfaces\router[] $serviceRouters */ private array $serviceRouters = []; /** - * @param string[] $serviceNamespaces + * Framework Services are declared in config.json's `services` section. Each is + * constructed here only if its block is present, and is handed its own typed + * configuration — there is no discovery step and no service configures itself from a + * singleton the application had to remember to tweak in \app\app::_before(). * - * @throws \gcgov\framework\exceptions\routeException + * @throws \gcgov\framework\exceptions\configException */ - public function __construct( array $serviceNamespaces ) { + public function __construct() { if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- constructing framework\router' ); - log::debug( 'Framework Lifecycle', '-Router- check for routers in services' ); } // The framework's own routes (health checks) come first and are not opt-in: a // deploy pipeline cannot gate on an endpoint an application chose not to have. $this->serviceRouters[] = new \gcgov\framework\services\health\router(); - foreach($serviceNamespaces as $serviceNamespace) { - try { - $reflectionClassOfServiceRouter = new ReflectionClass( $serviceNamespace . '\router' ); - if(config::getLogging()->lifecycle) { - log::debug( 'Framework Lifecycle', '-Router- instantiate ' . $serviceNamespace . '\router' ); - } - $serviceRouter = $reflectionClassOfServiceRouter->newInstance(); - if(!($serviceRouter instanceof \gcgov\framework\interfaces\router)) { - error_log($serviceNamespace.'\router must implement \gcgov\framework\interfaces\router if it wants to be used as a router by gcgov\framework'); - continue; - } - $this->serviceRouters[] = $serviceRouter; + $services = config::getServices(); + + if( $services->auth!==null ) { + if(config::getLogging()->lifecycle) { + log::debug( 'Framework Lifecycle', '-Router- enable auth service (' . $services->auth->provider . ')' ); } - catch( \ReflectionException $e ) { - //service does not have a router, no problem + $this->serviceRouters[] = new \gcgov\framework\services\auth\router( $services->auth ); + } + + if( $services->userCrud!==null ) { + if(config::getLogging()->lifecycle) { + log::debug( 'Framework Lifecycle', '-Router- enable userCrud service' ); } + $this->serviceRouters[] = new \gcgov\framework\services\userCrud\router(); + } + + if( $services->documentation!==null ) { + if(config::getLogging()->lifecycle) { + log::debug( 'Framework Lifecycle', '-Router- enable documentation service' ); + } + $this->serviceRouters[] = new \gcgov\framework\services\documentation\router(); } if(config::getLogging()->lifecycle) { @@ -56,6 +62,7 @@ public function __construct( array $serviceNamespaces ) { /** * @return \gcgov\framework\models\routeHandler * @throws \gcgov\framework\exceptions\routeException + * @throws \gcgov\framework\exceptions\configException */ public function route(): \gcgov\framework\models\routeHandler { if(config::getLogging()->lifecycle) { @@ -65,6 +72,9 @@ public function route(): \gcgov\framework\models\routeHandler { //get all routes $routes = $this->getRoutes(); + // Refuse to serve routes that believe they are protected but are not. + self::assertAuthenticationIsProvided( $routes, config::getServices()->auth!==null, $this->appRouter->providesAuthentication() ); + //map routes to \FastRoute dispatcher $routeDispatcher = \FastRoute\simpleDispatcher( function( \FastRoute\RouteCollector $r ) use ( $routes ) { foreach( $routes as $route ) { @@ -114,7 +124,7 @@ public function route(): \gcgov\framework\models\routeHandler { } $runServiceRouting = true; - if(method_exists($this->appRouter, 'getRunFrameworkServiceRouteAuthentication')) { + if($this->appRouter instanceof \gcgov\framework\interfaces\router\skipsServiceAuthentication) { $runServiceRouting = $this->appRouter->getRunFrameworkServiceRouteAuthentication( $routeHandler ); } if($runServiceRouting) { @@ -123,12 +133,12 @@ public function route(): \gcgov\framework\models\routeHandler { } foreach($this->serviceRouters as $serviceRouter) { if(config::getLogging()->lifecycle) { - log::debug( 'Framework Lifecycle', '-Router- run framework\services\\' . get_class( $serviceRouter ) . '\router authentication()' ); + log::debug( 'Framework Lifecycle', '-Router- run ' . get_class( $serviceRouter ) . ' authentication()' ); } $serviceAllowRoute = $serviceRouter->authentication( $routeHandler ); if(!$serviceAllowRoute) { if(config::getLogging()->lifecycle) { - log::debug( 'Framework Lifecycle', '-Router- framework\services\\' . get_class( $serviceRouter ) . '\router authentication() returned false; raising route exception' ); + log::debug( 'Framework Lifecycle', '-Router- ' . get_class( $serviceRouter ) . ' authentication() returned false; raising route exception' ); } throw new \gcgov\framework\exceptions\routeException ( 'Authentication failed', 401 ); } @@ -149,17 +159,53 @@ public function route(): \gcgov\framework\models\routeHandler { /** - * Build the full merged route table (service routes first, then app routes) without - * dispatching a request. Used by the gf CLI to enumerate routes. + * Routes that declare authentication:true are only actually guarded by an + * authentication service, or by an application that authenticates its own routes. + * With neither, \app\router::authentication() is the only guard left — and the + * scaffolded implementation of it returns true for everyone, so those routes would be + * open to the world while looking protected in the route table. + * + * Refusing to serve is the only safe reading: a configuration that cannot protect what + * it claims to protect is a broken configuration, not a permissive one. + * + * @param \gcgov\framework\models\route[] $routes + * + * @throws \gcgov\framework\exceptions\configException + */ + public static function assertAuthenticationIsProvided( array $routes, bool $authServiceEnabled, bool $appProvidesAuthentication ): void { + if( $authServiceEnabled || $appProvidesAuthentication ) { + return; + } + + $unguarded = []; + foreach( $routes as $route ) { + if( $route->authentication ) { + $unguarded[] = ( is_array( $route->httpMethod ) ? implode( '|', $route->httpMethod ) : $route->httpMethod ) . ' ' . $route->route; + } + } + + if( count( $unguarded )===0 ) { + return; + } + + throw new \gcgov\framework\exceptions\configException( count( $unguarded ) . ' route(s) require authentication but no authentication service is enabled: ' . implode( ', ', $unguarded ) . '. Enable one by adding a "services": { "auth": { "provider": "oauth" } } block to config.json, or — if \app\router authenticates these routes itself — have it implement \gcgov\framework\interfaces\appRouter::providesAuthentication() returning true.', 500 ); + } + + + /** + * Build the full merged route table (framework routes first, then enabled Framework + * Services, then the application) without dispatching a request. Used by the gf CLI to + * enumerate routes. * - * @param string[] $serviceNamespaces Namespaces returned by \app\app::registerFrameworkServiceNamespaces() + * Deliberately does not run assertAuthenticationIsProvided(): enumerating the routes of + * a misconfigured application is exactly when that listing is most useful. * * @return \gcgov\framework\models\route[] * @throws \gcgov\framework\exceptions\routeException - * @throws \gcgov\framework\exceptions\configException Missing/invalid app/config/environment.json + * @throws \gcgov\framework\exceptions\configException Missing/invalid {root}/config.json */ - public static function getMergedRoutes( array $serviceNamespaces ): array { - return ( new self( $serviceNamespaces ) )->getRoutes(); + public static function getMergedRoutes(): array { + return ( new self() )->getRoutes(); } diff --git a/src/services/auth/controllers/auth.php b/src/services/auth/controllers/auth.php new file mode 100644 index 0000000..016b222 --- /dev/null +++ b/src/services/auth/controllers/auth.php @@ -0,0 +1,67 @@ + $jwtService->getJwksKeys() + ] ); + } + + + /** + * @OA\Get( + * path="/auth/fileToken", + * tags={"Auth"}, + * description="Exchange your access token for a very short lived one that may be passed as a URL parameter on routes which allow it" + * ) + * + * @return \gcgov\framework\models\controllerDataResponse + */ + public function fileToken(): controllerDataResponse { + $authUser = \gcgov\framework\services\request::getAuthUser(); + + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser, new \DateInterval( 'PT5S' ) ); + + return new controllerDataResponse( [ + 'accessToken' => $accessToken->toString() + ] ); + } + + + public static function _after(): void { + } + + + public static function _before(): void { + } + +} diff --git a/src/services/auth/guard.php b/src/services/auth/guard.php new file mode 100644 index 0000000..4eaa416 --- /dev/null +++ b/src/services/auth/guard.php @@ -0,0 +1,84 @@ +validateAccessToken( $accessToken ); + if( !( $parsedToken instanceof \Lcobucci\JWT\UnencryptedToken ) ) { + throw new routeException( 'Token parsing failed', 401 ); + } + + $tokenData = $parsedToken->claims()->get( 'data' ); + $tokenScopes = (array)$parsedToken->claims()->get( 'scope' ); + + $authUser = \gcgov\framework\services\request::getAuthUser(); + $authUser->setFromJwtToken( is_array( $tokenData ) ? $tokenData : [], $tokenScopes ); + } + catch( serviceException $e ) { + //JWT uses invalid kid/guid + throw new routeException( $e->getMessage(), 401, $e ); + } + catch( \Lcobucci\JWT\Encoding\CannotDecodeContent|\Lcobucci\JWT\Token\UnsupportedHeaderFound|\Lcobucci\JWT\Token\InvalidTokenStructure $e ) { + //JWT did not parse + throw new routeException( 'Token parsing failed', 401, $e ); + } + catch( \Lcobucci\JWT\Validation\RequiredConstraintsViolated $e ) { + //JWT parsed successfully but failed validation + $violationMessages = []; + foreach( $e->violations() as $violation ) { + $violationMessages[] = $violation->getMessage(); + } + throw new routeException( 'Token validation failed: ' . implode( ', ', $violationMessages ), 401, $e ); + } + + foreach( $routeHandler->requiredRoles as $requiredRole ) { + if( !in_array( $requiredRole, $authUser->roles ) ) { + throw new routeException( 'User does not have the permission "' . $requiredRole . '" required to access this content', 403 ); + } + } + + return true; + } + + + /** + * The Authorization header, or — only on routes that opt in — the fileAccessToken + * query parameter. A URL-borne token ends up in logs, referrers and browser history, + * which is why it is per-route opt-in and why the tokens minted for it expire in + * seconds. + * + * @throws \gcgov\framework\exceptions\routeException + */ + private static function readToken( \gcgov\framework\models\routeHandler $routeHandler ): string { + if( isset( $_SERVER[ 'HTTP_AUTHORIZATION' ] ) ) { + return (string)$_SERVER[ 'HTTP_AUTHORIZATION' ]; + } + + if( !$routeHandler->allowShortLivedUrlTokens || !isset( $_GET[ 'fileAccessToken' ] ) ) { + throw new routeException( 'Missing Authorization', 401 ); + } + + return (string)$_GET[ 'fileAccessToken' ]; + } + +} diff --git a/src/services/auth/providers/msFront/controllers/auth.php b/src/services/auth/providers/msFront/controllers/auth.php new file mode 100644 index 0000000..60926aa --- /dev/null +++ b/src/services/auth/providers/msFront/controllers/auth.php @@ -0,0 +1,106 @@ +clientId = config::getMicrosoft()->clientId; + $microsoftConfig->clientSecret = config::getMicrosoft()->clientSecret; + $microsoftConfig->tenant = config::getMicrosoft()->tenant; + $microsoftConfig->fromAddress = config::getMicrosoft()->fromAddress; + $microsoftAuthService = new \andrewsauder\microsoftServices\auth( $microsoftConfig ); // \gcgov\framework\services\microsoft\auth(); + $tokenInfo = $microsoftAuthService->verify(); + $user = $this->lookupUserMicrosoftTokenInfo( $tokenInfo ); + + //convert \app\models\user to authUser singleton + $authUser = \gcgov\framework\services\request::getAuthUser(); + $authUser->setFromUser( $user ); + + //generate our custom jwt and return it to the user + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser ); + + //return data + $data = [ + 'accessToken' => $accessToken->toString() + ]; + + return new controllerDataResponse( $data ); + + } + + + + /** + * Processed after lifecycle is complete with this instance + */ + public static function _after(): void { + + } + + + /** + * Processed prior to __constructor() being called + */ + public static function _before(): void { + + } + + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + private function lookupUserMicrosoftTokenInfo( \andrewsauder\microsoftServices\components\tokenInformation $tokenInfo ): \gcgov\framework\services\mongodb\models\auth\user { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + + //get user from database using Microsoft unique Id + try { + $authConfig = config::getServices()->auth; + + $user = $userClassName::getFromOauth( + email: $tokenInfo->email, + externalId: $tokenInfo->oid, + externalProvider: 'MicrosoftGraph', + firstName: $tokenInfo->name, + addIfNotExisting: !$authConfig->blockNewUsers, + rolesForNewUser: $authConfig->defaultNewUserRoles ); + } + catch( modelException $e ) { + throw new \gcgov\framework\exceptions\controllerException( 'The Microsoft user may need to be added to the user collection within the application. This Microsoft user could not be found in the app user list by external id and does not have a preferred username to lookup by email.', 404, $e ); + } + + try { + $updateResult = $userClassName::save( $user ); + } + catch( modelException $e ) { + //failed to save external id - no problem, we will try again next sign in + } + + return $user; + } + +} diff --git a/src/services/auth/providers/oauth/controllers/auth.php b/src/services/auth/providers/oauth/controllers/auth.php new file mode 100644 index 0000000..86329f5 --- /dev/null +++ b/src/services/auth/providers/oauth/controllers/auth.php @@ -0,0 +1,659 @@ + $baseUrl, + "authorization_endpoint" => $baseUrl . "/auth/authorize", + "token_endpoint" => $baseUrl . "/auth/authorize", + //"userinfo_endpoint" => "https://example.com/userinfo", + "jwks_uri" => $baseUrl . "/.well-known/jwks.json", + "end_session_endpoint" => $baseUrl . "/auth/out", + "scopes_supported" => [ + "login" + ], + "response_types_supported" => [ + "code", + "token" + ], + "token_endpoint_auth_methods_supported" => [ + "client_secret_post", + "private_key_jwt", + ], + + ]; + + //custom result for /.well-known/jwks.json + return new controllerDataResponse( $data ); + } + + + + /** + * @OA\Get( + * path="/auth/authorize", + * tags={"Auth"}, + * description="Direct access by end user - will send through to selected third party Oauth provider", + * @OA\Parameter( + * name="response_type", + * in="query", + * description="Only supported value is 'code'", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter( + * name="client_id", + * in="query", + * description="Must match app config guid", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter( + * name="scope", + * in="query", + * description="Must be oauth provider name string (ex. microsoft)", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Response( + * response="200", + * description="Successfully fetched", + * @OA\JsonContent( + * type="array", + * @OA\Items(ref="#/components/schemas/stdAuthResponse") + * ) + * ) + * ) + * + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function oauthGetAuthorize(): controllerDataResponse { + if( empty( $_GET[ 'response_type' ] ) || $_GET[ 'response_type' ]!='code' ) { + throw new controllerException( 'Invalid response type', 401 ); + } + if( empty( $_GET[ 'client_id' ] ) || $_GET[ 'client_id' ]!=config::getApp()->guid ) { + throw new controllerException( 'Invalid client id', 401 ); + } + if( empty( $_GET[ 'scope' ] ) ) { + throw new controllerException( 'Invalid scope', 401 ); + } + + if( session_status()!=PHP_SESSION_ACTIVE ) { + session_start(); + } + unset( $_SESSION[ 'auth_state' ] ); + if( !empty( $_GET[ 'state' ] ) ) { + $_SESSION[ 'auth_state' ] = urldecode( $_GET[ 'state' ] ); + } + + $this->oauthHybridAuth( $_GET[ 'scope' ] ); + + return new controllerDataResponse(); + } + + + /** + * Handler for third party oauth provider (authorization_code), exchange + * refresh tokens (refresh_token), and exchange username/password + * (password). OpenAPI documentation for this endpoint is provided in the + * stdAuthResponse schema and the README; the inline @OA annotations were + * removed because the malformed nested structure broke phpDoc parsing. + * + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function oauthPostAuthorize(): controllerDataResponse { + $postData = \gcgov\framework\services\request::getPostData(); + + if( empty( $postData[ 'grant_type' ] ) ) { + throw new controllerException( 'Invalid grant type', 401 ); + } + if( empty( $postData[ 'client_id' ] ) || $postData[ 'client_id' ]!=config::getApp()->guid ) { + throw new controllerException( 'Invalid client id', 401 ); + } + + //route + if( $postData[ 'grant_type' ]=='password' ) { + return new controllerDataResponse( $this->password() ); + } + elseif( $postData[ 'grant_type' ]=='refresh_token' ) { + return new controllerDataResponse( $this->refresh_token() ); + } + elseif( $postData[ 'grant_type' ]=='authorization_code' ) { + return new controllerDataResponse( $this->authorization_code() ); + } + + throw new controllerException( 'Invalid grant type', 401 ); + } + + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + private function password(): stdAuthResponse { + $postData = \gcgov\framework\services\request::getPostData(); + + if( empty( $postData[ 'scope' ] ) || $postData[ 'scope' ]!='login' ) { + throw new controllerException( 'Invalid scope', 401 ); + } + elseif( empty( $postData[ 'username' ] ) ) { + throw new controllerException( 'Username required', 401 ); + } + elseif( empty( $postData[ 'password' ] ) ) { + throw new controllerException( 'Password required', 401 ); + } + + //authenticate the username and password combo + try { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + /** @var \gcgov\framework\services\mongodb\models\auth\user $user */ + $user = $userClassName::verifyUsernamePassword( $postData[ 'username' ], $postData[ 'password' ] ); + } + catch( modelException $e ) { + throw new controllerException( 'Incorrect username or password', 401, $e ); + } + + $authUser = \gcgov\framework\services\request::getAuthUser(); + + //force configuration of MFA if required + if( $user->mfaRequired ) { + + //lock the user roles down but give them an authentication token so that they can verify their MFA + $user->roles = []; + $authUser->setFromUser( $user ); + + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser ); + + if( !$user->mfaConfigured ) { + return multifactor::configureMfaResponse( $user->_id, $accessToken ); + } + else { + return multifactor::requireMfaResponse( $accessToken, $user ); + } + } + + + //create token for valid user + return $this->createAccessTokenResponse( $user ); + } + + + /** + * @OA\Get( + * path="/auth/out", + * tags={"Auth"}, + * description="Sign out", + * @OA\Response( + * response="201", + * description="Successfully fetched", + * ) + * ) + * + * @return \gcgov\framework\models\controllerDataResponse + */ + public function out(): controllerDataResponse { + //unset the session variables + if( isset( $_SESSION ) ) { + foreach( $_SESSION as $key => $value ) { + unset( $_SESSION[ $key ] ); + } + } + + //delete the session cookie + $params = session_get_cookie_params(); + setcookie( session_name(), + '', + time() - 42000, + $params[ "path" ], + $params[ "domain" ], + $params[ "secure" ], + $params[ "httponly" ] ); + + //destroy the session + if( session_status()==PHP_SESSION_ACTIVE ) { + session_destroy(); + } + + //delete all other cookies + $past = time() - 3600; + foreach( $_COOKIE as $key => $value ) { + setcookie( $key, $value, $past, '/' ); + } + + //TODO: burn refresh token + + return new controllerDataResponse( [] ); + } + + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + private function refresh_token(): stdAuthResponse { + $postData = \gcgov\framework\services\request::getPostData(); + + if( empty( $postData[ 'refresh_token' ] ) ) { + throw new controllerException( 'Invalid refresh token', 401 ); + } + + try { + \gcgov\framework\services\jwtAuth\models\userRefreshToken::removeOutdatedRefreshTokens(); + } + catch( modelException $e ) { + throw new controllerException( 'Failed to remove outdated refresh token', 500 ); + } + + $jwtValidationService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + + try { + $userId = $jwtValidationService->validateRefreshToken( $postData[ 'refresh_token' ] ); + } + catch( \Exception $e ) { + throw new controllerException( $e->getMessage(), 401, $e ); + } + + //invalidate the existing token because we will provide a new one with the response + try { + $jwtValidationService->deleteRefreshToken( $postData[ 'refresh_token' ] ); + } + catch( modelException $e ) { + throw new controllerException( 'Failed to remove existing refresh token', 500 ); + } + + try { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + /** @var \gcgov\framework\services\mongodb\models\auth\user $user */ + $user = $userClassName::getOne( $userId ); + } + catch( modelException $e ) { + throw new controllerException( 'Refresh token corrupted', 401 ); + } + + //create token for good user + $authUser = \gcgov\framework\services\request::getAuthUser(); + $authUser->setFromUser( $user ); + + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser ); + try { + $refreshToken = $jwtService->createRefreshToken( $authUser ); + } + catch( modelException $e ) { + throw new controllerException( 'Failed to create new refresh token', 500 ); + } + + return new stdAuthResponse( $accessToken, $refreshToken ); + + } + + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + private function authorization_code(): stdAuthResponse { + $postData = \gcgov\framework\services\request::getPostData(); + + if( empty( $postData[ 'code' ] ) ) { + throw new controllerException( 'Invalid code', 401 ); + } + + //lookup auth code + try { + $userAuthorizationCode = \gcgov\framework\services\jwtAuth\models\userAuthorizationCode::getOne( $postData[ 'code' ] ); + } + catch( modelException $e ) { + throw new controllerException( 'Invalid code', 401 ); + } + + try { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + /** @var \gcgov\framework\interfaces\auth\user $user */ + $user = $userClassName::getOne( $userAuthorizationCode->userId ); + } + catch( modelException $e ) { + throw new controllerException( 'Authorization code corrupted', 401 ); + } + + //create token for good user + $authUser = \gcgov\framework\services\request::getAuthUser(); + $authUser->setFromUser( $user ); + + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser ); + try { + $refreshToken = $jwtService->createRefreshToken( $authUser ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), 500, $e ); + } + + return new stdAuthResponse( $accessToken, $refreshToken ); + } + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + public function oauthHybridAuth( string $provider = '' ): void { + $provider = strtolower( $provider ); + + if( $provider=='google' ) { + $provider = "Google"; + } + elseif( $provider=='facebook' ) { + $provider = "Facebook"; + } + elseif( $provider=='microsoft' || $provider=='microsoftgraph' ) { + $provider = "MicrosoftGraph"; + + if( empty( config::getMicrosoft()->clientId ) ) { + throw new controllerException( 'Microsoft client id has not been defined in the app config file. /app/config/environment.json > microsoft.clientId', 400 ); + } + if( empty( config::getMicrosoft()->clientSecret ) ) { + throw new controllerException( 'Microsoft client secret has not been defined in the app config file. /app/config/environment.json > microsoft.clientSecret', 400 ); + } + if( empty( config::getMicrosoft()->tenant ) ) { + throw new controllerException( 'Microsoft tenant has not been defined in the app config file. /app/config/environment.json > microsoft.tenant', 400 ); + } + + } + + if( empty( $provider ) ) { + throw new controllerException( 'We do not currently support logging in with the service you provided', 400 ); + } + + $authConfig = config::getServices()->auth; + + $config = [ + //Location where to redirect users once they authenticate with a provider + 'callback' => config::getBaseUrl() . '/auth/hybridauth/' . $provider, + + //Providers specifics + 'providers' => [ + 'Google' => [ + 'enabled' => false, + 'keys' => [ + 'id' => '', + 'secret' => '' + ], + 'authorize_url_parameters' => $authConfig->oauth->authorizeUrlParameters + ], + 'Facebook' => [ + 'enabled' => false, + 'keys' => [ + 'id' => '', + 'secret' => '' + ], + 'authorize_url_parameters' => $authConfig->oauth->authorizeUrlParameters + ], + 'MicrosoftGraph' => [ + 'enabled' => true, + 'keys' => [ + 'id' => config::getMicrosoft()->clientId, + 'secret' => config::getMicrosoft()->clientSecret + ], + 'tenant' => config::getMicrosoft()->tenant, + 'scope' => 'openid offline_access profile email User.Read', + 'authorize_url_parameters' => $authConfig->oauth->authorizeUrlParameters + ], + ] + ]; + + try { + //Feed configuration array to Hybridauth + $hybridauth = new \Hybridauth\Hybridauth( $config ); + + //Then we can proceed and sign in with Twitter as an example. If you want to use a diffirent provider, + //simply replace 'Twitter' with 'Google' or 'Facebook'. + + //Attempt to authenticate users with a provider by name + $adapter = $hybridauth->authenticate( $provider ); + + //Returns a boolean of whether the user is connected with Twitter + $isConnected = $adapter->isConnected(); + + //Retrieve the user's profile + $oauthProfile = $adapter->getUserProfile(); + + //Disconnect the adapter + $adapter->disconnect(); + } + catch( \Exception $e ) { + error_log( $e ); + $message = $e->getMessage(); + switch( $e->getCode() ) { + case 0 : + $message = "Unspecified error."; + break; + case 1 : + $message = "Hybridauth configuration error."; + break; + case 2 : + $message = "Provider not properly configured."; + break; + case 3 : + $message = "Unknown or disabled provider."; + break; + case 4 : + $message = "Missing provider application credentials."; + break; + case 5 : + $message = "The user has canceled the authentication or the provider refused the connection."; + break; + case 6 : + $message = "User profile request failed. Most likely the user is not connected to the provider and he should authenticate again."; + break; + case 7 : + $message = "User not connected to the provider."; + break; + case 8 : + $message = "Provider does not support this feature."; + break; + } + + header( 'Location: ' . config::getJwtAuth()->redirectAfterLoginUrl . '?errorMessage=' . urlencode( $message ) ); + exit; + } + + if( empty( $oauthProfile->email ) ) { + throw new controllerException( $provider . ' did not provide us with your email address. That is a requirement to sign in.', 401 ); + } + + try { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + /** @var \gcgov\framework\interfaces\auth\user $user */ + $user = $userClassName::getFromOauth( + email: $oauthProfile->email, + externalId: $oauthProfile->identifier, + externalProvider: $provider, + firstName: $oauthProfile->firstName, + lastName: $oauthProfile->lastName, + addIfNotExisting: !$authConfig->blockNewUsers, + rolesForNewUser: $authConfig->defaultNewUserRoles ); + } + catch( modelException $e ) { + header( 'Location: ' . config::getJwtAuth()->redirectAfterLoginUrl . '?errorMessage=' . urlencode( $e->getMessage() ) ); + exit; + } + + try { + $userIdRaw = $user->getId(); + $userIdObject = $userIdRaw instanceof \MongoDB\BSON\ObjectId ? $userIdRaw : new \MongoDB\BSON\ObjectId( (string) $userIdRaw ); + $authorizationCode = new \gcgov\framework\services\jwtAuth\models\userAuthorizationCode( $userIdObject, new \DateInterval( 'PT5M' ) ); + \gcgov\framework\services\jwtAuth\models\userAuthorizationCode::save( $authorizationCode ); + } + catch( modelException $e ) { + throw new controllerException( $provider . 'Server failed to generate an access code.', 500, $e ); + } + + $appendState = ''; + if( !empty( $_SESSION[ 'auth_state' ] ) ) { + $appendState = '&state=' . $_SESSION[ 'auth_state' ]; + } + + if( session_status()==PHP_SESSION_ACTIVE ) { + session_destroy(); + } + + header( 'Location: ' . config::getJwtAuth()->redirectAfterLoginUrl . '?code=' . urlencode( (string)$authorizationCode->_id ) . $appendState ); + exit; + } + + + public function createExternalAppToken( string $appName, \DateInterval $tokenExpiration, \MongoDB\BSON\ObjectId $_id, string $username, string $email, string $name, array $roles = [], string $password = '' ): controllerDataResponse { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + + if( $password=='' ) { + $password = uniqid(); + } + + $user = new $userClassName(); + $user->_id = $_id; + $user->username = $username; + $user->email = $email; + $user->name = $name; + $user->password = $password; + $user->roles = $roles; + $user::save( $user ); + + $authUser = \gcgov\framework\services\request::getAuthUser(); + $authUser->setFromUser( $user ); + + $jwt = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $token = $jwt->createAccessToken( $authUser, $tokenExpiration ); + + if( !file_exists( config::getRootDir() . '/externalAppTokens/' ) ) { + $created = mkdir( config::getRootDir() . '/externalAppTokens/', 777, true ); + if( !$created ) { + log::warning( 'auth', 'Directory "' . config::getRootDir() . '/externalAppTokens/" does not exist and could not be created automatically. Create directory to continue.' ); + throw new controllerException( 'Cannot create token because externalAppTokens directory does not exist' ); + } + } + + $tokenFilePath = config::getRootDir() . '/externalAppTokens/' . formatting::fileName( $appName ) . '.txt'; + + file_put_contents( $tokenFilePath, $token->toString() ); + + return new controllerDataResponse( $tokenFilePath ); + } + + private function createAccessTokenResponse( \gcgov\framework\interfaces\auth\user $user ): stdAuthResponse { + //create token for valid user + $authUser = \gcgov\framework\services\request::getAuthUser(); + $authUser->setFromUser( $user ); + try { + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser ); + + try { + $refreshToken = $jwtService->createRefreshToken( $authUser ); + } + catch( modelException $e ) { + throw new controllerException( 'Server failed to create refresh token', 500, $e ); + } + + return new stdAuthResponse( $accessToken, $refreshToken ); + } + catch( \Exception $e ) { + throw new controllerException( $e->getCode(), $e->getMessage(), $e ); + } + } + + /** + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function verifyMfaSecret(): controllerDataResponse { + $verifyMfaSecretRequestJSON = file_get_contents( 'php://input' ); + try { + $verifyMfaSecretRequest = verifyMfaSecretRequest::jsonDeserialize( $verifyMfaSecretRequestJSON ); + } + catch( jsonDeserializeException $e ) { + throw new controllerException( 'Provided data is not in a valid format', 400, $e ); + } + + $authUser = \gcgov\framework\services\request::getAuthUser(); + + $response = multifactor::verifyMfaSecret( new \MongoDB\BSON\ObjectId( $authUser->userId ), $verifyMfaSecretRequest->userMultifactorId, $verifyMfaSecretRequest->code ); + + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + return new controllerDataResponse( $this->createAccessTokenResponse( $userClassName::getOne($authUser->userId) ) ); + } + + /** + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function verifyMfaCode(): controllerDataResponse { + $verifyMfaCodeRequestJSON = file_get_contents( 'php://input' ); + try { + $verifyMfaCodeRequest = verifyMfaCodeRequest::jsonDeserialize( $verifyMfaCodeRequestJSON ); + } + catch( jsonDeserializeException $e ) { + throw new controllerException( 'Provided data is not in a valid format', 400, $e ); + } + + $authUser = \gcgov\framework\services\request::getAuthUser(); + + $valid = multifactor::isMfaCodeCorrect( new \MongoDB\BSON\ObjectId( $authUser->userId ), $verifyMfaCodeRequest->code ); + if(!$valid) { + throw new controllerException('Invalid code', 500); + } + + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + return new controllerDataResponse( $this->createAccessTokenResponse( $userClassName::getOne($authUser->userId) ) ); + } + +} diff --git a/src/services/auth/providers/oauth/models/configureMfaResponse.php b/src/services/auth/providers/oauth/models/configureMfaResponse.php new file mode 100644 index 0000000..74f02fa --- /dev/null +++ b/src/services/auth/providers/oauth/models/configureMfaResponse.php @@ -0,0 +1,41 @@ +qrCodeDataUri = $qrCodeDataUri; + $this->secret = $userMultifactor->secret; + $this->userId = $userMultifactor->userId; + $this->userMultifactorId = $userMultifactor->_id; + $this->mfaRequired = true; + $this->mfaConfigured = false; + } + +} diff --git a/src/services/auth/providers/oauth/models/requireMfaResponse.php b/src/services/auth/providers/oauth/models/requireMfaResponse.php new file mode 100644 index 0000000..5851553 --- /dev/null +++ b/src/services/auth/providers/oauth/models/requireMfaResponse.php @@ -0,0 +1,25 @@ +mfaRequired = $user->getMfaRequired(); + $this->mfaConfigured = $user->getMfaConfigured(); + } + } + +} diff --git a/src/services/auth/providers/oauth/models/stdAuthResponse.php b/src/services/auth/providers/oauth/models/stdAuthResponse.php new file mode 100644 index 0000000..66888cc --- /dev/null +++ b/src/services/auth/providers/oauth/models/stdAuthResponse.php @@ -0,0 +1,37 @@ +token_type = $tokenType; + $this->expires_in = $accessToken->claims()->get( 'exp' )->getTimestamp() - $now->getTimestamp(); + $this->access_token = $accessToken->toString(); + } + + if( $refreshToken!==null ) { + $this->refresh_token = $refreshToken->toString(); + } + } + +} diff --git a/src/services/auth/providers/oauth/models/verifyMfaCodeRequest.php b/src/services/auth/providers/oauth/models/verifyMfaCodeRequest.php new file mode 100644 index 0000000..1b3edab --- /dev/null +++ b/src/services/auth/providers/oauth/models/verifyMfaCodeRequest.php @@ -0,0 +1,18 @@ +code = $code; + } + +} diff --git a/src/services/auth/providers/oauth/models/verifyMfaSecretRequest.php b/src/services/auth/providers/oauth/models/verifyMfaSecretRequest.php new file mode 100644 index 0000000..601fb08 --- /dev/null +++ b/src/services/auth/providers/oauth/models/verifyMfaSecretRequest.php @@ -0,0 +1,22 @@ +code = $code; + $this->userMultifactorId = $userMultifactorId; + } + +} diff --git a/src/services/auth/providers/oauth/services/multifactor.php b/src/services/auth/providers/oauth/services/multifactor.php new file mode 100644 index 0000000..4d905e6 --- /dev/null +++ b/src/services/auth/providers/oauth/services/multifactor.php @@ -0,0 +1,172 @@ + renders. + */ + private static function qrProvider(): \RobThree\Auth\Providers\Qr\BaconQrCodeProvider { + return new \RobThree\Auth\Providers\Qr\BaconQrCodeProvider( format: 'svg' ); + } + + + public static function requireMfaResponse( ?\Lcobucci\JWT\Token\Plain $accessToken, \gcgov\framework\interfaces\auth\user $user ): requireMfaResponse { + return new requireMfaResponse( $accessToken, $user ); + } + + + /** + * @throws \gcgov\framework\exceptions\controllerException + */ + public static function configureMfaResponse( \MongoDB\BSON\ObjectId $userId, ?\Lcobucci\JWT\Token\Plain $accessToken = null ): configureMfaResponse { + try { + $tfa = new \RobThree\Auth\TwoFactorAuth( self::qrProvider() ); + $secret = $tfa->createSecret(); + } + catch( TwoFactorAuthException $e ) { + throw new controllerException( 'Failed to generate MFA secret', 500 ); + } + + try { + $qrCodeDataUri = $tfa->getQRCodeImageAsDataUri( config::getApp()->title, $secret, 500 ); + } + catch( TwoFactorAuthException $e ) { + throw new controllerException( 'Failed to generate QR code for MFA secret', 500 ); + } + + try { + $oldMultifactorConfigurations = userMultifactor::getAll( [ 'userId' => $userId ] ); + userMultifactor::deleteMany( $oldMultifactorConfigurations ); + + //create new multifactor attempt + $userMultifactor = new userMultifactor( $userId ); + $userMultifactor->secret = $secret; + userMultifactor::save( $userMultifactor ); + } + catch( modelException $e ) { + error_log( $e ); + throw new controllerException( 'Failed to save MFA secret', 500 ); + } + + return new configureMfaResponse( $accessToken, $userMultifactor, $qrCodeDataUri ); + } + + + /** + * @param \MongoDB\BSON\ObjectId $userId + * @param \MongoDB\BSON\ObjectId $userMultifactorId + * @param string $code + * + * @return \gcgov\framework\interfaces\auth\user + * @throws \gcgov\framework\exceptions\controllerException + */ + public static function verifyMfaSecret( \MongoDB\BSON\ObjectId $userId, \MongoDB\BSON\ObjectId $userMultifactorId, string $code ): \gcgov\framework\interfaces\auth\user { + + try { + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + $user = $userClassName::getOne( $userId ); + error_log( $user->password ); + $userMultifactor = userMultifactor::getOneBy( [ '_id' => $userMultifactorId, 'userId' => $user->_id ] ); + } + catch( modelDocumentNotFoundException|modelException $e ) { + throw new controllerException( 'Identifier not found for user', 404 ); + } + + try { + $tfa = new \RobThree\Auth\TwoFactorAuth( self::qrProvider() ); + $timeslice = $userMultifactor->timeslice; + $result = $tfa->verifyCode( $userMultifactor->secret, $code, 1, null, $timeslice ); + + } + catch( TwoFactorAuthException $e ) { + throw new controllerException( 'Not able to check MFA code', 500, $e ); + } + + if( !$result ) { + throw new controllerException( 'Incorrect code provided', 400 ); + } + + try { + $userMultifactor->timeslice = $timeslice; + $userMultifactor->verified = true; + $userMultifactor->verifiedAt = new \DateTimeImmutable(); + userMultifactor::save( $userMultifactor ); + + $user->password = ''; + $user->mfaConfigured = true; + $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); + $userClassName::save( $user ); + + return $user; + } + catch( \Exception $e ) { + throw new controllerException( 'Failed to save MFA configuration', 500, $e ); + } + + } + + + /** + * @param \MongoDB\BSON\ObjectId $userId + * @param string $code + * + * @return bool + * @throws \gcgov\framework\exceptions\controllerException + */ + public static function isMfaCodeCorrect( \MongoDB\BSON\ObjectId $userId, string $code ): bool { + + try { + $userMultifactor = userMultifactor::getOneBy( [ 'userId' => new \MongoDB\BSON\ObjectId( $userId ) ] ); + } + catch( modelDocumentNotFoundException|modelException $e ) { + throw new controllerException( 'Identifier not found for user', 404 ); + } + + try { + $tfa = new \RobThree\Auth\TwoFactorAuth( self::qrProvider() ); + $timeslice = null; + $result = $tfa->verifyCode( $userMultifactor->secret, $code, 1, null, $timeslice ); + } + catch( TwoFactorAuthException $e ) { + throw new controllerException( 'Not able to check MFA code', 500, $e ); + } + + if( !$result ) { + throw new controllerException( 'Incorrect code provided', 400 ); + } + + //replay attack prevention + if( $timeslice===null || $timeslice<=$userMultifactor->timeslice ) { + throw new controllerException( 'This code has already been used', 500 ); + } + + //save the updated timeslice for future verification + try { + $userMultifactor->timeslice = $timeslice; + userMultifactor::save( $userMultifactor ); + } + catch( \Exception $e ) { + error_log( 'Failed to save MFA timeslice' ); + error_log( $e ); + } + + return true; + } + +} diff --git a/src/services/auth/router.php b/src/services/auth/router.php new file mode 100644 index 0000000..6bb01de --- /dev/null +++ b/src/services/auth/router.php @@ -0,0 +1,60 @@ +config->isOauth() ) { + return array_merge( $routes, [ + new route( 'GET', $basePath . '/.well-known/openid-configuration', self::OAUTH, 'openId', false ), + new route( 'POST', $basePath . '/auth/authorize', self::OAUTH, 'oauthPostAuthorize', false ), + new route( 'GET', $basePath . '/auth/authorize', self::OAUTH, 'oauthGetAuthorize', false ), + new route( 'GET', $basePath . '/auth/hybridauth/{provider}', self::OAUTH, 'oauthHybridAuth', false ), + new route( 'GET', $basePath . '/auth/out', self::OAUTH, 'out', true ), + new route( 'POST', $basePath . '/auth/verifyMfaSecret', self::OAUTH, 'verifyMfaSecret', true ), + new route( 'POST', $basePath . '/auth/verifyMfaCode', self::OAUTH, 'verifyMfaCode', true ), + ] ); + } + + return array_merge( $routes, [ + new route( 'GET', $basePath . '/auth/microsoft', self::MS_FRONT, 'microsoft', false ), + ] ); + } + + + /** + * @throws \gcgov\framework\exceptions\routeException + */ + public function authentication( \gcgov\framework\models\routeHandler $routeHandler ): bool { + return guard::authenticate( $routeHandler ); + } + +} diff --git a/src/services/cronMonitor/cronMonitor.php b/src/services/cronMonitor/cronMonitor.php new file mode 100644 index 0000000..748ff47 --- /dev/null +++ b/src/services/cronMonitor/cronMonitor.php @@ -0,0 +1,54 @@ +jobId = $jobId; + $this->client = new \GuzzleHttp\Client( [ 'base_uri' => config::getCronMonitor()->url ] ); + // Fired asynchronously so the start ping overlaps the job rather than delaying it. + $this->jobPromise = $this->client->requestAsync( 'GET', 'jobHistory/start/' . $this->jobId ); + } + + public function end(): void { + + $runId = ''; + //make sure the job started and get the run id from it's response + try { + $response = $this->jobPromise->wait(); + $parsedResponse = json_decode( (string) $response->getBody(), false, 512, JSON_THROW_ON_ERROR ); + if( is_object( $parsedResponse ) && isset( $parsedResponse->data ) ) { + $runId = (string) $parsedResponse->data; + } + } + catch( \JsonException|\Exception $e ) { + } + + //end the job regardless of whether we got a successful start or not + try { + $this->client->request( 'GET', 'jobHistory/end/'.$this->jobId.'/' . $runId ); + } + catch( \Exception|GuzzleException $e ) { + } + } + +} diff --git a/src/services/documentation/controllers/documentation.php b/src/services/documentation/controllers/documentation.php new file mode 100644 index 0000000..f42e18c --- /dev/null +++ b/src/services/documentation/controllers/documentation.php @@ -0,0 +1,113 @@ +getScanDirectories(); + $excludeFilesDirectories = $this->getExcludeDirectoriesFiles(); + $finder = new \OpenApi\SourceFinder( $scanDirectories, $excludeFilesDirectories, '*.php' ); + $openapi = ( new \OpenApi\Generator() )->generate( $finder ); + header( 'Content-Type: text/x-yaml' ); + echo $openapi->toYaml(); + die(); + } + + + /** + * The application's own source, plus the framework's. + * + * The framework directory is derived from this file rather than guessed at + * vendor/gcgov/framework, so it is correct whether the framework is installed from + * Packagist or symlinked from a path repository during development. Under a path + * repository the old hardcoded vendor path did not exist and was silently dropped by + * the file_exists filter below, so nothing of the framework was documented in + * development. + * + * Scanning the framework's src/services now also picks up the Framework Services' + * own annotations. As separate packages they were simply never in this list, so + * their endpoints never reached the document. + * + * @return string[] + */ + private function getScanDirectories(): array { + $frameworkSrc = dirname( __DIR__, 3 ); + + $directoriesToScan = [ + config::getAppDir(), + $frameworkSrc . '/controllers', + $frameworkSrc . '/exceptions', + $frameworkSrc . '/models', + $frameworkSrc . '/services' + ]; + + foreach( $directoriesToScan as $i => $directory ) { + if( !file_exists( $directory ) ) { + unset( $directoriesToScan[ $i ] ); + } + } + + return array_values( $directoriesToScan ); + } + + + /** + * An application that defines its own user model replaces the framework's, so the + * framework's must not also appear in the document as a second schema of the same name. + * + * @return string[] + */ + private function getExcludeDirectoriesFiles(): array { + $frameworkSrc = dirname( __DIR__, 3 ); + + $exclusions = []; + + $vendor = config::getRootDir() . '/vendor'; + if( file_exists( $vendor ) ) { + $exclusions[] = $vendor; + } + + if( class_exists( '\app\models\user' ) ) { + $exclusions[] = $frameworkSrc . '/services/mongodb/models/auth/user.php'; + } + + if( class_exists( '\app\models\authUser' ) ) { + $exclusions[] = $frameworkSrc . '/models/authUser.php'; + } + + return array_values( $exclusions ); + } + + + public function routes(): controllerDataResponse { + $routes = []; + return new controllerDataResponse( $routes ); + } + + + /** + * Processed after lifecycle is complete with this instance + */ + public static function _after(): void { + } + + + /** + * Processed prior to __constructor() being called + */ + public static function _before(): void { + } + +} diff --git a/src/services/documentation/router.php b/src/services/documentation/router.php new file mode 100644 index 0000000..a8c1d27 --- /dev/null +++ b/src/services/documentation/router.php @@ -0,0 +1,28 @@ + [ + 'name' => -1 + ] + ]; + $limit = $_GET[ 'limit' ] ?? 10; + $page = $_GET[ 'page' ] ?? 1; + + + try { + $userClassName = request::getUserClassFqdn(); + $users = $userClassName::getPagedResponse($limit, $page, $filter, $options ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), $e->getCode(), $e ); + } + + return new controllerPagedDataResponse( $users ); + + } + + + /** + * @OA\Get( + * path="/user/{_id}", + * tags={"User", "Auth"}, + * description="Fetch an user object", + * @OA\Parameter(in="path", name="_id", required=true, @OA\Schema(type="string")), + * @OA\Response( + * response="200", + * description="Successfully fetched", + * @OA\JsonContent(ref="#/components/schemas/user") + * ) + * ) + * + * @param string $_id + * + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function getOne( string $_id ): controllerDataResponse { + + $userClassName = request::getUserClassFqdn(); + if( $_id==='new' ) { + $user = new $userClassName(); + } + else { + try { + $user = $userClassName::getOne( $_id ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), $e->getCode(), $e ); + } + } + + return new controllerDataResponse( $user ); + } + + + /** + * @OA\Post( + * path="/user/{_id}", + * tags={"User", "Auth"}, + * description="Create or update an user object", + * @OA\Parameter(in="path", name="_id", required=true, @OA\Schema(type="string")), + * @OA\RequestBody( + * description="user object to save", + * required=true, + * @OA\JsonContent(ref="#/components/schemas/user") + * ), + * @OA\Response( + * response="200", + * description="Successfully saved", + * @OA\JsonContent(ref="#/components/schemas/user") + * ) + * ) + * + * @param string $_id + * + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function save( string $_id ): controllerDataResponse { + + $userClassName = request::getUserClassFqdn(); + $userJSON = file_get_contents( 'php://input' ); + try { + $user = $userClassName::jsonDeserialize( $userJSON ); + $userClassName::save( $user ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), $e->getCode(), $e ); + } + + return new controllerDataResponse( $user ); + } + + + /** + * @OA\Delete( + * path="/user/{_id}", + * tags={"User", "Auth"}, + * @OA\Parameter(in="path", name="_id", required=true, @OA\Schema(type="string")), + * @OA\Response( + * response="204", + * description="Successfully deleted" + * ) + * ) + * + * @param string $_id + * + * @return \gcgov\framework\models\controllerDataResponse + * @throws \gcgov\framework\exceptions\controllerException + */ + public function delete( string $_id ): controllerDataResponse { + $userClassName = request::getUserClassFqdn(); + try { + $deleteResult = $userClassName::delete( $_id ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), $e->getCode(), $e ); + } + + if( $deleteResult->getDeletedCount()===0 ) { + throw new controllerException( 'user not found', 404 ); + } + + $response = new controllerDataResponse(); + $response->setHttpStatus( 204 ); + return $response; + + } + +} diff --git a/src/services/userCrud/router.php b/src/services/userCrud/router.php new file mode 100644 index 0000000..bac6d09 --- /dev/null +++ b/src/services/userCrud/router.php @@ -0,0 +1,41 @@ +assertSame( 'string', (string) $method->getReturnType() ); } - public function testRouterConstructorTakesServiceNamespaces(): void { + /** + * Which Framework Services run is read from config.json, not passed in from + * \app\app — so the router needs nothing from its caller. + */ + public function testRouterConstructorTakesNoArguments(): void { $ctor = ( new \ReflectionClass( router::class ) )->getConstructor(); $this->assertNotNull( $ctor ); - $params = $ctor->getParameters(); - $this->assertCount( 1, $params ); - $this->assertSame( 'serviceNamespaces', $params[0]->getName() ); - $this->assertSame( 'array', (string) $params[0]->getType() ); + $this->assertCount( 0, $ctor->getParameters() ); + } + + + public function testGetMergedRoutesTakesNoArguments(): void { + $method = new \ReflectionMethod( router::class, 'getMergedRoutes' ); + $this->assertCount( 0, $method->getParameters() ); + $this->assertTrue( $method->isStatic() ); } public function testRouterRouteReturnsRouteHandler(): void { diff --git a/tests/Unit/Interfaces/InterfacesTest.php b/tests/Unit/Interfaces/InterfacesTest.php index 8dab9b7..46c8260 100644 --- a/tests/Unit/Interfaces/InterfacesTest.php +++ b/tests/Unit/Interfaces/InterfacesTest.php @@ -30,8 +30,10 @@ public function testInterfaceDeclaresExpectedMethods( string $interface, array $ public static function interfaceMethodMatrix(): array { return [ - 'app' => [ interfaces\app::class, [ 'registerFrameworkServiceNamespaces' ] ], + 'app' => [ interfaces\app::class, [ '_before', '_after' ] ], 'router' => [ interfaces\router::class, [ 'getRoutes', 'authentication' ] ], + 'appRouter' => [ interfaces\appRouter::class, [ 'getRoutes', 'authentication', '_before', '_after', 'providesAuthentication' ] ], + 'skipsServiceAuthentication' => [ interfaces\router\skipsServiceAuthentication::class, [ 'getRunFrameworkServiceRouteAuthentication' ] ], 'controller' => [ interfaces\controller::class, [] ], 'render' => [ interfaces\render::class, [ 'processModelException', @@ -68,4 +70,31 @@ public function testSingletonIsAbstractClassNotInterface(): void { $this->assertTrue( $reflection->hasMethod( 'getInstance' ) ); } + + /** + * Framework Services are declared in config.json's `services` section, not returned + * from the application class. + */ + public function testAppInterfaceNoLongerRegistersServiceNamespaces(): void { + $reflection = new \ReflectionClass( interfaces\app::class ); + $this->assertFalse( $reflection->hasMethod( 'registerFrameworkServiceNamespaces' ) ); + } + + + /** + * Only \app\router's lifecycle hooks are ever invoked, so requiring them of every + * router described a contract the framework did not honour. They live on appRouter. + */ + public function testServiceRouterInterfaceCarriesNoLifecycleHooks(): void { + $reflection = new \ReflectionClass( interfaces\router::class ); + $this->assertFalse( $reflection->hasMethod( '_before' ) ); + $this->assertFalse( $reflection->hasMethod( '_after' ) ); + $this->assertTrue( $reflection->isInterface() ); + } + + + public function testAppRouterExtendsRouter(): void { + $this->assertTrue( is_subclass_of( interfaces\appRouter::class, interfaces\router::class ) ); + } + } diff --git a/tests/Unit/Models/Config/AppConfigModelsTest.php b/tests/Unit/Models/Config/AppConfigModelsTest.php index 23951e4..e10bbfa 100644 --- a/tests/Unit/Models/Config/AppConfigModelsTest.php +++ b/tests/Unit/Models/Config/AppConfigModelsTest.php @@ -48,10 +48,18 @@ public function testEmailDefaults(): void { public function testSettingsDefaults(): void { $settings = new settings(); - $this->assertFalse( $settings->useSession ); $this->assertFalse( $settings->forceMfaForPasswordUsers ); } + + /** + * useSession was declared, documented, and read by nothing — not the framework, not a + * Framework Service, not any application. It was removed rather than carried forward. + */ + public function testUseSessionIsGone(): void { + $this->assertFalse( property_exists( settings::class, 'useSession' ) ); + } + public function testAllConfigsExtendJsonDeserialize(): void { foreach ( [ unifiedConfig::class, app::class, email::class, settings::class ] as $class ) { $this->assertTrue( is_subclass_of( $class, \andrewsauder\jsonDeserialize\jsonDeserialize::class ) ); diff --git a/tests/Unit/Models/Config/ServicesConfigTest.php b/tests/Unit/Models/Config/ServicesConfigTest.php new file mode 100644 index 0000000..e082df4 --- /dev/null +++ b/tests/Unit/Models/Config/ServicesConfigTest.php @@ -0,0 +1,143 @@ +assertInstanceOf( services::class, $config->services ); + $this->assertNull( $config->services->auth ); + $this->assertNull( $config->services->userCrud ); + $this->assertNull( $config->services->documentation ); + } + + + public function testEmptyServicesSectionLeavesEveryServiceOff(): void { + $config = self::hydrate( '{"services":{}}' ); + + $this->assertNull( $config->services->auth ); + $this->assertNull( $config->services->userCrud ); + $this->assertNull( $config->services->documentation ); + } + + + public function testAnEmptyBlockEnablesTheService(): void { + $config = self::hydrate( '{"services":{"userCrud":{}}}' ); + + $this->assertInstanceOf( userCrud::class, $config->services->userCrud ); + $this->assertNull( $config->services->documentation ); + $this->assertNull( $config->services->auth ); + } + + + public function testSeveralServicesEnableIndependently(): void { + $config = self::hydrate( '{"services":{"userCrud":{},"documentation":{}}}' ); + + $this->assertInstanceOf( userCrud::class, $config->services->userCrud ); + $this->assertInstanceOf( documentation::class, $config->services->documentation ); + $this->assertNull( $config->services->auth ); + } + + + public function testAuthSettingsHydrate(): void { + $config = self::hydrate( '{"services":{"auth":{"provider":"oauth","blockNewUsers":false,"defaultNewUserRoles":["Widget.Read"],"oauth":{"authorizeUrlParameters":{"prompt":"consent"}}}}}' ); + + $auth = $config->services->auth; + $this->assertInstanceOf( auth::class, $auth ); + $this->assertTrue( $auth->isOauth() ); + $this->assertFalse( $auth->blockNewUsers ); + $this->assertSame( [ 'Widget.Read' ], $auth->defaultNewUserRoles ); + $this->assertSame( [ 'prompt' => 'consent' ], $auth->oauth->authorizeUrlParameters ); + $this->assertNull( $auth->msFront ); + } + + + public function testBlockNewUsersDefaultsToBlocking(): void { + $config = self::hydrate( '{"services":{"auth":{"provider":"msFront"}}}' ); + + $this->assertTrue( $config->services->auth->blockNewUsers ); + } + + + /** A missing block for the selected provider is the established missing-section rule. */ + public function testSelectedProviderBlockMayBeOmitted(): void { + $config = self::hydrate( '{"services":{"auth":{"provider":"oauth"}}}' ); + + $this->assertInstanceOf( \gcgov\framework\models\config\services\auth\oauth::class, $config->services->auth->oauth ); + $this->assertSame( [], $config->services->auth->oauth->authorizeUrlParameters ); + $this->assertNull( $config->services->auth->msFront ); + } + + + public function testMsFrontProviderSelects(): void { + $config = self::hydrate( '{"services":{"auth":{"provider":"msFront"}}}' ); + + $this->assertTrue( $config->services->auth->isMsFront() ); + $this->assertFalse( $config->services->auth->isOauth() ); + $this->assertInstanceOf( \gcgov\framework\models\config\services\auth\msFront::class, $config->services->auth->msFront ); + } + + + /** Configuration that nothing would read is an error, not a silent no-op. */ + public function testBlockForTheUnselectedProviderIsRejected(): void { + $this->expectException( environmentException::class ); + $this->expectExceptionMessage( 'services.auth.msFront is configured but services.auth.provider is "oauth"' ); + + self::hydrate( '{"services":{"auth":{"provider":"oauth","msFront":{}}}}' ); + } + + + public function testBlockForTheUnselectedProviderIsRejectedTheOtherWayRound(): void { + $this->expectException( environmentException::class ); + $this->expectExceptionMessage( 'services.auth.oauth is configured but services.auth.provider is "msFront"' ); + + self::hydrate( '{"services":{"auth":{"provider":"msFront","oauth":{}}}}' ); + } + + + public function testUnknownProviderIsRejected(): void { + $this->expectException( environmentException::class ); + $this->expectExceptionMessage( 'not "typo"' ); + + self::hydrate( '{"services":{"auth":{"provider":"typo"}}}' ); + } + + + public function testMissingProviderIsRejected(): void { + $this->expectException( environmentException::class ); + $this->expectExceptionMessage( 'is missing' ); + + self::hydrate( '{"services":{"auth":{"blockNewUsers":false}}}' ); + } + + + public function testProviderConstantsAreTheLegalValues(): void { + $this->assertSame( [ 'oauth', 'msFront' ], auth::PROVIDERS ); + } + +} diff --git a/tests/Unit/RouterAuthenticationGuaranteeTest.php b/tests/Unit/RouterAuthenticationGuaranteeTest.php new file mode 100644 index 0000000..2581c8d --- /dev/null +++ b/tests/Unit/RouterAuthenticationGuaranteeTest.php @@ -0,0 +1,83 @@ +expectException( configException::class ); + $this->expectExceptionMessage( '2 route(s) require authentication but no authentication service is enabled' ); + + router::assertAuthenticationIsProvided( self::routes( true ), false, false ); + } + + + public function testTheMessageNamesTheOffendingRoutes(): void { + try { + router::assertAuthenticationIsProvided( self::routes( true ), false, false ); + $this->fail( 'expected a configException' ); + } + catch( configException $e ) { + $this->assertStringContainsString( 'POST /widget/{_id}', $e->getMessage() ); + $this->assertStringContainsString( 'GET|POST /secret', $e->getMessage() ); + // and tells the reader both ways out + $this->assertStringContainsString( '"provider": "oauth"', $e->getMessage() ); + $this->assertStringContainsString( 'providesAuthentication()', $e->getMessage() ); + } + } + + + public function testAnEnabledAuthServiceSatisfiesTheCheck(): void { + router::assertAuthenticationIsProvided( self::routes( true ), true, false ); + $this->expectNotToPerformAssertions(); + } + + + public function testAnApplicationThatGuardsItsOwnRoutesSatisfiesTheCheck(): void { + router::assertAuthenticationIsProvided( self::routes( true ), false, true ); + $this->expectNotToPerformAssertions(); + } + + + /** No authenticated routes means nothing to fail closed about. */ + public function testNoAuthenticatedRoutesNeedsNoAuthService(): void { + router::assertAuthenticationIsProvided( self::routes( false ), false, false ); + $this->expectNotToPerformAssertions(); + } + + + public function testEmptyRouteTableIsFine(): void { + router::assertAuthenticationIsProvided( [], false, false ); + $this->expectNotToPerformAssertions(); + } + +} diff --git a/tests/Unit/RouterServiceActivationTest.php b/tests/Unit/RouterServiceActivationTest.php new file mode 100644 index 0000000..ff8b144 --- /dev/null +++ b/tests/Unit/RouterServiceActivationTest.php @@ -0,0 +1,137 @@ +getValue(); + } + + + protected function tearDown(): void { + ( new \ReflectionProperty( config::class, 'unifiedConfig' ) )->setValue( null, self::$original ); + } + + + private static function useServices( string $servicesJson ): void { + $config = unifiedConfig::jsonDeserialize( json_decode( '{"type":"local","rootUrl":"http://test.local","basePath":"api","services":' . $servicesJson . '}', false ) ); + ( new \ReflectionProperty( config::class, 'unifiedConfig' ) )->setValue( null, $config ); + } + + + /** @return string[] */ + private static function routePaths(): array { + $paths = []; + foreach( router::getMergedRoutes() as $route ) { + $paths[] = $route->route; + } + + return $paths; + } + + + public function testNoServicesGivesOnlyHealthAndAppRoutes(): void { + self::useServices( '{}' ); + $paths = self::routePaths(); + + $this->assertContains( '/api/health', $paths ); + $this->assertContains( '/api/health/ready', $paths ); + $this->assertContains( '/widget', $paths ); + $this->assertNotContains( '/api/user', $paths ); + $this->assertNotContains( '/api/documentation.yaml', $paths ); + } + + + public function testUserCrudBlockAddsItsRoutes(): void { + self::useServices( '{"userCrud":{}}' ); + $paths = self::routePaths(); + + $this->assertContains( '/api/user', $paths ); + $this->assertContains( '/api/user/{_id}', $paths ); + $this->assertNotContains( '/api/documentation.yaml', $paths ); + } + + + public function testDocumentationBlockAddsItsRoute(): void { + self::useServices( '{"documentation":{}}' ); + + $this->assertContains( '/api/documentation.yaml', self::routePaths() ); + } + + + /** + * The provider decides which token-acquisition routes exist. The shared ones — + * jwks and fileToken — are present either way. + */ + public function testOauthProviderContributesTheOauthRoutes(): void { + self::useServices( '{"auth":{"provider":"oauth"}}' ); + $paths = self::routePaths(); + + $this->assertContains( '/api/.well-known/jwks.json', $paths ); + $this->assertContains( '/api/auth/fileToken', $paths ); + $this->assertContains( '/api/auth/authorize', $paths ); + $this->assertContains( '/api/auth/hybridauth/{provider}', $paths ); + $this->assertContains( '/api/auth/verifyMfaCode', $paths ); + $this->assertContains( '/api/.well-known/openid-configuration', $paths ); + $this->assertNotContains( '/api/auth/microsoft', $paths ); + } + + + public function testMsFrontProviderContributesOnlyTheExchangeRoute(): void { + self::useServices( '{"auth":{"provider":"msFront"}}' ); + $paths = self::routePaths(); + + $this->assertContains( '/api/.well-known/jwks.json', $paths ); + $this->assertContains( '/api/auth/fileToken', $paths ); + $this->assertContains( '/api/auth/microsoft', $paths ); + $this->assertNotContains( '/api/auth/authorize', $paths ); + $this->assertNotContains( '/api/auth/verifyMfaCode', $paths ); + } + + + /** Every route the enabled providers register must point at a real method. */ + public function testEveryRouteResolvesToAnExistingControllerMethod(): void { + foreach( [ '{"auth":{"provider":"oauth"},"userCrud":{},"documentation":{}}', '{"auth":{"provider":"msFront"}}' ] as $services ) { + self::useServices( $services ); + foreach( router::getMergedRoutes() as $route ) { + if( !str_starts_with( ltrim( $route->class, '\\' ), 'gcgov\\framework\\services' ) ) { + continue; + } + $this->assertTrue( class_exists( $route->class ), 'missing controller ' . $route->class ); + $this->assertTrue( method_exists( $route->class, $route->method ), $route->class . '::' . $route->method . '() does not exist' ); + } + } + } + + + public function testEnablingEverythingProducesNoDuplicateRoutes(): void { + self::useServices( '{"auth":{"provider":"oauth"},"userCrud":{},"documentation":{}}' ); + + $seen = []; + foreach( router::getMergedRoutes() as $route ) { + foreach( (array)$route->httpMethod as $method ) { + $key = $method . ' ' . $route->route; + $this->assertNotContains( $key, $seen, 'duplicate route ' . $key . ' would be a FastRoute boot failure' ); + $seen[] = $key; + } + } + } + +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c99a3c6..3a7e88d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,19 +12,23 @@ } // Several framework call sites reflect on \app\app to derive directories. -// Stub the class so tests that touch config::getAppDir() can boot. The gf CLI -// additionally calls registerFrameworkServiceNamespaces() during route enumeration. +// Stub the class so tests that touch config::getAppDir() can boot. if ( !class_exists( '\app\app' ) ) { - eval( 'namespace app; class app { public static function _before(): void {} public static function _after(): void {} public function registerFrameworkServiceNamespaces(): array { return []; } }' ); + eval( 'namespace app; class app { public static function _before(): void {} public static function _after(): void {} }' ); } // Stub \app\router with fixture routes so the gf CLI route catalog can be -// exercised (router::getMergedRoutes() instantiates \app\router). +// exercised (router::getMergedRoutes() instantiates \app\router). None of the +// fixture routes require authentication, so the framework's no-auth-service +// check is satisfied without the stub claiming to authenticate anything. if ( !class_exists( '\app\router' ) ) { eval( 'namespace app; - class router implements \gcgov\framework\interfaces\router { + class router implements \gcgov\framework\interfaces\appRouter { public static function _before(): void {} public static function _after(): void {} + public function providesAuthentication(): bool { + return false; + } public function getRoutes(): array { return [ new \gcgov\framework\models\route( "GET", "/widget", "\\\\app\\\\controllers\\\\widget", "getAll" ), From f8bf21dcfbb512b2a93bc776881e5212927ebc14 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 15:34:37 +0000 Subject: [PATCH 13/30] Migrate service tests, and teach gf migrate about services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five packages' test suites move into tests/Unit/Services/ mirroring src/. Three needed more than a namespace rewrite: - The two auth router tests became one. They now cover the same class, and both were written against v6 — a no-arg constructor, models\environmentConfig, and the lifecycle hooks the service router interface no longer declares. The consolidated test asserts the route set each provider contributes and the guard's refusal behaviour, which used to be two near copies. - The jwks/fileToken assertions moved to a test for the shared controller, alongside one asserting neither provider still declares them. Note the old test named the method 'openid' and passed anyway: ReflectionMethod is case-insensitive, which is why nothing caught the router pointing at a method that does not exist. - UserControllerTest now runs in separate processes. It needs \app\models\user to exist so request::getUserClassFqdn() resolves to the stub, while the framework's RequestTest asserts the opposite — that with no application user model the Mongo default is returned. One process cannot hold both, so the stub is required in setUpBeforeClass, which runs only in the child. Two router tests relied on whatever configuration a previously-run test had left in the static; they now seed their own. The documentation one keeps its multi-segment base path, which catches a router assuming one path element. gf migrate gains the services half of the conversion: - detectServices() reads app/app.php and reports the namespaces registered and the configuration singletons called. It strips comments with the tokenizer rather than matching text, because the scaffolded app.php ships the alternatives commented out directly above the live array — a plain search reports services the application does not run, and a conflict between the two auth services that is not there. Verified against the real app/app.php from framework-app-template's v7 branch. - plan() takes what was detected and writes the services section, moves appDictionary.cronMonitorUrl to its own typed cronMonitor.url, and turns singleton calls into warnings naming the config keys that replace them. It reports rather than guesses, as it already does for sqlDatabases. - execute() removes the service packages from the application's composer.json. The framework conflicts with them, so leaving them makes the application unresolvable rather than untidy. It stops short of running composer update: resolution does not belong in a command that is otherwise file manipulation. 688 tests pass, up from 542. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KmBBiV3fQaaRdrmspZarS5 --- src/cli/commands/migrateCommand.php | 177 ++++++++++++++- tests/Stubs/FakeUserModel.php | 141 ++++++++++++ tests/Unit/Cli/MigrateServicesTest.php | 152 +++++++++++++ tests/Unit/Cli/RouteCatalogTest.php | 27 ++- .../Auth/Controllers/AuthControllerTest.php | 62 +++++ .../Controllers/AuthControllerTest.php | 53 +++++ .../Oauth/Controllers/AuthControllerTest.php | 71 ++++++ .../Oauth/Models/ConfigureMfaResponseTest.php | 63 ++++++ .../Oauth/Models/RequireMfaResponseTest.php | 93 ++++++++ .../Oauth/Models/StdAuthResponseTest.php | 68 ++++++ .../Oauth/Models/VerifyMfaCodeRequestTest.php | 39 ++++ .../Models/VerifyMfaSecretRequestTest.php | 42 ++++ .../Oauth/Services/MultifactorTest.php | 103 +++++++++ tests/Unit/Services/Auth/RouterTest.php | 165 ++++++++++++++ .../Services/CronMonitor/CronMonitorTest.php | 118 ++++++++++ .../DocumentationControllerTest.php | 82 +++++++ .../Services/Documentation/RouterTest.php | 74 ++++++ .../Controllers/UserControllerTest.php | 212 ++++++++++++++++++ tests/Unit/Services/UserCrud/RouterTest.php | 98 ++++++++ 19 files changed, 1836 insertions(+), 4 deletions(-) create mode 100644 tests/Stubs/FakeUserModel.php create mode 100644 tests/Unit/Cli/MigrateServicesTest.php create mode 100644 tests/Unit/Services/Auth/Controllers/AuthControllerTest.php create mode 100644 tests/Unit/Services/Auth/Providers/MsFront/Controllers/AuthControllerTest.php create mode 100644 tests/Unit/Services/Auth/Providers/Oauth/Controllers/AuthControllerTest.php create mode 100644 tests/Unit/Services/Auth/Providers/Oauth/Models/ConfigureMfaResponseTest.php create mode 100644 tests/Unit/Services/Auth/Providers/Oauth/Models/RequireMfaResponseTest.php create mode 100644 tests/Unit/Services/Auth/Providers/Oauth/Models/StdAuthResponseTest.php create mode 100644 tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaCodeRequestTest.php create mode 100644 tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaSecretRequestTest.php create mode 100644 tests/Unit/Services/Auth/Providers/Oauth/Services/MultifactorTest.php create mode 100644 tests/Unit/Services/Auth/RouterTest.php create mode 100644 tests/Unit/Services/CronMonitor/CronMonitorTest.php create mode 100644 tests/Unit/Services/Documentation/Controllers/DocumentationControllerTest.php create mode 100644 tests/Unit/Services/Documentation/RouterTest.php create mode 100644 tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php create mode 100644 tests/Unit/Services/UserCrud/RouterTest.php diff --git a/src/cli/commands/migrateCommand.php b/src/cli/commands/migrateCommand.php index ca5256e..eed16f2 100644 --- a/src/cli/commands/migrateCommand.php +++ b/src/cli/commands/migrateCommand.php @@ -78,6 +78,40 @@ final class migrateCommand extends Command { 'composer-prod.json', ]; + /** + * v6 Framework Service namespaces, and the config.json `services` key each becomes. + * + * @var array + */ + public const array SERVICE_NAMESPACES = [ + 'gcgov\\framework\\services\\documentation' => 'documentation', + 'gcgov\\framework\\services\\usercrud' => 'userCrud', + 'gcgov\\framework\\services\\authoauth' => 'auth:oauth', + 'gcgov\\framework\\services\\authmsfront' => 'auth:msFront', + 'gcgov\\framework\\services\\cronMonitor' => 'cronMonitor', + ]; + + /** + * Service configuration that used to be applied by calling a singleton in + * \app\app::_before(). The values are arbitrary PHP expressions, so these are + * reported for the developer to transcribe rather than guessed at. + * + * @var array + */ + public const array SINGLETON_CALLS = [ + 'setBlockNewUsers' => 'services.auth.blockNewUsers / services.auth.defaultNewUserRoles', + 'setAuthorizeUrlParameters' => 'services.auth.oauth.authorizeUrlParameters', + ]; + + /** Framework Service packages that are now part of the framework itself. */ + public const array SERVICE_PACKAGES = [ + 'gcgov/framework-service-auth-oauth-server', + 'gcgov/framework-service-auth-ms-front', + 'gcgov/framework-service-user-crud', + 'gcgov/framework-service-documentation', + 'gcgov/framework-service-gcgov-cron-monitor', + ]; + protected function configure(): void { $this->addOption( 'dry-run', null, InputOption::VALUE_NONE, 'Show what would change without writing anything' ); @@ -119,9 +153,15 @@ protected function execute( InputInterface $input, OutputInterface $output ): in throw new cliException( $context->getConfigPath() . ' already exists — this application appears to be migrated. Pass --force to overwrite it.' ); } + $appPhpPath = $context->rootDir . '/app/app.php'; + $detected = file_exists( $appPhpPath ) + ? self::detectServices( (string)file_get_contents( $appPhpPath ) ) + : [ 'services' => [], 'singletons' => [] ]; + $plan = self::plan( self::readJson( $appJsonPath ), - self::readJson( $environmentJsonPath ) + self::readJson( $environmentJsonPath ), + $detected ); $io->title( 'v6 → v7 migration' . ( $dryRun ? ' (dry run)' : '' ) ); @@ -141,6 +181,23 @@ protected function execute( InputInterface $input, OutputInterface $output ): in } } + $composerPath = $context->rootDir . '/composer.json'; + $composerRemoved = []; + $composerJson = null; + if( file_exists( $composerPath ) ) { + $decoded = json_decode( (string)file_get_contents( $composerPath ), true ); + if( is_array( $decoded ) ) { + [ 'json' => $composerJson, 'removed' => $composerRemoved ] = self::removeServiceRequires( $decoded ); + } + } + if( count( $composerRemoved )>0 ) { + $io->section( 'composer.json' ); + $io->text( 'These are part of the framework now, and conflict with it:' ); + foreach( $composerRemoved as $package ) { + $io->text( ' - ' . $package ); + } + } + $deadFiles = self::deadFilesPresent( $context->rootDir ); if( count( $deadFiles )>0 && !$input->getOption( 'keep-dead-files' ) ) { $io->section( 'Deleting' ); @@ -162,6 +219,13 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $this->writeEnvFile( $context->getEnvFilePath(), $plan[ 'env' ], $plan[ 'secrets' ] ); + if( count( $composerRemoved )>0 && is_array( $composerJson ) ) { + $encodedComposer = json_encode( $composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + if( $encodedComposer===false || file_put_contents( $composerPath, $encodedComposer . "\n" )===false ) { + throw new cliException( 'Failed writing ' . $composerPath ); + } + } + if( !$input->getOption( 'keep-dead-files' ) ) { foreach( $deadFiles as $deadFile ) { @unlink( $context->rootDir . '/' . $deadFile ); @@ -169,6 +233,9 @@ protected function execute( InputInterface $input, OutputInterface $output ): in } $io->success( 'Migrated. Review the diff, then run `gf env` to confirm the configuration resolves.' ); + if( count( $composerRemoved )>0 ) { + $io->text( 'Run `composer update` — composer.json changed.' ); + } $io->text( 'Still to do by hand: the Dockerfile and compose entry, the Zone this application belongs in, and moving its secrets into the ops repository.' ); return Command::SUCCESS; @@ -181,10 +248,11 @@ protected function execute( InputInterface $input, OutputInterface $output ): in * * @param array $appJson Decoded app/config/app.json * @param array $environmentJson Decoded app/config/environment.json + * @param array{services: string[], singletons: string[]} $detected Result of detectServices() * * @return array{config: array, env: array, secrets: array, warnings: string[]} */ - public static function plan( array $appJson, array $environmentJson ): array { + public static function plan( array $appJson, array $environmentJson, array $detected = [ 'services' => [], 'singletons' => [] ] ): array { $config = $environmentJson; $env = []; $secrets = []; @@ -248,6 +316,40 @@ public static function plan( array $appJson, array $environmentJson ): array { $warnings[] = 'app.guid is empty. The oauth server uses it as the OAuth client_id, so set it before deploying.'; } + // Framework Services: from namespaces returned by \app\app to a config section. + $services = []; + foreach( $detected[ 'services' ] ?? [] as $service ) { + if( $service==='cronMonitor' ) { + continue; + } + if( str_starts_with( $service, 'auth:' ) ) { + $provider = substr( $service, 5 ); + if( isset( $services[ 'auth' ] ) ) { + $warnings[] = 'Both authentication services were registered. Only one provider can be active, so "' . $services[ 'auth' ][ 'provider' ] . '" was kept — change services.auth.provider if that is the wrong one.'; + continue; + } + $services[ 'auth' ] = [ 'provider' => $provider ]; + continue; + } + $services[ $service ] = new \stdClass(); + } + if( count( $services )>0 ) { + $config[ 'services' ] = $services; + } + + // cronMonitor is no longer a Framework Service, so its url gets a typed home of + // its own rather than living in the untyped appDictionary. + $cronMonitorUrl = $config[ 'appDictionary' ][ 'cronMonitorUrl' ] ?? null; + if( is_string( $cronMonitorUrl ) && $cronMonitorUrl!=='' ) { + $config[ 'cronMonitor' ] = [ 'url' => $cronMonitorUrl ]; + unset( $config[ 'appDictionary' ][ 'cronMonitorUrl' ] ); + $warnings[] = 'appDictionary.cronMonitorUrl moved to cronMonitor.url. Update any application code still reading it from appDictionary.'; + } + + foreach( $detected[ 'singletons' ] ?? [] as $call ) { + $warnings[] = $call . '() is called in app/app.php. That configuration moved to ' . ( self::SINGLETON_CALLS[ $call ] ?? 'config.json' ) . ' — copy the values across by hand, then delete the call.'; + } + ksort( $env ); return [ 'config' => $config, 'env' => $env, 'secrets' => $secrets, 'warnings' => $warnings ]; @@ -259,6 +361,77 @@ public static function reference( string $varName, bool $isSecret ): string { } + /** + * Find the Framework Services an application registers, and the service-configuration + * singletons it calls, by reading app/app.php. + * + * Comments are stripped with the tokenizer rather than by matching text, because the + * scaffolded app.php ships the alternatives commented out directly above the live + * array — a plain search would report services the application does not run. + * + * Impure input, pure function: execute() reads the file, this interprets it. + * + * @return array{services: string[], singletons: string[]} + */ + public static function detectServices( string $appSource ): array { + $code = ''; + foreach( @token_get_all( $appSource ) as $token ) { + if( is_array( $token ) ) { + if( $token[ 0 ]===T_COMMENT || $token[ 0 ]===T_DOC_COMMENT ) { + continue; + } + $code .= $token[ 1 ]; + continue; + } + $code .= $token; + } + + $services = []; + foreach( self::SERVICE_NAMESPACES as $namespace => $service ) { + if( str_contains( $code, $namespace ) ) { + $services[] = $service; + } + } + + $singletons = []; + foreach( array_keys( self::SINGLETON_CALLS ) as $call ) { + if( str_contains( $code, $call ) ) { + $singletons[] = $call; + } + } + + return [ 'services' => $services, 'singletons' => $singletons ]; + } + + + /** + * Drop the Framework Service packages from an application's composer.json. + * + * The framework declares a `conflict` against them, so leaving them in place makes the + * application unresolvable rather than merely untidy. + * + * @param array $composerJson + * + * @return array{json: array, removed: string[]} + */ + public static function removeServiceRequires( array $composerJson ): array { + $removed = []; + foreach( [ 'require', 'require-dev' ] as $section ) { + if( !isset( $composerJson[ $section ] ) || !is_array( $composerJson[ $section ] ) ) { + continue; + } + foreach( self::SERVICE_PACKAGES as $package ) { + if( array_key_exists( $package, $composerJson[ $section ] ) ) { + unset( $composerJson[ $section ][ $package ] ); + $removed[] = $package; + } + } + } + + return [ 'json' => $composerJson, 'removed' => array_values( array_unique( $removed ) ) ]; + } + + /** * @return string[] Dead v6 files that actually exist, relative to the root */ diff --git a/tests/Stubs/FakeUserModel.php b/tests/Stubs/FakeUserModel.php new file mode 100644 index 0000000..ab93bfb --- /dev/null +++ b/tests/Stubs/FakeUserModel.php @@ -0,0 +1,141 @@ + */ + public static array $records = []; + + /** @var \Exception|null */ + public static ?\Exception $nextException = null; + + /** @var int */ + public static int $deleteAffectedCount = 1; + + public string $_id = ''; + public string $name = ''; + public string $email = ''; + public string $username = ''; + /** @var string[] */ + public array $roles = []; + + public static function reset(): void { + self::$records = []; + self::$nextException = null; + self::$deleteAffectedCount = 1; + } + + /** + * @param int $limit + * @param int $page + * @param array $filter + * @param array $options + */ + public static function getPagedResponse( int $limit, int $page, array $filter, array $options ): FakeDbGetResult { + self::throwIfQueued(); + $result = new FakeDbGetResult(); + $result->setData( array_values( self::$records ) ); + $result->setLimit( $limit ); + $result->setPage( $page ); + $result->setTotalDocumentCount( count( self::$records ) ); + return $result; + } + + public static function getOne( \MongoDB\BSON\ObjectId|string|int $_id ): self { + self::throwIfQueued(); + $key = (string) $_id; + if ( !isset( self::$records[ $key ] ) ) { + throw new \gcgov\framework\exceptions\modelException( 'not found', 404 ); + } + return self::$records[ $key ]; + } + + public static function save( object &$object ): mixed { + self::throwIfQueued(); + if ( !( $object instanceof self ) ) { + throw new \InvalidArgumentException( 'Expected ' . self::class ); + } + self::$records[ $object->_id ] = $object; + return $object; + } + + public static function delete( string $_id ): FakeDeleteResult { + self::throwIfQueued(); + $count = self::$deleteAffectedCount; + if ( isset( self::$records[ $_id ] ) ) { + unset( self::$records[ $_id ] ); + } + return new FakeDeleteResult( $count ); + } + + public static function jsonDeserialize( string $json ): self { + $data = json_decode( $json, true ) ?: []; + $instance = new self(); + $instance->_id = (string) ( $data[ '_id' ] ?? '' ); + $instance->name = (string) ( $data[ 'name' ] ?? '' ); + return $instance; + } + + private static function throwIfQueued(): void { + if ( self::$nextException !== null ) { + $e = self::$nextException; + self::$nextException = null; + throw $e; + } + } + + public function getId(): string|int|\MongoDB\BSON\ObjectId { return $this->_id; } + public function getName(): string { return $this->name; } + public function getUsername(): string { return $this->username; } + public function getPassword(): string { return ''; } + public function getOauthId(): string { return ''; } + public function getOauthProvider(): string { return ''; } + public function getEmail(): string { return $this->email; } + public function getRoles(): array { return $this->roles; } + public function getActive(): bool { return true; } + public function getMfaRequired(): bool { return false; } + public function getMfaConfigured(): bool { return false; } + public static function getFromOauth( string $email, string $externalId, string $externalProvider, ?string $firstName = '', ?string $lastName = '', bool $addIfNotExisting = false, array $rolesForNewUser=[] ): self { throw new \BadMethodCallException(); } + public static function verifyUsernamePassword( string $username, string $password ): self { throw new \BadMethodCallException(); } + public static function getOneByExternalId( string $externalId ): self { throw new \BadMethodCallException(); } + public static function getOneByEmail( string $email ): self { throw new \BadMethodCallException(); } + +} + +class FakeDeleteResult { + public function __construct( private int $deletedCount ) {} + public function getDeletedCount(): int { + return $this->deletedCount; + } +} + +class FakeDbGetResult implements \gcgov\framework\interfaces\dbGetResult { + /** @var array */ + private array $data = []; + private int $limit = 10; + private int $page = 1; + private int $totalDocumentCount = 0; + + public function setData( array $data ): void { $this->data = $data; } + public function setLimit( int $limit ): void { $this->limit = max( 1, $limit ); } + public function setPage( int $page ): void { $this->page = max( 1, $page ); } + public function getData(): array { return $this->data; } + public function getLimit(): int { return $this->limit; } + public function getSkip(): int { return ( $this->page - 1 ) * $this->limit; } + public function getCount(): int { return count( $this->data ); } + public function getPage(): int { return $this->page; } + public function getTotalDocumentCount(): int { return $this->totalDocumentCount; } + public function setTotalDocumentCount( int $totalDocumentCount ): void { $this->totalDocumentCount = $totalDocumentCount; } + public function getTotalPageCount(): int { + return (int) ceil( $this->totalDocumentCount / $this->limit ); + } +} diff --git a/tests/Unit/Cli/MigrateServicesTest.php b/tests/Unit/Cli/MigrateServicesTest.php new file mode 100644 index 0000000..1d51311 --- /dev/null +++ b/tests/Unit/Cli/MigrateServicesTest.php @@ -0,0 +1,152 @@ +setBlockNewUsers( false, constants::DEFAULT_ROLES ); + //$oauthConfig = \gcgov\framework\services\authoauth\oauthConfig::getInstance(); + //$oauthConfig->setBlockNewUsers( false, constants::DEFAULT_ROLES ); + return [ + '\gcgov\framework\services\documentation', + '\gcgov\framework\services\cronMonitor', + '\gcgov\framework\services\usercrud', + //'\gcgov\framework\services\authmsfront', + '\gcgov\framework\services\authoauth', + ]; + } + } + PHP; + + public function testCommentedOutRegistrationsAreNotDetected(): void { + $detected = migrateCommand::detectServices( self::TEMPLATE_APP_PHP ); + + $this->assertContains( 'documentation', $detected[ 'services' ] ); + $this->assertContains( 'userCrud', $detected[ 'services' ] ); + $this->assertContains( 'cronMonitor', $detected[ 'services' ] ); + $this->assertContains( 'auth:oauth', $detected[ 'services' ] ); + $this->assertNotContains( 'auth:msFront', $detected[ 'services' ], 'the msFront entry is commented out' ); + } + + + public function testCommentedOutSingletonCallsAreNotDetected(): void { + $detected = migrateCommand::detectServices( self::TEMPLATE_APP_PHP ); + + $this->assertSame( [], $detected[ 'singletons' ] ); + } + + + public function testLiveSingletonCallsAreDetected(): void { + $source = 'setBlockNewUsers( false, [ "Widget.Read" ] ); + $c->setAuthorizeUrlParameters( [ "prompt" => "consent" ] ); + } }'; + + $detected = migrateCommand::detectServices( $source ); + + $this->assertContains( 'setBlockNewUsers', $detected[ 'singletons' ] ); + $this->assertContains( 'setAuthorizeUrlParameters', $detected[ 'singletons' ] ); + } + + + public function testDetectedServicesBecomeAConfigSection(): void { + $detected = migrateCommand::detectServices( self::TEMPLATE_APP_PHP ); + $plan = migrateCommand::plan( [], [], $detected ); + + $services = $plan[ 'config' ][ 'services' ]; + $this->assertSame( [ 'provider' => 'oauth' ], $services[ 'auth' ] ); + $this->assertEquals( new \stdClass(), $services[ 'userCrud' ] ); + $this->assertEquals( new \stdClass(), $services[ 'documentation' ] ); + // cronMonitor is not a Framework Service any more, so it gets no services entry + $this->assertArrayNotHasKey( 'cronMonitor', $services ); + } + + + /** An empty services block must not appear at all rather than appear empty. */ + public function testNoDetectedServicesWritesNoServicesSection(): void { + $plan = migrateCommand::plan( [], [], [ 'services' => [], 'singletons' => [] ] ); + + $this->assertArrayNotHasKey( 'services', $plan[ 'config' ] ); + } + + + public function testRegisteringBothAuthServicesKeepsOneAndWarns(): void { + $plan = migrateCommand::plan( [], [], [ 'services' => [ 'auth:oauth', 'auth:msFront' ], 'singletons' => [] ] ); + + $this->assertSame( [ 'provider' => 'oauth' ], $plan[ 'config' ][ 'services' ][ 'auth' ] ); + $this->assertNotEmpty( array_filter( $plan[ 'warnings' ], fn( string $w ): bool => str_contains( $w, 'Both authentication services were registered' ) ) ); + } + + + public function testCronMonitorUrlMovesOutOfAppDictionary(): void { + $plan = migrateCommand::plan( [], [ 'appDictionary' => [ 'cronMonitorUrl' => 'https://monitor.local/', 'other' => 'kept' ] ] ); + + $this->assertSame( [ 'url' => 'https://monitor.local/' ], $plan[ 'config' ][ 'cronMonitor' ] ); + $this->assertSame( [ 'other' => 'kept' ], $plan[ 'config' ][ 'appDictionary' ] ); + } + + + public function testSingletonCallsBecomeWarningsNamingTheirReplacement(): void { + $plan = migrateCommand::plan( [], [], [ 'services' => [], 'singletons' => [ 'setBlockNewUsers' ] ] ); + + $matching = array_filter( $plan[ 'warnings' ], fn( string $w ): bool => str_contains( $w, 'setBlockNewUsers' ) ); + $this->assertNotEmpty( $matching ); + $this->assertStringContainsString( 'services.auth.blockNewUsers', implode( ' ', $matching ) ); + } + + + public function testServiceRequiresAreRemovedFromComposerJson(): void { + $result = migrateCommand::removeServiceRequires( [ + 'require' => [ + 'php' => '>=8.4', + 'gcgov/framework' => '^7.0', + 'gcgov/framework-service-documentation' => '^1.1', + 'gcgov/framework-service-user-crud' => '^1.1', + 'gcgov/framework-service-auth-oauth-server' => '^2.1', + ], + 'require-dev' => [ 'phpunit/phpunit' => '^11.5' ], + ] ); + + $this->assertSame( [ 'php' => '>=8.4', 'gcgov/framework' => '^7.0' ], $result[ 'json' ][ 'require' ] ); + $this->assertSame( [ 'phpunit/phpunit' => '^11.5' ], $result[ 'json' ][ 'require-dev' ] ); + $this->assertCount( 3, $result[ 'removed' ] ); + } + + + public function testComposerJsonWithoutServicePackagesIsUnchanged(): void { + $input = [ 'require' => [ 'php' => '>=8.4', 'gcgov/framework' => '^7.0' ] ]; + $result = migrateCommand::removeServiceRequires( $input ); + + $this->assertSame( $input, $result[ 'json' ] ); + $this->assertSame( [], $result[ 'removed' ] ); + } + +} diff --git a/tests/Unit/Cli/RouteCatalogTest.php b/tests/Unit/Cli/RouteCatalogTest.php index 143dc8c..1020352 100644 --- a/tests/Unit/Cli/RouteCatalogTest.php +++ b/tests/Unit/Cli/RouteCatalogTest.php @@ -35,7 +35,7 @@ protected function tearDown(): void { } public function testGetMergedRoutesReturnsFrameworkAndAppRoutes(): void { - $routes = router::getMergedRoutes( [] ); + $routes = router::getMergedRoutes(); // 3 stub app routes + the framework's own two health routes, which every // application gets whether it asked for them or not. @@ -44,7 +44,7 @@ public function testGetMergedRoutesReturnsFrameworkAndAppRoutes(): void { public function testHealthRoutesAreContributedFirstAndUnauthenticated(): void { - $routes = router::getMergedRoutes( [] ); + $routes = router::getMergedRoutes(); $healthRoutes = array_values( array_filter( $routes, fn( $route ) => str_contains( $route->route, '/health' ) ) ); $this->assertCount( 2, $healthRoutes ); @@ -61,6 +61,29 @@ public function testHealthRoutesAreContributedFirstAndUnauthenticated(): void { $this->assertSame( '/api/health', $routes[ 0 ]->route ); } + + /** + * The CLI reads the same `services` section the HTTP router does. Before this, the + * CLI asked \app\app for the namespaces without running _before(), so a service + * configured there was invisible to gf — the two paths could disagree. + */ + public function testEnabledServicesAppearInTheCliRouteCatalog(): void { + $original = ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->getValue(); + try { + $config = \gcgov\framework\models\unifiedConfig::jsonDeserialize( json_decode( '{"basePath":"api","services":{"userCrud":{},"documentation":{}}}', false ) ); + ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->setValue( null, $config ); + + $paths = array_map( fn( $route ) => $route->route, router::getMergedRoutes() ); + + $this->assertContains( '/api/user', $paths ); + $this->assertContains( '/api/documentation.yaml', $paths ); + $this->assertContains( '/api/health', $paths, 'health is never opt-in' ); + } + finally { + ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->setValue( null, $original ); + } + } + public function testGetCliRoutesFiltersToCliMethodOnly(): void { $context = appContext::locate( $this->tempRootDir ); $this->assertNotNull( $context ); diff --git a/tests/Unit/Services/Auth/Controllers/AuthControllerTest.php b/tests/Unit/Services/Auth/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..f82bb3f --- /dev/null +++ b/tests/Unit/Services/Auth/Controllers/AuthControllerTest.php @@ -0,0 +1,62 @@ +assertContains( controller::class, class_implements( auth::class ) ?: [] ); + } + + + public function testConstructorRequiresNoArguments(): void { + $constructor = ( new \ReflectionClass( auth::class ) )->getConstructor(); + $this->assertNotNull( $constructor ); + $this->assertSame( 0, $constructor->getNumberOfRequiredParameters() ); + $this->assertInstanceOf( auth::class, new auth() ); + } + + + public function testJwksReturnsControllerDataResponseType(): void { + $this->assertSame( controllerDataResponse::class, (string)( new \ReflectionMethod( auth::class, 'jwks' ) )->getReturnType() ); + } + + + public function testFileTokenReturnsControllerDataResponseType(): void { + $this->assertSame( controllerDataResponse::class, (string)( new \ReflectionMethod( auth::class, 'fileToken' ) )->getReturnType() ); + } + + + public function testLifecycleHooksReturnVoid(): void { + auth::_before(); + auth::_after(); + $reflection = new \ReflectionClass( auth::class ); + $this->assertSame( 'void', (string)$reflection->getMethod( '_before' )->getReturnType() ); + $this->assertSame( 'void', (string)$reflection->getMethod( '_after' )->getReturnType() ); + } + + + /** Neither provider should keep a copy of the shared endpoints. */ + public function testProvidersDoNotRedeclareTheSharedEndpoints(): void { + foreach( [ \gcgov\framework\services\auth\providers\oauth\controllers\auth::class, + \gcgov\framework\services\auth\providers\msFront\controllers\auth::class ] as $provider ) { + $this->assertFalse( method_exists( $provider, 'jwks' ), $provider . ' still declares jwks()' ); + $this->assertFalse( method_exists( $provider, 'fileToken' ), $provider . ' still declares fileToken()' ); + } + } + +} diff --git a/tests/Unit/Services/Auth/Providers/MsFront/Controllers/AuthControllerTest.php b/tests/Unit/Services/Auth/Providers/MsFront/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..c8eb71c --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/MsFront/Controllers/AuthControllerTest.php @@ -0,0 +1,53 @@ +assertContains( + \gcgov\framework\interfaces\controller::class, + class_implements( auth::class ) ?: [] + ); + } + + public function testConstructorRequiresNoArguments(): void { + $reflection = new \ReflectionClass( auth::class ); + $constructor = $reflection->getConstructor(); + $this->assertNotNull( $constructor ); + $this->assertSame( 0, $constructor->getNumberOfRequiredParameters() ); + $this->assertInstanceOf( auth::class, new auth() ); + } + + + public function testMicrosoftMethodReturnsControllerDataResponseType(): void { + $reflection = new \ReflectionMethod( auth::class, 'microsoft' ); + $this->assertSame( + \gcgov\framework\models\controllerDataResponse::class, + (string) $reflection->getReturnType() + ); + } + + + public function testLookupUserMicrosoftTokenInfoIsPrivate(): void { + $reflection = new \ReflectionMethod( auth::class, 'lookupUserMicrosoftTokenInfo' ); + $this->assertTrue( $reflection->isPrivate() ); + } + + public function testLifecycleHooksReturnVoid(): void { + auth::_before(); + auth::_after(); + + $reflection = new \ReflectionClass( auth::class ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_before' )->getReturnType() ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_after' )->getReturnType() ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Controllers/AuthControllerTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..59f722a --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Controllers/AuthControllerTest.php @@ -0,0 +1,71 @@ +assertContains( + \gcgov\framework\interfaces\controller::class, + class_implements( auth::class ) ?: [] + ); + } + + public function testConstructorRequiresNoArguments(): void { + $reflection = new \ReflectionClass( auth::class ); + $constructor = $reflection->getConstructor(); + $this->assertNotNull( $constructor ); + $this->assertSame( 0, $constructor->getNumberOfRequiredParameters() ); + $this->assertInstanceOf( auth::class, new auth() ); + } + + + public function testOpenidReturnsControllerDataResponseType(): void { + $reflection = new \ReflectionMethod( auth::class, 'openId' ); + $this->assertSame( + \gcgov\framework\models\controllerDataResponse::class, + (string) $reflection->getReturnType() + ); + } + + + public function testOauthPostAuthorizeMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'oauthPostAuthorize' ) ); + } + + public function testOauthGetAuthorizeMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'oauthGetAuthorize' ) ); + } + + public function testOauthHybridAuthMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'oauthHybridAuth' ) ); + } + + public function testVerifyMfaSecretMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'verifyMfaSecret' ) ); + } + + public function testVerifyMfaCodeMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'verifyMfaCode' ) ); + } + + public function testOutMethodExists(): void { + $this->assertTrue( method_exists( auth::class, 'out' ) ); + } + + public function testLifecycleHooksReturnVoid(): void { + auth::_before(); + auth::_after(); + $reflection = new \ReflectionClass( auth::class ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_before' )->getReturnType() ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_after' )->getReturnType() ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Models/ConfigureMfaResponseTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Models/ConfigureMfaResponseTest.php new file mode 100644 index 0000000..e9d6f3c --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Models/ConfigureMfaResponseTest.php @@ -0,0 +1,63 @@ +assertTrue( is_subclass_of( configureMfaResponse::class, stdAuthResponse::class ) ); + } + + public function testConstructorPopulatesAllFieldsFromUserMultifactor(): void { + $userId = new ObjectId(); + $mf = new userMultifactor( $userId ); + $mf->secret = 'SECRETXYZ'; + + $token = $this->buildToken(); + $response = new configureMfaResponse( $token, $mf, 'data:image/png;base64,abc' ); + + $this->assertSame( 'data:image/png;base64,abc', $response->qrCodeDataUri ); + $this->assertSame( 'SECRETXYZ', $response->secret ); + $this->assertSame( (string) $userId, (string) $response->userId ); + $this->assertSame( (string) $mf->_id, (string) $response->userMultifactorId ); + $this->assertTrue( $response->mfaRequired ); + $this->assertFalse( $response->mfaConfigured ); + $this->assertSame( $token->toString(), $response->access_token ); + } + + public function testQrCodeDataUriDefaultsToEmptyString(): void { + $mf = new userMultifactor( new ObjectId() ); + $response = new configureMfaResponse( null, $mf ); + $this->assertSame( '', $response->qrCodeDataUri ); + } + + public function testNullAccessTokenLeavesAccessTokenEmpty(): void { + $mf = new userMultifactor( new ObjectId() ); + $response = new configureMfaResponse( null, $mf, 'x' ); + $this->assertSame( '', $response->access_token ); + $this->assertSame( 0, $response->expires_in ); + } + + private function buildToken(): Plain { + $exp = ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ); + return new Plain( + new DataSet( [ 'typ' => 'JWT', 'alg' => 'none' ], 'header' ), + new DataSet( [ 'exp' => $exp ], 'payload' ), + new Signature( '', '' ) + ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Models/RequireMfaResponseTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Models/RequireMfaResponseTest.php new file mode 100644 index 0000000..30521ff --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Models/RequireMfaResponseTest.php @@ -0,0 +1,93 @@ +assertTrue( is_subclass_of( requireMfaResponse::class, stdAuthResponse::class ) ); + } + + public function testDefaultsWithNoArguments(): void { + $response = new requireMfaResponse(); + $this->assertTrue( $response->mfaRequired ); + $this->assertTrue( $response->mfaConfigured ); + $this->assertSame( '', $response->access_token ); + } + + public function testAccessTokenWithoutUserPreservesDefaultFlags(): void { + $token = $this->buildToken(); + $response = new requireMfaResponse( $token ); + + $this->assertSame( $token->toString(), $response->access_token ); + $this->assertTrue( $response->mfaRequired ); + $this->assertTrue( $response->mfaConfigured ); + } + + public function testUserDictatesFlagValues(): void { + $user = $this->buildUser( false, false ); + $response = new requireMfaResponse( null, $user ); + + $this->assertFalse( $response->mfaRequired ); + $this->assertFalse( $response->mfaConfigured ); + } + + public function testUserAndAccessTokenCombineCorrectly(): void { + $token = $this->buildToken(); + $user = $this->buildUser( true, false ); + $response = new requireMfaResponse( $token, $user ); + + $this->assertSame( $token->toString(), $response->access_token ); + $this->assertTrue( $response->mfaRequired ); + $this->assertFalse( $response->mfaConfigured ); + } + + private function buildToken(): Plain { + $exp = ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ); + return new Plain( + new DataSet( [ 'typ' => 'JWT', 'alg' => 'none' ], 'header' ), + new DataSet( [ 'exp' => $exp ], 'payload' ), + new Signature( '', '' ) + ); + } + + private function buildUser( bool $mfaRequired, bool $mfaConfigured ): \gcgov\framework\interfaces\auth\user { + return new class( $mfaRequired, $mfaConfigured ) implements \gcgov\framework\interfaces\auth\user { + public bool $mfaRequired; + public bool $mfaConfigured; + public function __construct( bool $mfaRequired, bool $mfaConfigured ) { + $this->mfaRequired = $mfaRequired; + $this->mfaConfigured = $mfaConfigured; + } + public function getId(): string|int|\MongoDB\BSON\ObjectId { return new \MongoDB\BSON\ObjectId(); } + public function getName(): string { return ''; } + public function getUsername(): string { return ''; } + public function getPassword(): string { return ''; } + public function getOauthId(): string { return ''; } + public function getOauthProvider(): string { return ''; } + public function getEmail(): string { return ''; } + public function getRoles(): array { return []; } + public function getActive(): bool { return true; } + public function getMfaRequired(): bool { return $this->mfaRequired; } + public function getMfaConfigured(): bool { return $this->mfaConfigured; } + public static function getFromOauth( string $email, string $externalId, string $externalProvider, ?string $firstName = '', ?string $lastName = '', bool $addIfNotExisting = false, array $rolesForNewUser=[] ): self { throw new \BadMethodCallException(); } + public static function verifyUsernamePassword( string $username, string $password ): self { throw new \BadMethodCallException(); } + public static function getOneByExternalId( string $externalId ): self { throw new \BadMethodCallException(); } + public static function getOneByEmail( string $email ): self { throw new \BadMethodCallException(); } + public static function getOne( \MongoDB\BSON\ObjectId|string|int $_id ): self { throw new \BadMethodCallException(); } + public static function save( object &$object ): mixed { return null; } + }; + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Models/StdAuthResponseTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Models/StdAuthResponseTest.php new file mode 100644 index 0000000..79dc51e --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Models/StdAuthResponseTest.php @@ -0,0 +1,68 @@ +assertSame( 'Bearer', $response->token_type ); + $this->assertSame( 0, $response->expires_in ); + $this->assertSame( '', $response->access_token ); + $this->assertSame( '', $response->refresh_token ); + } + + public function testAccessTokenPopulatesExpiryAndString(): void { + $expiresAt = ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ); + $accessToken = $this->buildToken( [ 'exp' => $expiresAt ] ); + + $response = new stdAuthResponse( $accessToken ); + $this->assertEqualsWithDelta( 3600, $response->expires_in, 5 ); + $this->assertSame( $accessToken->toString(), $response->access_token ); + $this->assertSame( 'Bearer', $response->token_type ); + $this->assertSame( '', $response->refresh_token ); + } + + public function testCustomTokenTypeIsRespected(): void { + $accessToken = $this->buildToken( [ 'exp' => ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT5M' ) ) ] ); + $response = new stdAuthResponse( $accessToken, null, 'MAC' ); + $this->assertSame( 'MAC', $response->token_type ); + } + + public function testRefreshTokenPopulatesRefreshString(): void { + $accessToken = $this->buildToken( [ 'exp' => ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ) ] ); + $refreshToken = $this->buildToken( [ 'exp' => ( new \DateTimeImmutable() )->add( new \DateInterval( 'P30D' ) ) ] ); + + $response = new stdAuthResponse( $accessToken, $refreshToken ); + $this->assertSame( $refreshToken->toString(), $response->refresh_token ); + } + + public function testRefreshTokenAlonePopulatesOnlyRefreshString(): void { + $refreshToken = $this->buildToken( [ 'exp' => ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ) ] ); + $response = new stdAuthResponse( null, $refreshToken ); + $this->assertSame( '', $response->access_token ); + $this->assertSame( $refreshToken->toString(), $response->refresh_token ); + $this->assertSame( 'Bearer', $response->token_type ); + } + + /** + * @param array $claims + */ + private function buildToken( array $claims ): Plain { + $headers = new DataSet( [ 'typ' => 'JWT', 'alg' => 'none' ], 'eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0' ); + $payload = new DataSet( $claims, 'eyJjbGFpbXMiOiJpbnNpZGUifQ' ); + $signature = new Signature( '', '' ); + return new Plain( $headers, $payload, $signature ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaCodeRequestTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaCodeRequestTest.php new file mode 100644 index 0000000..e247089 --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaCodeRequestTest.php @@ -0,0 +1,39 @@ +assertSame( '', $request->code ); + } + + public function testConstructorAssignsCode(): void { + $request = new verifyMfaCodeRequest( '123456' ); + $this->assertSame( '123456', $request->code ); + } + + public function testCodeIsPublicallyMutable(): void { + $request = new verifyMfaCodeRequest(); + $request->code = '987654'; + $this->assertSame( '987654', $request->code ); + } + + public function testExtendsJsonDeserialize(): void { + $this->assertTrue( + is_subclass_of( + verifyMfaCodeRequest::class, + \andrewsauder\jsonDeserialize\jsonDeserialize::class + ) + ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaSecretRequestTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaSecretRequestTest.php new file mode 100644 index 0000000..9738d31 --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Models/VerifyMfaSecretRequestTest.php @@ -0,0 +1,42 @@ +assertSame( '', $request->code ); + $this->assertNull( $request->userMultifactorId ); + } + + public function testConstructorAssignsCodeAndObjectId(): void { + $id = new ObjectId(); + $request = new verifyMfaSecretRequest( 'abc', $id ); + $this->assertSame( 'abc', $request->code ); + $this->assertSame( (string) $id, (string) $request->userMultifactorId ); + } + + public function testConstructorWithCodeOnlyLeavesIdNull(): void { + $request = new verifyMfaSecretRequest( '12345' ); + $this->assertNull( $request->userMultifactorId ); + } + + public function testExtendsJsonDeserialize(): void { + $this->assertTrue( + is_subclass_of( + verifyMfaSecretRequest::class, + \andrewsauder\jsonDeserialize\jsonDeserialize::class + ) + ); + } + +} diff --git a/tests/Unit/Services/Auth/Providers/Oauth/Services/MultifactorTest.php b/tests/Unit/Services/Auth/Providers/Oauth/Services/MultifactorTest.php new file mode 100644 index 0000000..09d52ee --- /dev/null +++ b/tests/Unit/Services/Auth/Providers/Oauth/Services/MultifactorTest.php @@ -0,0 +1,103 @@ +buildUser( true, false ); + $response = multifactor::requireMfaResponse( null, $user ); + + $this->assertInstanceOf( requireMfaResponse::class, $response ); + $this->assertTrue( $response->mfaRequired ); + $this->assertFalse( $response->mfaConfigured ); + } + + public function testRequireMfaResponseIncludesAccessTokenString(): void { + $user = $this->buildUser( true, true ); + $token = $this->buildToken(); + $response = multifactor::requireMfaResponse( $token, $user ); + + $this->assertSame( $token->toString(), $response->access_token ); + $this->assertTrue( $response->mfaRequired ); + $this->assertTrue( $response->mfaConfigured ); + } + + public function testRequireMfaResponseStaticAndReturnsModelType(): void { + $method = new \ReflectionMethod( multifactor::class, 'requireMfaResponse' ); + $this->assertTrue( $method->isStatic() ); + $this->assertTrue( $method->isPublic() ); + $this->assertSame( requireMfaResponse::class, (string) $method->getReturnType() ); + } + + public function testConfigureMfaResponseIsStaticWithMongoIdArgument(): void { + $method = new \ReflectionMethod( multifactor::class, 'configureMfaResponse' ); + $this->assertTrue( $method->isStatic() ); + + $params = $method->getParameters(); + $this->assertSame( 'userId', $params[0]->getName() ); + $this->assertSame( \MongoDB\BSON\ObjectId::class, (string) $params[0]->getType() ); + $this->assertTrue( $params[1]->allowsNull() ); + } + + public function testVerifyMfaSecretIsStaticAndReturnsUserInterface(): void { + $method = new \ReflectionMethod( multifactor::class, 'verifyMfaSecret' ); + $this->assertTrue( $method->isStatic() ); + $this->assertSame( \gcgov\framework\interfaces\auth\user::class, (string) $method->getReturnType() ); + } + + public function testIsMfaCodeCorrectIsStaticAndReturnsBool(): void { + $method = new \ReflectionMethod( multifactor::class, 'isMfaCodeCorrect' ); + $this->assertTrue( $method->isStatic() ); + $this->assertSame( 'bool', (string) $method->getReturnType() ); + } + + private function buildToken(): Plain { + $exp = ( new \DateTimeImmutable() )->add( new \DateInterval( 'PT1H' ) ); + return new Plain( + new DataSet( [ 'typ' => 'JWT', 'alg' => 'none' ], 'h' ), + new DataSet( [ 'exp' => $exp ], 'p' ), + new Signature( '', '' ) + ); + } + + private function buildUser( bool $mfaRequired, bool $mfaConfigured ): \gcgov\framework\interfaces\auth\user { + return new class( $mfaRequired, $mfaConfigured ) implements \gcgov\framework\interfaces\auth\user { + public bool $mfaRequired; + public bool $mfaConfigured; + public function __construct( bool $mfaRequired, bool $mfaConfigured ) { + $this->mfaRequired = $mfaRequired; + $this->mfaConfigured = $mfaConfigured; + } + public function getId(): string|int|\MongoDB\BSON\ObjectId { return new \MongoDB\BSON\ObjectId(); } + public function getName(): string { return ''; } + public function getUsername(): string { return ''; } + public function getPassword(): string { return ''; } + public function getOauthId(): string { return ''; } + public function getOauthProvider(): string { return ''; } + public function getEmail(): string { return ''; } + public function getRoles(): array { return []; } + public function getActive(): bool { return true; } + public function getMfaRequired(): bool { return $this->mfaRequired; } + public function getMfaConfigured(): bool { return $this->mfaConfigured; } + public static function getFromOauth( string $email, string $externalId, string $externalProvider, ?string $firstName = '', ?string $lastName = '', bool $addIfNotExisting = false, array $rolesForNewUser=[] ): self { throw new \BadMethodCallException(); } + public static function verifyUsernamePassword( string $username, string $password ): self { throw new \BadMethodCallException(); } + public static function getOneByExternalId( string $externalId ): self { throw new \BadMethodCallException(); } + public static function getOneByEmail( string $email ): self { throw new \BadMethodCallException(); } + public static function getOne( \MongoDB\BSON\ObjectId|string|int $_id ): self { throw new \BadMethodCallException(); } + public static function save( object &$object ): mixed { return null; } + }; + } + +} diff --git a/tests/Unit/Services/Auth/RouterTest.php b/tests/Unit/Services/Auth/RouterTest.php new file mode 100644 index 0000000..7c4a4e2 --- /dev/null +++ b/tests/Unit/Services/Auth/RouterTest.php @@ -0,0 +1,165 @@ + $provider ] ) ); + } + + + protected function setUp(): void { + unset( $_SERVER[ 'HTTP_AUTHORIZATION' ], $_GET[ 'fileAccessToken' ] ); + } + + + private static function findRoute( router $router, string $method ): route { + foreach( $router->getRoutes() as $route ) { + if( $route->method===$method ) { + return $route; + } + } + throw new \LogicException( 'No route with method=' . $method ); + } + + + public function testRouterImplementsTheFrameworkRouterInterface(): void { + $this->assertContains( \gcgov\framework\interfaces\router::class, class_implements( router::class ) ?: [] ); + } + + + public function testOauthProviderRegistersNineRoutes(): void { + $routes = self::router( 'oauth' )->getRoutes(); + $this->assertCount( 9, $routes ); + foreach( $routes as $route ) { + $this->assertInstanceOf( route::class, $route ); + } + } + + + public function testMsFrontProviderRegistersThreeRoutes(): void { + $this->assertCount( 3, self::router( 'msFront' )->getRoutes() ); + } + + + /** jwks and fileToken are the same endpoints whichever provider is selected. */ + public function testSharedRoutesArePresentForBothProviders(): void { + foreach( [ 'oauth', 'msFront' ] as $provider ) { + $router = self::router( $provider ); + + $jwks = self::findRoute( $router, 'jwks' ); + $this->assertSame( 'GET', $jwks->httpMethod ); + $this->assertSame( '/api/.well-known/jwks.json', $jwks->route ); + $this->assertFalse( $jwks->authentication ); + + $fileToken = self::findRoute( $router, 'fileToken' ); + $this->assertSame( '/api/auth/fileToken', $fileToken->route ); + $this->assertTrue( $fileToken->authentication ); + } + } + + + public function testOpenidConfigurationRouteIsPublic(): void { + $route = self::findRoute( self::router( 'oauth' ), 'openId' ); + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertSame( '/api/.well-known/openid-configuration', $route->route ); + $this->assertFalse( $route->authentication ); + } + + + public function testAuthorizeRoutesShareOnePathAcrossTwoMethods(): void { + $router = self::router( 'oauth' ); + + $post = self::findRoute( $router, 'oauthPostAuthorize' ); + $this->assertSame( 'POST', $post->httpMethod ); + $this->assertSame( '/api/auth/authorize', $post->route ); + $this->assertFalse( $post->authentication ); + + $get = self::findRoute( $router, 'oauthGetAuthorize' ); + $this->assertSame( 'GET', $get->httpMethod ); + $this->assertSame( '/api/auth/authorize', $get->route ); + } + + + public function testHybridAuthRouteHasProviderPlaceholder(): void { + $this->assertSame( '/api/auth/hybridauth/{provider}', self::findRoute( self::router( 'oauth' ), 'oauthHybridAuth' )->route ); + } + + + public function testMfaRoutesArePostAndAuthenticated(): void { + foreach( [ 'verifyMfaSecret', 'verifyMfaCode' ] as $method ) { + $route = self::findRoute( self::router( 'oauth' ), $method ); + $this->assertSame( 'POST', $route->httpMethod ); + $this->assertTrue( $route->authentication ); + } + } + + + public function testMicrosoftExchangeRouteIsPublic(): void { + $route = self::findRoute( self::router( 'msFront' ), 'microsoft' ); + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertSame( '/api/auth/microsoft', $route->route ); + $this->assertFalse( $route->authentication ); + } + + + public function testMfaRoutesAreAbsentUnderMsFront(): void { + $methods = array_map( fn( route $r ): string => $r->method, self::router( 'msFront' )->getRoutes() ); + $this->assertNotContains( 'verifyMfaCode', $methods ); + $this->assertNotContains( 'oauthPostAuthorize', $methods ); + } + + + public function testMissingAuthorizationHeaderIs401( ): void { + foreach( [ 'oauth', 'msFront' ] as $provider ) { + try { + self::router( $provider )->authentication( new routeHandler( '\some\controller', 'm' ) ); + $this->fail( 'Expected routeException for ' . $provider ); + } + catch( routeException $e ) { + $this->assertSame( 401, $e->getCode() ); + $this->assertSame( 'Missing Authorization', $e->getMessage() ); + } + } + } + + + public function testShortLivedUrlTokensAllowedButNoTokenSuppliedIs401(): void { + $handler = new routeHandler( '\some\controller', 'm' ); + $handler->allowShortLivedUrlTokens = true; + + try { + self::router( 'oauth' )->authentication( $handler ); + $this->fail( 'Expected routeException' ); + } + catch( routeException $e ) { + $this->assertSame( 401, $e->getCode() ); + } + } + + + public function testMalformedJwtIsRejected(): void { + $_SERVER[ 'HTTP_AUTHORIZATION' ] = 'not.a.valid.jwt'; + + $this->expectException( \Throwable::class ); + self::router( 'oauth' )->authentication( new routeHandler( '\some\controller', 'm' ) ); + } + +} diff --git a/tests/Unit/Services/CronMonitor/CronMonitorTest.php b/tests/Unit/Services/CronMonitor/CronMonitorTest.php new file mode 100644 index 0000000..ff5b9ae --- /dev/null +++ b/tests/Unit/Services/CronMonitor/CronMonitorTest.php @@ -0,0 +1,118 @@ +> */ + private array $requestHistory; + + protected function setUp(): void { + $this->requestHistory = []; + $this->primeFrameworkConfig(); + } + + + /** The monitor url moved out of the untyped appDictionary into its own section. */ + private function primeFrameworkConfig(): void { + $config = ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->getValue(); + $config->cronMonitor->url = 'http://monitor.test/'; + } + + public function testClassExposesEndMethodReturningVoid(): void { + $reflection = new \ReflectionClass( cronMonitor::class ); + $this->assertTrue( $reflection->hasMethod( 'end' ) ); + $this->assertSame( 'void', (string) $reflection->getMethod( 'end' )->getReturnType() ); + } + + public function testConstructorStoresJobIdAndFiresStartRequest(): void { + $monitor = $this->buildMonitorWithMockedTransport( 'job-001', [ + new Response( 200, [], json_encode( [ 'data' => 'run-xyz' ] ) ), + new Response( 200, [], '' ), + ] ); + + $this->assertSame( 'job-001', $this->reflectProperty( $monitor, 'jobId' ) ); + $this->assertInstanceOf( PromiseInterface::class, $this->reflectProperty( $monitor, 'jobPromise' ) ); + + $monitor->end(); + $this->assertCount( 2, $this->requestHistory ); + $this->assertSame( 'http://monitor.test/jobHistory/start/job-001', (string) $this->requestHistory[0][ 'request' ]->getUri() ); + $this->assertSame( 'http://monitor.test/jobHistory/end/job-001/run-xyz', (string) $this->requestHistory[1][ 'request' ]->getUri() ); + } + + public function testEndDefaultsRunIdToEmptyWhenStartResponseIsInvalidJson(): void { + $monitor = $this->buildMonitorWithMockedTransport( 'job-002', [ + new Response( 200, [], 'not-json' ), + new Response( 200, [], '' ), + ] ); + $monitor->end(); + + $this->assertCount( 2, $this->requestHistory ); + $this->assertSame( 'http://monitor.test/jobHistory/end/job-002/', (string) $this->requestHistory[1][ 'request' ]->getUri() ); + } + + public function testEndSwallowsExceptionsFromFinalRequest(): void { + $monitor = $this->buildMonitorWithMockedTransport( 'job-004', [ + new Response( 200, [], json_encode( [ 'data' => 'run-abc' ] ) ), + new \RuntimeException( 'network is down' ), + ] ); + + $monitor->end(); + $this->assertCount( 2, $this->requestHistory ); + } + + public function testEndSwallowsStartPromiseExceptionAndStillFiresEndRequest(): void { + // The start-promise wait() is now wrapped in the same try/catch as + // the JSON-decoding step, so an exception from the start request + // is swallowed and the end request still fires with an empty runId. + $monitor = $this->buildMonitorWithMockedTransport( 'job-005', [ + new \RuntimeException( 'start failed' ), + new Response( 200, [], '' ), + ] ); + + $monitor->end(); + $this->assertCount( 2, $this->requestHistory ); + $this->assertSame( 'http://monitor.test/jobHistory/end/job-005/', (string) $this->requestHistory[1][ 'request' ]->getUri() ); + } + + /** + * @param list<\Throwable|Response> $queue + */ + private function buildMonitorWithMockedTransport( string $jobId, array $queue ): cronMonitor { + $monitor = ( new \ReflectionClass( cronMonitor::class ) )->newInstanceWithoutConstructor(); + + $mock = new MockHandler( $queue ); + $stack = HandlerStack::create( $mock ); + $stack->push( Middleware::history( $this->requestHistory ) ); + $client = new Client( [ 'base_uri' => 'http://monitor.test/', 'handler' => $stack ] ); + + $jobIdProp = new \ReflectionProperty( cronMonitor::class, 'jobId' ); + $jobIdProp->setValue( $monitor, $jobId ); + $clientProp = new \ReflectionProperty( cronMonitor::class, 'client' ); + $clientProp->setValue( $monitor, $client ); + $promiseProp = new \ReflectionProperty( cronMonitor::class, 'jobPromise' ); + $promiseProp->setValue( $monitor, $client->requestAsync( 'GET', 'jobHistory/start/' . $jobId ) ); + + return $monitor; + } + + private function reflectProperty( cronMonitor $monitor, string $name ): mixed { + $prop = new \ReflectionProperty( cronMonitor::class, $name ); + return $prop->getValue( $monitor ); + } + + +} diff --git a/tests/Unit/Services/Documentation/Controllers/DocumentationControllerTest.php b/tests/Unit/Services/Documentation/Controllers/DocumentationControllerTest.php new file mode 100644 index 0000000..de496a9 --- /dev/null +++ b/tests/Unit/Services/Documentation/Controllers/DocumentationControllerTest.php @@ -0,0 +1,82 @@ +primeFrameworkAppDir(); + } + + public function testControllerImplementsFrameworkControllerInterface(): void { + $this->assertContains( + \gcgov\framework\interfaces\controller::class, + class_implements( documentation::class ) ?: [] + ); + } + + public function testConstructorAcceptsNoArguments(): void { + $reflection = new \ReflectionClass( documentation::class ); + $this->assertSame( 0, $reflection->getConstructor()?->getNumberOfRequiredParameters() ); + $this->assertInstanceOf( documentation::class, new documentation() ); + } + + public function testRoutesReturnsEmptyControllerDataResponse(): void { + $response = ( new documentation() )->routes(); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + } + + public function testYamlMethodIsMarkedNoReturn(): void { + $reflection = new \ReflectionMethod( documentation::class, 'yaml' ); + $attributes = $reflection->getAttributes(); + $names = array_map( fn( \ReflectionAttribute $a ) => $a->getName(), $attributes ); + $this->assertContains( \JetBrains\PhpStorm\NoReturn::class, $names ); + } + + public function testGetScanDirectoriesReturnsExistingDirectories(): void { + $controller = new documentation(); + $method = new \ReflectionMethod( $controller, 'getScanDirectories' ); + $directories = $method->invoke( $controller ); + + $this->assertIsArray( $directories ); + foreach ( $directories as $dir ) { + $this->assertIsString( $dir ); + $this->assertDirectoryExists( $dir ); + } + } + + public function testGetExcludeDirectoriesFilesReturnsArray(): void { + $controller = new documentation(); + $method = new \ReflectionMethod( $controller, 'getExcludeDirectoriesFiles' ); + $exclusions = $method->invoke( $controller ); + + $this->assertIsArray( $exclusions ); + } + + public function testLifecycleHooksReturnVoid(): void { + documentation::_before(); + documentation::_after(); + + $reflection = new \ReflectionClass( documentation::class ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_before' )->getReturnType() ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_after' )->getReturnType() ); + } + + private function primeFrameworkAppDir(): void { + // config::getAppDir() reflects on \app\app to derive the directory. + // Use a temp dir for the stub so getScanDirectories can verify any + // real-on-disk paths return false (and thus get filtered). + if ( !class_exists( '\app\app' ) ) { + eval( 'namespace app; class app {}' ); + } + } + +} diff --git a/tests/Unit/Services/Documentation/RouterTest.php b/tests/Unit/Services/Documentation/RouterTest.php new file mode 100644 index 0000000..e5a96fc --- /dev/null +++ b/tests/Unit/Services/Documentation/RouterTest.php @@ -0,0 +1,74 @@ +basePath = 'api/v1'; + ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->setValue( null, $config ); + } + + + + + public function testRouterImplementsFrameworkRouterInterface(): void { + $this->assertContains( + \gcgov\framework\interfaces\router::class, + class_implements( router::class ) ?: [] + ); + } + + public function testGetRoutesReturnsSingleDocumentationYamlRoute(): void { + $routes = ( new router() )->getRoutes(); + $this->assertCount( 1, $routes ); + $this->assertInstanceOf( route::class, $routes[0] ); + } + + public function testRouteIsGetMethodAtConfiguredBasePath(): void { + $routes = ( new router() )->getRoutes(); + /** @var route $route */ + $route = $routes[0]; + + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertSame( '/api/v1/documentation.yaml', $route->route ); + } + + public function testRouteTargetsDocumentationControllerYamlMethod(): void { + $routes = ( new router() )->getRoutes(); + /** @var route $route */ + $route = $routes[0]; + + $this->assertSame( '\gcgov\framework\services\documentation\controllers\documentation', $route->class ); + $this->assertSame( 'yaml', $route->method ); + } + + public function testRouteIsUnauthenticated(): void { + $routes = ( new router() )->getRoutes(); + /** @var route $route */ + $route = $routes[0]; + + $this->assertFalse( $route->authentication ); + } + + public function testAuthenticationAlwaysReturnsTrue(): void { + $routeHandler = $this->createStub( \gcgov\framework\models\routeHandler::class ); + $this->assertTrue( ( new router() )->authentication( $routeHandler ) ); + } + + +} diff --git a/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php b/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php new file mode 100644 index 0000000..e309dbf --- /dev/null +++ b/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php @@ -0,0 +1,212 @@ +assertContains( + \gcgov\framework\interfaces\controller::class, + class_implements( user::class ) ?: [] + ); + } + + public function testGetAllReturnsPagedDataResponse(): void { + $response = ( new user() )->getAll(); + $this->assertInstanceOf( controllerPagedDataResponse::class, $response ); + } + + public function testGetAllReturnsRecordsFromUserModel(): void { + $record = new FakeUser(); + $record->_id = 'u1'; + $record->name = 'Alice'; + FakeUser::$records[ 'u1' ] = $record; + + $response = ( new user() )->getAll(); + $this->assertInstanceOf( controllerPagedDataResponse::class, $response ); + } + + public function testGetAllWrapsModelExceptionInControllerException(): void { + FakeUser::$nextException = new modelException( 'boom', 500 ); + + $this->expectException( controllerException::class ); + $this->expectExceptionMessage( 'boom' ); + ( new user() )->getAll(); + } + + public function testGetOneReturnsExistingUser(): void { + $record = new FakeUser(); + $record->_id = 'u42'; + $record->name = 'Bob'; + FakeUser::$records[ 'u42' ] = $record; + + $response = ( new user() )->getOne( 'u42' ); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + } + + public function testGetOneWithNewSentinelInstantiatesEmptyUser(): void { + $response = ( new user() )->getOne( 'new' ); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + } + + public function testGetOneWrapsModelExceptionInControllerException(): void { + $this->expectException( controllerException::class ); + ( new user() )->getOne( 'does-not-exist' ); + } + + public function testSaveDeserializesPayloadAndPersists(): void { + $payload = json_encode( [ '_id' => 'u99', 'name' => 'Carol' ] ); + $this->withPhpInput( $payload, function() { + $response = ( new user() )->save( 'u99' ); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + $this->assertArrayHasKey( 'u99', FakeUser::$records ); + $this->assertSame( 'Carol', FakeUser::$records[ 'u99' ]->name ); + } ); + } + + public function testSaveWrapsModelExceptionInControllerException(): void { + FakeUser::$nextException = new modelException( 'validation failure', 422 ); + + $this->withPhpInput( json_encode( [ '_id' => 'u1' ] ), function() { + $this->expectException( controllerException::class ); + $this->expectExceptionCode( 422 ); + ( new user() )->save( 'u1' ); + } ); + } + + public function testDeleteReturnsNoContentWhenSucceeded(): void { + $record = new FakeUser(); + $record->_id = 'u88'; + FakeUser::$records[ 'u88' ] = $record; + FakeUser::$deleteAffectedCount = 1; + + $response = ( new user() )->delete( 'u88' ); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + $this->assertSame( 204, $response->getHttpStatus() ); + } + + public function testDeleteThrows404WhenNoRecordsAffected(): void { + FakeUser::$deleteAffectedCount = 0; + + $this->expectException( controllerException::class ); + $this->expectExceptionCode( 404 ); + ( new user() )->delete( 'missing' ); + } + + public function testDeleteWrapsModelExceptionInControllerException(): void { + FakeUser::$nextException = new modelException( 'db error', 500 ); + + $this->expectException( controllerException::class ); + $this->expectExceptionMessage( 'db error' ); + ( new user() )->delete( 'u1' ); + } + + public function testLifecycleHooksReturnVoid(): void { + user::_before(); + user::_after(); + + $reflection = new \ReflectionClass( user::class ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_before' )->getReturnType() ); + $this->assertSame( 'void', (string) $reflection->getMethod( '_after' )->getReturnType() ); + } + + public function testConstructorAcceptsNoArguments(): void { + $reflection = new \ReflectionClass( user::class ); + $constructor = $reflection->getConstructor(); + $this->assertNotNull( $constructor ); + $this->assertSame( 0, $constructor->getNumberOfRequiredParameters() ); + } + + private function withPhpInput( string $body, callable $work ): void { + // php://input is read-only, so we hijack via a stream wrapper. + stream_wrapper_unregister( 'php' ); + stream_wrapper_register( 'php', PhpInputStreamWrapper::class ); + PhpInputStreamWrapper::$content = $body; + try { + $work(); + } + finally { + stream_wrapper_restore( 'php' ); + } + } + +} + +final class PhpInputStreamWrapper { + public static string $content = ''; + public $context; + private int $position = 0; + + public function stream_open( string $path, string $mode, int $options, ?string &$opened_path ): bool { + $this->position = 0; + return true; + } + + public function stream_read( int $count ): string { + $ret = substr( self::$content, $this->position, $count ); + $this->position += strlen( $ret ); + return $ret; + } + + public function stream_eof(): bool { + return $this->position >= strlen( self::$content ); + } + + public function stream_stat(): array { + return [ 'size' => strlen( self::$content ) ]; + } + + public function stream_close(): void {} + + public function stream_seek( int $offset, int $whence = SEEK_SET ): bool { + switch ( $whence ) { + case SEEK_SET: $this->position = $offset; break; + case SEEK_CUR: $this->position += $offset; break; + case SEEK_END: $this->position = strlen( self::$content ) + $offset; break; + } + return true; + } + + public function stream_tell(): int { + return $this->position; + } + + public function url_stat( string $path, int $flags ): array|false { + return [ 'size' => strlen( self::$content ) ]; + } +} diff --git a/tests/Unit/Services/UserCrud/RouterTest.php b/tests/Unit/Services/UserCrud/RouterTest.php new file mode 100644 index 0000000..736b3e9 --- /dev/null +++ b/tests/Unit/Services/UserCrud/RouterTest.php @@ -0,0 +1,98 @@ +basePath = 'api'; + ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->setValue( null, $config ); + } + + + + + public function testRouterImplementsFrameworkRouterInterface(): void { + $this->assertContains( + \gcgov\framework\interfaces\router::class, + class_implements( router::class ) ?: [] + ); + } + + public function testGetRoutesReturnsFourCrudRoutes(): void { + $routes = ( new router() )->getRoutes(); + $this->assertCount( 4, $routes ); + foreach ( $routes as $route ) { + $this->assertInstanceOf( route::class, $route ); + } + } + + public function testGetAllRouteIsGetAtBasePathUser(): void { + $route = $this->routes()[0]; + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertSame( '/api/user', $route->route ); + $this->assertSame( 'getAll', $route->method ); + $this->assertTrue( $route->authentication ); + $this->assertSame( [ 'User.Read' ], $route->requiredRoles ); + } + + public function testGetOneRouteIsGetWithIdParameter(): void { + $route = $this->routes()[1]; + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertSame( '/api/user/{_id}', $route->route ); + $this->assertSame( 'getOne', $route->method ); + $this->assertTrue( $route->authentication ); + $this->assertSame( [ 'User.Read' ], $route->requiredRoles ); + } + + public function testSaveRouteIsPostAndRequiresWritePermission(): void { + $route = $this->routes()[2]; + $this->assertSame( 'POST', $route->httpMethod ); + $this->assertSame( '/api/user/{_id}', $route->route ); + $this->assertSame( 'save', $route->method ); + $this->assertSame( [ 'User.Read', 'User.Write' ], $route->requiredRoles ); + } + + public function testDeleteRouteIsDeleteAndRequiresWritePermission(): void { + $route = $this->routes()[3]; + $this->assertSame( 'DELETE', $route->httpMethod ); + $this->assertSame( '/api/user/{_id}', $route->route ); + $this->assertSame( 'delete', $route->method ); + $this->assertSame( [ 'User.Read', 'User.Write' ], $route->requiredRoles ); + } + + public function testAllRoutesTargetUserController(): void { + $expected = [ + 'gcgov\framework\services\userCrud\controllers\user', + '\gcgov\framework\services\userCrud\controllers\user', + ]; + foreach ( $this->routes() as $route ) { + $this->assertContains( $route->class, $expected ); + } + } + + public function testAuthenticationContractReturnsTrue(): void { + $routeHandler = $this->createStub( \gcgov\framework\models\routeHandler::class ); + $this->assertTrue( ( new router() )->authentication( $routeHandler ) ); + } + + + /** @return list */ + private function routes(): array { + return ( new router() )->getRoutes(); + } + +} From d8d09550e25668555e5c1172c22358ea7121d665 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 15:37:29 +0000 Subject: [PATCH 14/30] Record ADR 0005 and update the docs for config-activated services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTEXT.md defined Framework Service by the mechanism this work deletes ("when the Application registers its namespace"), so the glossary entry is rewritten and Provider added beside it. Service namespace registration and "auth plugin" join Retired language. ADR 0005 records why, including the part that will not be reconstructible from the diff: the packages are properly released and independently versioned — an earlier draft claimed otherwise, having misread clones that had not fetched tags — so folding in trades that away deliberately, in exchange for the three things the split caused and could not fix. CLAUDE.md, README.md, readme/app.php.md, readme/router.php.md and readme/gf.md drop the plugin vocabulary and describe the services section. app.php.md needed rewriting rather than editing: its subject was the deleted method. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KmBBiV3fQaaRdrmspZarS5 --- CLAUDE.md | 128 +++++++++++------- CONTEXT.md | 18 ++- README.md | 38 ++++-- ...vices-are-built-in-and-config-activated.md | 64 +++++++++ readme/app.php.md | 42 ++++-- readme/environment-variables.md | 2 +- readme/gf.md | 3 +- readme/router.php.md | 2 +- 8 files changed, 213 insertions(+), 84 deletions(-) create mode 100644 docs/adr/0005-framework-services-are-built-in-and-config-activated.md diff --git a/CLAUDE.md b/CLAUDE.md index ef27135..add3ec5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — gcgov/framework -Guidance for Claude when working **on this framework** or **on any application/plugin built on it**. +Guidance for Claude when working **on this framework** or **on any application built on it**. This file is the fast path to a correct mental model. For exhaustive reference, see `README.md` and the `readme/` directory (especially `readme/mongodb.md`). @@ -13,7 +13,7 @@ SSR apps) for Garrett County Government. Composer package name: `gcgov/framework `gcgov\framework\` → `src/`. A full API with Microsoft OAuth authentication, user CRUD, and OpenAPI docs can be assembled with **almost -no custom code** by installing framework-service plugins (see §12). The framework's standout feature is its +no custom code** by enabling Framework Services in config.json (see §12). The framework's standout feature is its **MongoDB document-modeling system** (`\gcgov\framework\services\mongodb`), which is where most of the code and most of the complexity lives (§7). @@ -48,7 +48,7 @@ phpstan.neon.dist # PHPStan level config; phpstan-stubs/ holds stubs - **Class names are lowercase**: `class inspection`, `class user`, `class router`, `class app`, `controllerDataResponse`. This is deliberate and pervasive. File name == class name (`inspection.php`). - App code lives under namespace `\app` mapped to the app's `/app` directory. Framework code is - `\gcgov\framework\...`. Plugins are `\gcgov\framework\services\\...`. + `\gcgov\framework\...`. Framework Services are `\gcgov\framework\services\\...`. - Do not "modernize" to StudlyCase class names — you will break PSR-4 autoloading and every reference. --- @@ -60,7 +60,7 @@ An app that runs a full request lifecycle must supply, in its `/app` directory: | File | Class | Must implement | |------|-------|----------------| | `/app/app.php` | `\app\app` | `\gcgov\framework\interfaces\app` | -| `/app/router.php` | `\app\router` | `\gcgov\framework\interfaces\router` | +| `/app/router.php` | `\app\router` | `\gcgov\framework\interfaces\appRouter` | | `/app/renderer.php` | `\app\renderer` | `\gcgov\framework\interfaces\render` | | `/app/controllers/*.php` | e.g. `\app\controllers\widget` | `\gcgov\framework\interfaces\controller` | @@ -101,9 +101,10 @@ hooks defined by the `lifecycle\before` / `lifecycle\after` interfaces: ``` www/index.php app::_before() - new app() → app->registerFrameworkServiceNamespaces() # returns plugin namespaces to load + new app() # no longer asked which services to load router::_before() - new framework\router(serviceNamespaces) # instantiates each plugin's \{ns}\router if present, then \app\router + new framework\router() # health, then each service enabled in config.json's + # `services` section, then \app\router framework\router->route() # FastRoute dispatch + auth guards → routeHandler (or routeException) router::_after() renderer::_before() @@ -119,14 +120,14 @@ www/index.php Rules a controller method must obey: - **Always return a `controllerResponse` subtype (§6). Never `die()`/`exit`** — it skips the rest of the - lifecycle. (The documentation plugin's `yaml()` is the one deliberate exception.) + lifecycle. (The documentation service's `yaml()` is the one deliberate exception.) - Route method parameters are bound positionally from the URL pattern placeholders. --- ## 5. Routing -`\app\router::getRoutes()` returns `\gcgov\framework\models\route[]`. Plugin routers contribute routes too; +`\app\router::getRoutes()` returns `\gcgov\framework\models\route[]`. Service routers contribute routes too; the framework merges **service routes first, then app routes** (`framework\router::getRoutes()`). ```php @@ -148,19 +149,23 @@ $routes[] = new route('POST', 'structure/{_id}', '\app\controllers\structure', $routes[] = new route('CLI', '/cli/cleanup', '\app\controllers\cli\import','cleanup',false); ``` If the app is not served at the domain root, prepend a base path (commonly -`config::getBasePath()`, which is what plugin routers use). +`config::getBasePath()`, which is what service routers use). ### Authentication guard flow (`framework\router::route()`) For a matched route with `authentication === true`: 1. `\app\router::authentication($routeHandler)` runs **first** (your custom checks). Return `false` → 401. -2. Then **each plugin router's** `authentication()` runs — unless `\app\router` defines - `getRunFrameworkServiceRouteAuthentication($routeHandler): bool` and returns `false` for that route. -3. Auth plugins (oauth-server / auth-ms-front) validate the JWT from the `Authorization: Bearer …` header +2. Then **each enabled service router's** `authentication()` runs — unless `\app\router` implements + `\gcgov\framework\interfaces\router\skipsServiceAuthentication` and returns `false` for that route. +3. The auth service validates the JWT from the `Authorization: Bearer …` header (or `?fileAccessToken=` when `allowShortLivedUrlTokens`), populate the request-scoped `authUser`, and enforce `requiredRoles` (missing header → 401, missing role → 403). -Routes with `authentication === false` skip all of this. There is **no built-in auth**; it comes from a -plugin (§12). A `routeException` thrown anywhere in this flow becomes the HTTP error response. +Routes with `authentication === false` skip all of this. A `routeException` thrown anywhere in this flow +becomes the HTTP error response. + +**The framework refuses to boot** if any route sets `authentication: true` while no auth service is +enabled and `\app\router::providesAuthentication()` returns `false` — such routes look protected and are +open to anyone, because the scaffolded `authentication()` returns `true`. --- @@ -332,7 +337,7 @@ exposed **directly on `config`** — there are no separate appConfig/environment `getRootUrl()`, `getBaseUrl()`, `getBasePath()`, `getLogging()`, `getMongoDatabases()`, `getSqlDatabases()`, `getDefaultSqlDatabase()`, `getSqlDatabaseByName($name)`, `getMicrosoft()`, `getJwtAuth()`, `getTokenIssuedBy()`, `getTokenPermittedFor()`, `getJwtKeyPath()`, -`getPayjunction()`, `getAppDictionary()`. +`getPayjunction()`, `getAppDictionary()`, `getServices()`, `getCronMonitor()`. `serverName`, `cookieUrl` and `phpPath` were **removed in v7** — nothing read them (confirmed across the framework and all five framework services). The PHP interpreter is `GF_PHP` / `gf cli --php`. @@ -370,7 +375,7 @@ environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hostin { "app": { "title": "...", "guid": "..." }, "email": { "fromAddress": "", "fromName": "", "useSMTP": false, "SMTPHost": "", "SMTPPort": 587, "...": "" }, - "settings": { "useSession": false, "forceMfaForPasswordUsers": false }, + "settings": { "forceMfaForPasswordUsers": false }, "type": "local|prod", "rootUrl": "", "basePath": "", "logging": { "lifecycle": false, "renderer": false, "destination": "stderr|file|both" }, // stderr (default) emits JSON lines @@ -381,9 +386,21 @@ environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hostin "jwtAuth": { "tokenIssuedBy": "", "tokenPermittedFor": "", // empty → derived from rootUrl / basePath "redirectAfterLoginUrl": "", "redirectAfterLogoutUrl": "", "keyPath": "" }, // empty → {root}/srv/jwtCertificates - "appDictionary": { } // free-form key/values plugins read (e.g. cronMonitorUrl) + "cronMonitor": { "url": "" }, // empty disables cron run reporting + "services": { // presence enables; absent = off; contents are that service's settings + "auth": { "provider": "oauth", // "oauth" | "msFront" — required when auth is present + "blockNewUsers": true, + "defaultNewUserRoles": [], + "oauth": { "authorizeUrlParameters": {} } }, // only for provider "oauth" + "userCrud": { }, + "documentation": { } + }, + "appDictionary": { } // free-form key/values an application reads } ``` +`services.auth` is fail-closed: an unknown `provider`, or a block for the provider that is **not** +selected, is a startup failure. A missing block for the provider that *is* selected hydrates to its +defaults, like every other section. --- @@ -392,13 +409,13 @@ environment, Docker/K8s secrets, or a `.env` file — the basis of Docker hostin | Call | Purpose | |------|---------| | `services\log::{debug,info,notice,warning,error,critical,alert,emergency}($channel,$msg,$context=[])` | Monolog-backed. Destination is `logging.destination`: `stderr` (default, JSON lines) / `file` (`/logs/{channel}.log`) / `both`. | -| `services\request::getAuthUser(): authUser` | Request-scoped authenticated user (roles, id, email…). Populated by the auth plugin's guard. | +| `services\request::getAuthUser(): authUser` | Request-scoped authenticated user (roles, id, email…). Populated by the auth service's guard. | | `services\request::getUserClassFqdn(): string` | Resolves the app's user model FQDN: `\app\models\user`, else the Mongo `…\models\auth\user`. | | `services\request::getPostData(): array` | Parsed request body. | | `services\guid::create($trim=true)` | GUID string. | | `services\http::statusText($code)` | HTTP status text. | | `services\formatting::fileName() / xlsxTabName() / getDateIntervalHumanText()` | Sanitizers/formatters. | -| `services\jwtAuth\jwtAuth` | JWT create/validate for access & refresh tokens; JWKS. Used by auth plugins — don't hand-roll auth. | +| `services\jwtAuth\jwtAuth` | JWT create/validate for access & refresh tokens; JWKS. Used by the auth service — don't hand-roll auth. | | `services\chrome\chrome::getExecutablePath() / ::getBrowserFactory()` | Headless Chrome: path to the gf-installed chrome-headless-shell binary, or a ready `\HeadlessChromium\BrowserFactory` (chrome-php/chrome). Throws `serviceException` until `gf chrome:install` has run. | | `new services\pdodb\pdodb($readOnly=true, $databaseName='')` | Thin PDO wrapper using `sqlDatabases` config (read vs write account). | | `services\microsoft\*` | **Deprecated** — use `andrewsauder/microsoftServices` instead. | @@ -445,8 +462,9 @@ List routes with `gf cli:list`; debug with `gf cli /path --debug`. - `aggregation()` does **not** auto-apply the typemap. - Deeply nested/mutually-referential models can infinite-loop the typemap → use `#[excludeFromTypemapWhenThisClassNotRoot]`. -- There's no auth without an auth plugin; and auth plugins register a **global guard** over every - `authentication:true` route in the app. +- There's no auth without `services.auth`; it registers a **global guard** over every + `authentication:true` route. The framework refuses to boot if authenticated routes exist without it + (and without `\app\router::providesAuthentication()`), rather than serving them unprotected. - Set `logging.lifecycle=true` in `config.json` to trace the entire pipeline when debugging routing/auth. - Every `%env()` reference is required — there is no default and `FOO=` counts as unset. `gf env` says which one is missing. @@ -456,36 +474,44 @@ List routes with `gf cli:list`; debug with `gf cli /path --debug`. --- -## 12. Extensions / plugins +## 12. Framework Services + +Framework Services ship **inside** the framework (`src/services/`). Enable one by adding its block to the +`services` section of `config.json` — presence enables, and the block's contents are its settings, so +activation and configuration are one statement. See ADR 0005. + +| Config key | Namespace | Adds | +|------------|-----------|------| +| `services.auth` (`provider: "oauth"`) | `\gcgov\framework\services\auth` | Full OAuth server (password + third-party + MFA), JWKS, file tokens, global JWT guard. | +| `services.auth` (`provider: "msFront"`) | same | Exchange a Microsoft token the front end holds for an app JWT, plus the same JWKS/file tokens/guard. | +| `services.userCrud` | `\gcgov\framework\services\userCrud` | `/user` CRUD over the resolved user model (`User.Read` / `User.Write`). | +| `services.documentation` | `\gcgov\framework\services\documentation` | `GET /documentation.yaml` (OpenAPI from annotations). | -Register a plugin by adding its namespace to `\app\app::registerFrameworkServiceNamespaces()`; the framework -then auto-discovers `\{namespace}\router` and merges its routes + auth guard. +There is **one** auth service with two providers, so two cannot be active at once — it is unrepresentable +rather than merely discouraged. -| Plugin (repo) | Namespace to register | Adds | -|---------------|-----------------------|------| -| `gcgov/framework-service-documentation` | `\gcgov\framework\services\documentation` | `GET /documentation.yaml` (OpenAPI from annotations). | -| `gcgov/framework-service-auth-ms-front` | `\gcgov\framework\services\authmsfront` | Exchange a Microsoft token for an app JWT; global JWT guard. | -| `gcgov/framework-service-auth-oauth-server` | `\gcgov\framework\services\authoauth` | Full OAuth server (password + third-party + MFA); global JWT guard. | -| `gcgov/framework-service-user-crud` | `\gcgov\framework\services\usercrud` | `/user` CRUD over the resolved user model. | -| `gcgov/framework-service-gcgov-cron-monitor` | `\gcgov\framework\services\cronMonitor` | Report cron start/end to a monitor service. | +`\gcgov\framework\services\cronMonitor\cronMonitor` is **not** a Framework Service: it registers no +routes and takes no part in the lifecycle. Construct it directly and configure `cronMonitor.url`. -Each plugin repo has its own `CLAUDE.md` with specifics. Only **one** authentication plugin should be active -at a time (oauth-server OR auth-ms-front). +The separately published `gcgov/framework-service-*` packages still exist for **v6** applications. The +framework declares a `conflict` against all five, so a v7 application cannot install both. --- -## 13. Authoring a new plugin (framework-service) -- `composer.json`: `"type": "framework-service"`, PSR-4 `gcgov\framework\services\\ → src/`. -- Provide `src/router.php` = `\gcgov\framework\services\\router implements \gcgov\framework\interfaces\router` - with `getRoutes()`, `authentication()`, static `_before()/_after()`. Prefix routes with - `config::getBasePath()`. -- Controllers live under `\gcgov\framework\services\\controllers\…` and implement `controller`. -- Config via a singleton (`getInstance()`) the app tweaks in `app::_before()` (see oauth-server's `oauthConfig`), - and/or `config.json` `appDictionary` (via `config::getAppDictionary()`). -- Return `false` from a plugin `authentication()` only to deny; return `true` to allow. -- Optional: contribute gf commands with `src/cli/commandProvider.php` = - `\gcgov\framework\services\\cli\commandProvider implements \gcgov\framework\cli\commandProvider` - returning symfony/console command instances (namespace the command names, e.g. `docs:regenerate`). See §16. +## 13. Adding a new Framework Service +Services live in this repository; there is no out-of-tree extension point (ADR 0005). An application +needing routes of its own puts them in `\app\router`, which already runs first in the guard chain. + +- Code in `src/services//`, namespace `\gcgov\framework\services\`. +- `src/services//router.php` implements `\gcgov\framework\interfaces\router` — just `getRoutes()` + and `authentication()`, no lifecycle hooks. Prefix routes with `config::getBasePath()`. +- Controllers under `\gcgov\framework\services\\controllers\…` implementing `controller`. +- Config: add a nullable property to `\gcgov\framework\models\config\services` and a DTO beside it in + `src/models/config/services/`. Nullable means absent = disabled. Give the router its typed config as a + constructor argument — no singletons. +- Construct it in `framework\router::__construct()` behind `if( $services-> !== null )`. +- Return `false` from `authentication()` only to deny; `true` to allow. +- Mirror the tests under `tests/Unit/Services//`. `composer ci` before pushing. --- @@ -507,7 +533,7 @@ at a time (oauth-server OR auth-ms-front). - Core file examples: `readme/{index.php,cli-index.php,app.php,router.php,renderer.php}.md`. - **Mongo (authoritative, deep)**: `readme/mongodb.md`. - **gf CLI (authoritative)**: `readme/gf.md`. -- A real, minimal consuming controller: the user-crud plugin's `src/controllers/user.php`. +- A real, minimal consuming controller: `src/services/userCrud/controllers/user.php`. --- @@ -549,9 +575,10 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea The interpreter must be the CLI binary — `php-cgi`/`php-fpm`/`php-win` are swapped for the `php`/`php.exe` beside them, else rejected; the child always gets `-dregister_argc_argv=1`, and `internal/run-route.php` assumes neither `$argv` nor `STDERR` exists until it has checked. -- **Expandability**: apps (`\app\cli\commandProvider`) and plugins - (`{ns}\cli\commandProvider`) implement `\gcgov\framework\cli\commandProvider::getCommands()`. - Discovery is fail-safe — errors never break gf (visible with `-v`). +- **Expandability**: an app implements `\app\cli\commandProvider` + (`\gcgov\framework\cli\commandProvider::getCommands()`); discovery is fail-safe — errors never + break gf (visible with `-v`). Framework Services register commands directly in + `application::__construct()`, since they are part of the framework. - When adding a command: lowercase lowerCamelCase class in `src/cli/commands/`, `#[AsCommand]` attribute, register it in `application::__construct()`, throw `cliException` for user errors, add a mirrored test in `tests/Unit/Cli/`. Keep the logic in a pure static method the test can @@ -579,4 +606,5 @@ Single-context: one root `CONTEXT.md` plus `docs/adr/`. See `docs/agents/domain. isolation boundary) are different things, and that v6's "environment variant" no longer exists. ADRs recorded so far: 0001 fail-closed configuration · 0002 immutable Release pinned by digest · -0003 secrets never decrypt in CI or on hosts · 0004 one self-hosted runner per Zone. +0003 secrets never decrypt in CI or on hosts · 0004 one self-hosted runner per Zone · +0005 Framework Services are built in and config-activated. diff --git a/CONTEXT.md b/CONTEXT.md index d2f2661..0a95e7d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,10 +14,17 @@ repository and depending on the framework as a library. _Avoid_: project, site, instance, consumer **Framework Service**: -An installable extension that contributes routes, controllers, an auth guard, and CLI commands to -an Application when the Application registers its namespace. +An optional part of the framework that contributes routes, controllers and an auth guard to an +Application when the Application enables it in the `services` section of its Unified Config. A +Framework Service ships inside the framework; it is not separately installable. _Avoid_: plugin, module, extension, package +**Provider**: +One of the ways the authentication Framework Service can establish an identity — a full OAuth server, +or the exchange of a Microsoft token the front end already holds. Exactly one is selected, because +`provider` is a single key. +_Avoid_: driver, strategy, backend, adapter + **Scaffold**: The one-time act of creating a new Application from the application template. _Avoid_: setup, bootstrap, generate @@ -130,3 +137,10 @@ recognized as history rather than reintroduced. along with the ability to read another Environment's database from a workstation. - **Scaffolding token** — a marker replaced once at Scaffold time. Replaced by Config References and by generated developer environment files. +- **Service namespace registration** — the array of namespace strings an Application returned from + `\app\app::registerFrameworkServiceNamespaces()` to enable Framework Services. Replaced by the + `services` section of the Unified Config, so that enabling a service and configuring it are one + statement. The separately published `gcgov/framework-service-*` packages remain real, but only for + v6 Applications; the framework conflicts with them. +- **Auth plugin** — either of the two separate authentication packages. There is now one + authentication Framework Service with two Providers. diff --git a/README.md b/README.md index 8837daa..0f0c7ea 100644 --- a/README.md +++ b/README.md @@ -345,18 +345,26 @@ Write user connection: `new gcgov\framework\services\pdodb\pdodb(false, $databas ## Extensions Extensions add service or app level functionality to the app that registers them. Extensions may expose new endpoints. -* **Open API Documentation** `gcgov/framework-service-documentation` - * https://github.com/gcgov/framework-service-documentation - * Add namespace `\gcgov\framework\services\documentation` to `\app\app->registerFrameworkServiceNamespaces()` -* **Microsoft Auth Token Exchange** `gcgov/framework-service-auth-ms` - * https://github.com/gcgov/framework-service-auth-ms-front - * Add namespace `\gcgov\framework\services\authmsfront` to `\app\app->registerFrameworkServiceNamespaces()` -* **Oauth Server Service** `gcgov/framework-service-auth-oauth-server` - * https://github.com/gcgov/framework-service-auth-oauth-server - * Add namespace `\gcgov\framework\services\authoauth` to `\app\app->registerFrameworkServiceNamespaces()` -* **User CRUD** `gcgov/framework-service-user-crud` - * https://github.com/gcgov/framework-service-user-crud - * Add namespace `\gcgov\framework\services\usercrud` to `\app\app->registerFrameworkServiceNamespaces()` -* **Cron Monitor** `gcgov/framework-service-gcgov-cron-monitor` - * https://github.com/gcgov/framework-service-gcgov-cron-monitor/ - * Add namespace `gcgov\framework\services\cronMonitor` to `\app\app->registerFrameworkServiceNamespaces()` +Framework Services ship inside the framework. Enable one by adding its block to the `services` +section of `config.json` — presence enables it, and the block's contents are its settings. + +```jsonc +"services": { + "auth": { "provider": "oauth" }, // or "msFront" + "userCrud": { }, + "documentation": { } +} +``` + +* **Authentication** `services.auth` — one service, two providers. + * `provider: "oauth"` — full OAuth server: password, third-party and authorization-code grants, MFA. + * `provider: "msFront"` — exchange a Microsoft token the front end already holds for an app JWT. + * Either way you get `/.well-known/jwks.json`, `/auth/fileToken`, and a JWT guard over every + `authentication: true` route. +* **User CRUD** `services.userCrud` — `/user` CRUD over the resolved user model. +* **Open API Documentation** `services.documentation` — `GET /documentation.yaml`. +* **Cron Monitor** — not a Framework Service; construct + `\gcgov\framework\services\cronMonitor\cronMonitor` directly and set `cronMonitor.url`. + +The separately published `gcgov/framework-service-*` packages remain available for **v6** applications. +The framework conflicts with them, so a v7 application cannot install both. diff --git a/docs/adr/0005-framework-services-are-built-in-and-config-activated.md b/docs/adr/0005-framework-services-are-built-in-and-config-activated.md new file mode 100644 index 0000000..dde68d4 --- /dev/null +++ b/docs/adr/0005-framework-services-are-built-in-and-config-activated.md @@ -0,0 +1,64 @@ +# Framework Services live in the framework and are activated from the Unified Config + +The five Framework Services are part of `gcgov/framework` and are switched on by a `services` section +of `config.json`. `\app\app::registerFrameworkServiceNamespaces()` is deleted. The two authentication +services become one, selected by `provider`. `cronMonitor` stops being a Framework Service at all. + +## Considered Options + +Keeping them as separate packages and moving only *activation* into config was the smaller change, and +it is what the evidence first appeared to support — the packages looked untagged and therefore +unreleased. That was a misreading of clones that had not fetched tags: all five are properly released +(`auth-oauth-server` is on v2.2.1, 39 releases between them) and consumed by semver through a committed +lock file. So folding in trades away real, working independent versioning. It is worth it for three +reasons the packaging split caused and could not fix: + +- **Configuration had two homes.** Activation and `oauthConfig::setBlockNewUsers()` were PHP in + `app::_before()`; `jwtAuth` and `appDictionary` were `config.json`. "How is auth configured here?" had + two answers. Worse, `gf` deliberately skips `_before()`, so a service was unconfigured during CLI route + enumeration — the HTTP and CLI paths genuinely disagreed. +- **Forgetting a namespace was silent.** The router swallowed the `ReflectionException` for a missing + `{ns}\router`, so a mistyped or omitted namespace produced 404s, not an error. +- **The duplication was unfixable in place.** `oauthConfig` and `msAuthConfig` were the same class twice + and the two guards the same fifty lines twice, because there was no shared package below them to hold + the common part. Merging the auth services removes it and, by making `provider` a single key, makes + two active auth providers unrepresentable rather than merely discouraged. + +An out-of-tree extension point was not preserved. A package can still ship routes and a guard — +`\app\router` composes them explicitly — and `\app\router` already contributes routes, already runs a +guard, and already runs first in the chain. Auto-discovery only added magic, and keeping it would mean +carrying the mechanism being deleted for a case that has never occurred: every Framework Service ever +written is first-party. Reopen this if a second internal service is wanted by three or more applications +and genuinely does not belong in the framework. + +Deleting `registerFrameworkServiceNamespaces()` outright, rather than deprecating it, is possible +because no v7 application is deployed. `v7.0.0-rc.1` is published, but a release candidate is where a +break like this belongs. Carrying both mechanisms would have reintroduced "which list won?" — the same +quiet ambiguity ADR 0001 removed the `default:` processor to avoid. + +## Consequences + +- **The standalone packages are not retired.** They stay published on their v1/v2 lines for v6 + applications until those migrate. Nothing is withdrawn and no v6 application breaks; they simply never + gain v7 support. A guard fix therefore lands in three places during the transition, and the + duplication is only truly gone once the last v6 application migrates. +- **The framework declares a `conflict` against all five packages.** `documentation` and `cronMonitor` + keep their namespaces, so an application with both installed would have two definitions of the same + class. This is the normal case mid-migration, not an edge case: `gf migrate` only exists in v7, so an + application must upgrade the framework *before* it can migrate. Composer refuses at resolution time, + naming the packages, and `gf migrate` removes them from the application's `composer.json`. +- **Presence enables.** A service's block being absent means off; present — even `{}` — means on, and + the block's contents are its settings. This reuses the nullable-section pattern `kmsProviders::$gcp` + already established, so `%env(...)%` and `gf env --list` work inside it with no new machinery. +- **The framework refuses to boot** when routes declare `authentication: true` and neither an auth + service nor `\app\router::providesAuthentication()` will guard them. Those routes were previously + reachable by anyone while looking protected, because the scaffolded `authentication()` returns true. +- **Four dependencies become unconditional** — `doctrine/annotations`, `robthree/twofactorauth`, + `bacon/bacon-qr-code`, `andrewsauder/microsoft-services` — matching the framework's existing posture, + where `hybridauth` and `swagger-php` are already required and unused by the core. `ext-imagick` is + *not*: `BaconQrCodeProvider` defaults to the Imagick backend, but takes a `format` argument, and the + SVG backend needs no extension to draw a square. +- **`interfaces\router` no longer carries lifecycle hooks.** It required `_before()`/`_after()` of every + router and the framework only ever called `\app\router`'s. Those move to `interfaces\appRouter`, and + the guard-skip method — previously duck-typed through `method_exists()` with no interface declaring + it — becomes `interfaces\router\skipsServiceAuthentication`. diff --git a/readme/app.php.md b/readme/app.php.md index b213656..9e7fecc 100644 --- a/readme/app.php.md +++ b/readme/app.php.md @@ -1,8 +1,16 @@ # /app/app.php -\app\app will be the first app class instantiated and the instance will last the entire lifecycle of the request. +`\app\app` is the first app class instantiated, and the instance lasts the entire lifecycle of the +request. -`registerFrameworkServiceNamespaces` can be used to register framework extensions. Extensions can provide services and/or app functionality like adding a documentation endpoint or adding JWT authentication. +It declares no methods of its own beyond the two lifecycle hooks — but it is not optional. +`\gcgov\framework\config` derives every path in the framework by reflecting on this class's file +location, so an application without it cannot resolve its own root. + +Framework Services used to be registered here, by returning their namespaces from +`registerFrameworkServiceNamespaces()`. They are now enabled in the `services` section of +`config.json`, so that switching a service on and configuring it are one statement rather than two. +See ADR 0005. ```php namespace app; @@ -13,23 +21,29 @@ final class app implements \gcgov\framework\interfaces\app { */ public static function _before() : void { } - + /** * Processed after lifecycle is complete with this instance */ public static function _after() : void { } - - /** - * Register framework extensions - */ - public function registerFrameworkServiceNamespaces(): array { - return [ - //enable framework extensions - //'gcgov\framework\services\cronMonitor', - //'\gcgov\framework\services\documentation', - ]; - } } ``` + +Enabling services is now a matter of configuration: + +```jsonc +// config.json +"services": { + "auth": { "provider": "oauth", "blockNewUsers": false, "defaultNewUserRoles": [ "Widget.Read" ] }, + "userCrud": { }, + "documentation": { } +} +``` + +Presence enables: a block that is absent means the service is off, a block that is present — even +empty — means it is on, and the block's contents are that service's settings. `blockNewUsers` and +`defaultNewUserRoles` were previously set by calling a singleton from `_before()`; note that `gf` +deliberately does not run `_before()`, so a service configured that way was unconfigured whenever the +CLI enumerated routes. diff --git a/readme/environment-variables.md b/readme/environment-variables.md index 11dd6b0..6709569 100644 --- a/readme/environment-variables.md +++ b/readme/environment-variables.md @@ -160,7 +160,7 @@ says so. ```jsonc { "app": { "title": "Permits API", "guid": "…" }, // literals: they never vary - "settings": { "useSession": false }, + "settings": { "forceMfaForPasswordUsers": false }, "type": "%env(APP_TYPE)%", "rootUrl": "%env(APP_ROOT_URL)%", diff --git a/readme/gf.md b/readme/gf.md index 4e83b6e..b5f59fd 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -277,7 +277,8 @@ class commandProvider implements \gcgov\framework\cli\commandProvider { Commands are ordinary [symfony/console](https://symfony.com/doc/current/console.html) commands. gf discovers providers in the `\app` namespace and in every namespace the app registers via -`\app\app::registerFrameworkServiceNamespaces()`. Name plugin commands with a namespace prefix +Framework Services register their commands directly in `application::__construct()`, since they are +part of the framework. Name commands with a namespace prefix (`docs:regenerate`) to avoid collisions. Discovery is fail-safe: a broken provider never takes down gf itself (run with `-v` to see discovery errors). diff --git a/readme/router.php.md b/readme/router.php.md index 8900be0..5c058df 100644 --- a/readme/router.php.md +++ b/readme/router.php.md @@ -56,7 +56,7 @@ class router implements \gcgov\framework\interfaces\router { * @throws \gcgov\framework\exceptions\routeException */ public function authentication( \gcgov\framework\models\routeHandler $routeHandler ) : bool { - //if you are utilizing the \gcgov\framework\services\authoauth service or \gcgov\framework\services\authmsfront + //if you have enabled services.auth (either provider) // it automatically adds our authentication guard // you can add additional, custom authentication checks here // your custom checks will run before the service authentication checks From f1caa946c1e1a4fc4c35aa105b8f3a8b2fdc497d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 15:39:00 +0000 Subject: [PATCH 15/30] Fix the two PHPStan findings this change introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit framework.php kept `new \app\app();` as a bare statement, which PHPStan reads as having no effect. The instance is held for the lifetime of the request as it always has been — there is simply nothing left to ask it for. The documentation controller's exclusion list is now built by appending rather than by unset(), so it has no holes and array_values() was a no-op. Two findings remain and predate this work, in files byte-identical to the base commit: dotEnvLoader's spread of a list PHPStan cannot see is non-empty, and gridfs's unsafe new static(). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KmBBiV3fQaaRdrmspZarS5 --- src/framework.php | 7 ++++--- src/services/documentation/controllers/documentation.php | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/framework.php b/src/framework.php index 4407c01..27eb529 100644 --- a/src/framework.php +++ b/src/framework.php @@ -19,9 +19,10 @@ public function runApp() : string { //appConfig \app\app::_before(); - // Constructed for its side effects only: since Framework Services moved into - // config.json, the instance itself has nothing the lifecycle needs to read. - new \app\app(); + // Held for the lifetime of the request, as it always has been. Since Framework + // Services moved into config.json there is nothing left to ask it for, but an + // application may still do work in its constructor. + $app = new \app\app(); //router \app\router::_before(); diff --git a/src/services/documentation/controllers/documentation.php b/src/services/documentation/controllers/documentation.php index f42e18c..2098fcc 100644 --- a/src/services/documentation/controllers/documentation.php +++ b/src/services/documentation/controllers/documentation.php @@ -87,7 +87,7 @@ private function getExcludeDirectoriesFiles(): array { $exclusions[] = $frameworkSrc . '/models/authUser.php'; } - return array_values( $exclusions ); + return $exclusions; } From b88f0d879e30ef0e8e4e21bcbd53734d165cee99 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:29:43 +0000 Subject: [PATCH 16/30] Record ADRs 0006 and 0007 for certificate issuance and deployment-secret keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two decisions taken while making gcgov/deploy operational, both of which trade away something the design originally assumed it would have. 0006 — Let's Encrypt DNS-01 on one registered domain every Zone shares. All three Zones serve names under garrettcountymd.gov, and a Cloudflare token scopes to a registered domain, so per-Zone token scoping is not achievable and every Zone holds a credential with power over every other Zone's names. Accepted for the bridge pilot, where only one token exists, on the condition that _acme-challenge is delegated per Zone before a second Zone is provisioned — enforced by an unresolved placeholder in ZONE_ACME_DELEGATION rather than by anyone remembering. An internal CA and a wildcard certificate were both considered; the wildcard is rejected outright, since a wildcard on the internal host is a certificate valid for www. The ADR also records that DNS-01 publishes internal hostnames to Certificate Transparency permanently, which is why paloalto-tools was renamed to netops-tools before first issuance, and that ZONE_ACME_EMAIL is a registration contact rather than a monitoring backstop now that Let's Encrypt no longer sends expiry mail. 0007 — deployment secrets are encrypted with KMS keys in a GCP project of their own. The MongoDB queryable-encryption credential lives on an application host; ADR 0003 says the sops keys must never be reachable from one. Sharing a project puts both in the same IAM surface, which per-key bindings contain today and a project-level binding granted later would not. Access goes through a Google group per Zone so that offboarding is one membership removal rather than three IAM edits that can be half-finished. CONTEXT.md gains Ops Project, Delegation Zone, Break-glass Key and Escrow Custodian. Documentation only — no PHP changes. composer ci was not run: composer install cannot authenticate to github.com from this environment, and ext-mongodb is absent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N3hHTQA6apQYF5mntbU8tf --- CLAUDE.md | 3 +- CONTEXT.md | 22 ++++++ ...pt-dns-01-on-a-shared-registered-domain.md | 68 +++++++++++++++++++ ...ated-gcp-project-for-deployment-secrets.md | 41 +++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0006-lets-encrypt-dns-01-on-a-shared-registered-domain.md create mode 100644 docs/adr/0007-dedicated-gcp-project-for-deployment-secrets.md diff --git a/CLAUDE.md b/CLAUDE.md index add3ec5..d594b38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -607,4 +607,5 @@ isolation boundary) are different things, and that v6's "environment variant" no ADRs recorded so far: 0001 fail-closed configuration · 0002 immutable Release pinned by digest · 0003 secrets never decrypt in CI or on hosts · 0004 one self-hosted runner per Zone · -0005 Framework Services are built in and config-activated. +0005 Framework Services are built in and config-activated · 0006 Let's Encrypt DNS-01 on one +registered domain every Zone shares · 0007 a dedicated GCP project for deployment secrets. diff --git a/CONTEXT.md b/CONTEXT.md index 0a95e7d..e1bb3e5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -127,6 +127,28 @@ by tag or branch. Deploying and rolling back are both the act of pointing a host Release. _Avoid_: version, build, deployment +**Ops Project**: +The cloud project holding the keys that encrypt the Ops Repo, one per Zone, and nothing else. Kept +apart from the project holding an Application's data-encryption keys, because those are reachable +from a host and these must never be. +_Avoid_: KMS project, secrets project, security project + +**Delegation Zone**: +A DNS zone holding only the ACME challenge records for one Zone, so that Zone's DNS credential can be +scoped to it rather than to a domain that serves traffic. +_Avoid_: ACME zone, challenge domain, validation domain + +**Break-glass Key**: +The offline key every file in the Ops Repo is encrypted to in addition to its Zone's key, so that a +total loss of cloud access is still recoverable. Retrieving it obliges replacing it. +_Avoid_: recovery key, backup key, master key + +**Escrow Custodian**: +One of the two named people who may retrieve the Break-glass Key from physical escrow, drawn from +different reporting lines so that retrieval crosses a departmental boundary and is witnessed by +someone with no stake in it. +_Avoid_: key holder, key owner, keeper + ### Retired language These terms named real things in v6 and no longer name anything. They are listed so that they are diff --git a/docs/adr/0006-lets-encrypt-dns-01-on-a-shared-registered-domain.md b/docs/adr/0006-lets-encrypt-dns-01-on-a-shared-registered-domain.md new file mode 100644 index 0000000..7bde10f --- /dev/null +++ b/docs/adr/0006-lets-encrypt-dns-01-on-a-shared-registered-domain.md @@ -0,0 +1,68 @@ +# Certificates come from Let's Encrypt over DNS-01, on one registered domain every Zone shares + +Every Zone's Traefik obtains certificates from Let's Encrypt using the DNS-01 challenge against +Cloudflare. All three Zones serve names under `garrettcountymd.gov`, so all three hold a Cloudflare +token with `Zone:DNS:Edit` on that one domain. Per-Zone token scoping — which the Ops Repo's own +follow-up list asked for — is not achievable in this shape. It is accepted for the pilot rather than +pretended, on a condition that is enforced rather than remembered. + +## Considered Options + +DNS-01 is not itself a choice. The internal-only Zone has no inbound path from the internet and so +cannot complete an HTTP-01 challenge; using DNS-01 everywhere means one ACME mechanism to understand +rather than two that drift apart. + +What was genuinely open is how to stop a credential in one Zone from being a credential over another +Zone's names. A Cloudflare API token scopes to a *registered domain* — there is no per-subdomain +record filtering short of Enterprise subdomain zones. Three ways out were weighed: + +- **An internal CA for the internal Zone.** Certificates for `internal-apps`, `swagger` and + `netops-tools` would come from AD CS and be provisioned like any other Secret, so the internal host + would hold no Cloudflare token at all and its hostnames would never be published. Rejected because + it reintroduces exactly the second certificate mechanism that DNS-01-everywhere exists to avoid, + and there is no internal PKI stood up to carry it. Worth reopening if one is built, since it is the + only option that removes the internal Zone's token entirely. +- **A wildcard certificate per Zone.** One `*.garrettcountymd.gov` certificate would keep individual + hostnames out of Certificate Transparency. Rejected outright — and it is the option that looks most + attractive while being the worst available. A wildcard on the internal host is a certificate valid + for `www.garrettcountymd.gov`, so a compromise of the least-exposed host yields a credential for + the most-exposed name. +- **`_acme-challenge` delegation.** Each Zone's challenge records are CNAMEd into a DNS zone of its + own, so its token can be scoped to that zone and to nothing that serves traffic. This is the + correct end state. It is deferred, not dismissed. + +Accepting the shared scope is defensible only because the pilot is a single Zone. While `bridge` is +the only Zone provisioned there is exactly one token and the cross-Zone capability does not exist. It +comes into being the moment a second Zone is provisioned — which is why the condition is a mechanism +and not a sentence. `internal` and `isolated` carry an unresolved placeholder in +`ZONE_ACME_DELEGATION`, and `bin/provision` already refuses to send any file containing a placeholder +to a host. Whoever provisions Zone 2, months from now and with none of this context, has to resolve +it deliberately to get past it. + +## Consequences + +- **A DNS-edit credential exists in every Zone, and they are equal in power.** Concretely: a + compromise of `c-web-isolated`, the most exposed of the three, yields a token that can repoint + `payments-api` and `dmr` over in `bridge` and issue valid certificates for them. Three separate + tokens are still issued, so one can be revoked without disturbing the others and Cloudflare's audit + log tells them apart — but that limits what a revocation costs, it does not prevent the capability. +- **Internal hostnames become public and permanent.** `internal-apps`, `swagger` and `netops-tools` + do not resolve in public DNS at all today. DNS-01 publishes every name it issues for to Certificate + Transparency, where it stays searchable indefinitely. Accepted: hostnames are not secrets, and + obscurity that is depended on but not maintained is worse than obscurity that has been written off. + It is why `paloalto-tools` was renamed to `netops-tools` first — a hostname that names a vendor + tells a reader which CVE feed to watch, and the rename is free before first issuance and impossible + after it. +- **All three Zones share one Let's Encrypt rate limit,** which is counted per registered domain. + Delegation would not change this, because the limit follows the certificate's names rather than + where the challenge was answered. Losing one Zone's `acme` volume therefore competes with every + other Zone's renewals, which is what turns backing that volume up from a nicety into a requirement. +- **`garrettcounty.org` is retired to a Cloudflare redirect** rather than served from an origin, so no + second registered domain enters any token's scope. Every domain that reaches an origin is a domain + whose certificate and token scope somebody maintains indefinitely. +- **Adding an Application now has a DNS step,** and gains a second one once delegation lands. The + `_acme-challenge` CNAME is easy to forget and its absence surfaces only as a certificate that never + issues, so it belongs in the runbook rather than in anyone's memory. +- **`ZONE_ACME_EMAIL` is a registration contact, not a monitoring backstop.** Let's Encrypt stopped + sending expiry notification emails in June 2025, so a renewal that silently stops working surfaces + as an outage unless something else watches for it. diff --git a/docs/adr/0007-dedicated-gcp-project-for-deployment-secrets.md b/docs/adr/0007-dedicated-gcp-project-for-deployment-secrets.md new file mode 100644 index 0000000..b73d8b1 --- /dev/null +++ b/docs/adr/0007-dedicated-gcp-project-for-deployment-secrets.md @@ -0,0 +1,41 @@ +# Deployment Secrets are encrypted with KMS keys in a GCP project of their own + +The `sops` keyring that encrypts the Ops Repo — one key per Zone — lives in a GCP project created for +operations and holding nothing else. It is deliberately not the project holding the Cloud KMS master +key for MongoDB queryable encryption. + +## Considered Options + +Sharing the existing project is the smaller change and the obvious one. The key material is the same +kind, the administration is the same, and per-key IAM would still stop an operator's decrypt on +`internal` from reaching `isolated`. The argument against it is not about the keys at all. It is about +who holds credentials inside the project. + +The queryable-encryption key is used by an Application at runtime, so its service-account credential +file sits **on an Application host**. The `sops` keys are used by operators at a workstation and, per +ADR 0003, must never be reachable from a host or from CI. One project for both means a compromised +Application host holds a credential inside the same project as the keys that decrypt every Zone's +Secrets. Per-key bindings contain that today; a project-level binding, or `roles/cloudkms.admin` +granted in eighteen months to solve something unrelated, does not. The separation costs one more +project to administer and removes a class of mistake that stays invisible until it matters. + +Access is granted through a Google group per Zone — `sops-internal@`, `sops-bridge@`, +`sops-isolated@` — each holding `roles/cloudkms.cryptoKeyEncrypterDecrypter` on its own key, rather +than through individual principals. Offboarding is then one membership removal instead of three IAM +edits that can be half-finished, and Cloud Audit Logs still name the individual who decrypted, so +nothing is lost from the audit trail. All three groups start with the same members: the per-key split +is what preserves the ability to narrow access later, which costs nothing now and cannot be +retrofitted once Secrets are encrypted to a single shared key. + +## Consequences + +- **Nothing automated holds `cryptoKeyEncrypterDecrypter`** — no service account, no CI identity, no + host. ADR 0003 already required this; the separate project makes it checkable by reading one + project's IAM rather than reasoning about which bindings in a shared project are for what. +- **Two GCP projects to administer,** with the usual risk that the less-used one drifts: its billing, + its audit log retention and its own IAM go unwatched between incidents. `offboard-an-operator.md` + is what keeps it honest, because it is the one routine that has to touch both. +- **Group membership is the record of who could decrypt what,** so it has to be read before it is + revoked. Removing someone from `sops-bridge@` also removes the evidence of which Zones' Secrets now + need rotating — and a rotation believed complete is worse than one never started, because it stops + anyone looking again. From 7ba59101015f45e8dd2677fc898e1afb832eb194 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:12:37 +0000 Subject: [PATCH 17/30] Rewrite ADR 0007: Azure Key Vault per Zone, not a GCP project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewritten in place rather than superseded. ADR 0007 exists only on this unmerged branch and has never been in the mainline, so a superseding ADR would document the reversal of a decision that never took effect anywhere. The reversal came out of building it. The original ADR argued for a dedicated GCP project so the deploy keys would not share an IAM surface with the MongoDB queryable-encryption credential, which lives on an application host. That argument still holds and is kept. What did not survive contact was the access model: there is no GCP organization and no group layer, operators sign in with individual Google accounts, so access would have been per-person IAM bindings and offboarding one edit per key per person — three chances to half-finish a revocation, which is the failure the ADR was written to prevent. Entra already has the groups, and more to the point already has the joiner/mover/leaver process, so revoking decrypt becomes a consequence of offboarding rather than a separate thing to remember. SOPS supports Key Vault natively and bin/provision only shells out to sops. With Mongo staying on GCP, the separation the ADR wanted is now across two clouds rather than two projects — a stronger form of the same property, arrived at sideways. Recorded consequences worth having in writing: the break-glass key becomes more load-bearing because the vaults are Entra and a tenant compromise takes the primary path outright; Azure key URLs are version-pinned so rotating a key means sops updatekeys across every file; Key Vault audit logging is off by default, which is what the offboarding runbook's claim about reading decrypt records depends on; and access is standing rather than just-in-time, since PIM would need Entra ID P2 and the county holds P1. CONTEXT.md replaces Ops Project, a GCP-shaped term, with Zone Key Vault. Documentation only. composer ci was not run: composer install cannot authenticate to github.com from this environment, and ext-mongodb is absent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N3hHTQA6apQYF5mntbU8tf --- CLAUDE.md | 2 +- CONTEXT.md | 10 +-- ...y-vault-per-zone-for-deployment-secrets.md | 72 +++++++++++++++++++ ...ated-gcp-project-for-deployment-secrets.md | 41 ----------- 4 files changed, 78 insertions(+), 47 deletions(-) create mode 100644 docs/adr/0007-azure-key-vault-per-zone-for-deployment-secrets.md delete mode 100644 docs/adr/0007-dedicated-gcp-project-for-deployment-secrets.md diff --git a/CLAUDE.md b/CLAUDE.md index d594b38..e520048 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -608,4 +608,4 @@ isolation boundary) are different things, and that v6's "environment variant" no ADRs recorded so far: 0001 fail-closed configuration · 0002 immutable Release pinned by digest · 0003 secrets never decrypt in CI or on hosts · 0004 one self-hosted runner per Zone · 0005 Framework Services are built in and config-activated · 0006 Let's Encrypt DNS-01 on one -registered domain every Zone shares · 0007 a dedicated GCP project for deployment secrets. +registered domain every Zone shares · 0007 Azure Key Vault per Zone for deployment secrets. diff --git a/CONTEXT.md b/CONTEXT.md index e1bb3e5..09660e9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -127,11 +127,11 @@ by tag or branch. Deploying and rolling back are both the act of pointing a host Release. _Avoid_: version, build, deployment -**Ops Project**: -The cloud project holding the keys that encrypt the Ops Repo, one per Zone, and nothing else. Kept -apart from the project holding an Application's data-encryption keys, because those are reachable -from a host and these must never be. -_Avoid_: KMS project, secrets project, security project +**Zone Key Vault**: +The vault holding the single key that encrypts one Zone's Secrets, and nothing else. Three exist, +one per Zone, deliberately apart from the cloud project holding an Application's data-encryption +keys — those are reachable from a host and these must never be. +_Avoid_: KMS, key store, secrets vault, ops project **Delegation Zone**: A DNS zone holding only the ACME challenge records for one Zone, so that Zone's DNS credential can be diff --git a/docs/adr/0007-azure-key-vault-per-zone-for-deployment-secrets.md b/docs/adr/0007-azure-key-vault-per-zone-for-deployment-secrets.md new file mode 100644 index 0000000..944aa34 --- /dev/null +++ b/docs/adr/0007-azure-key-vault-per-zone-for-deployment-secrets.md @@ -0,0 +1,72 @@ +# Deployment Secrets are encrypted with Azure Key Vault, one vault per Zone + +The Ops Repo's Secrets are encrypted by SOPS to a key held in Azure Key Vault — one vault and one +key per Zone, with decrypt granted to an Entra group per Zone. MongoDB queryable encryption keeps its +own Cloud KMS key in GCP and is untouched. + +## Considered Options + +This began as a dedicated GCP project, for a reason that still holds. The queryable-encryption key is +used by an Application at runtime, so its service-account credential file sits **on an Application +host**, while the `sops` keys are used by operators at a workstation and, per ADR 0003, must never be +reachable from a host or from CI. One project for both means a compromised Application host holds a +credential inside the same project as the keys that decrypt every Zone's Secrets — contained today by +per-key bindings, and not contained at all by a project-level binding, or by a `roles/cloudkms.admin` +granted in eighteen months to solve something unrelated. + +Building it is what changed the answer. There is no GCP organization and no group layer: operators +sign in with individual Google accounts. Access would therefore be individual IAM bindings, and +offboarding one edit per key per person — three chances to half-finish a revocation, which is the +precise failure this decision was written to prevent. A group layer could be built, since Cloud +Identity's free tier supplies Google Groups without Workspace, but it means verifying a domain that +Microsoft 365 already holds in order to replicate a directory that is already running. + +Azure Key Vault has the group layer, because the county already operates Entra. More importantly it +has the *process*: a joiner/mover/leaver routine already exists, so revoking decrypt stops being a +separate checklist item somebody has to remember and becomes a consequence of offboarding that +happens anyway. SOPS supports Key Vault natively and `bin/provision` only shells out to `sops`, so +nothing in the provisioning path changes. + +The separation argument survives the move and gets stronger. With Mongo staying on GCP, the deploy +keys and the host-resident credential are no longer merely in different projects but in different +clouds — the strongest available form of what the original decision was reaching for, arrived at +sideways rather than by design. + +Three vaults rather than one vault holding three keys. Key-scoped RBAC is possible, but vault-scoped +is easier to read in a role listing and to reason about mid-incident, and it gives each Zone its own +firewall and network rules if those are ever wanted. Premium SKU rather than Managed HSM: both give +HSM-backed keys, but Managed HSM is a dedicated pool billed hourly and would cost more per month than +every host in this design combined. `Key Vault Crypto User` at vault scope rather than `Crypto +Officer`, which can also create and destroy keys — no operator needs that to do their job, and the +gap between the two roles is the gap between losing one Secret and losing every Secret. + +Doing this before the pilot is most of why it is cheap. Nothing has been encrypted yet, so there is +no re-encryption and no rotation of Secrets exposed under superseded keys. That window closes at the +first `sops --encrypt`. + +## Consequences + +- **The Break-glass Key becomes more load-bearing, not less.** The vaults are Entra, so a + tenant-wide compromise takes the primary decryption path outright. The offline age key is the only + part of this design that does not depend on Entra, which is why its escrow must not sit anywhere + Entra can sign you in, and why the break-glass runbook now says so outright rather than leaving it + to be inferred. +- **Nothing automated holds a crypto role** — no service principal, no CI identity, no host. ADR + 0003 already required it; vault-scoped RBAC makes it checkable by listing role assignments on three + resources instead of reasoning about which bindings in a shared project are for what. That listing + has to include *inherited* assignments: a subscription-level `Owner` reaches all three vaults at + once and silently defeats the per-Zone split this ADR exists to create. +- **Key URLs in `.sops.yaml` are version-pinned.** Unlike a GCP resource id, an Azure key URL names + one specific version, so rotating a key means editing `.sops.yaml` and running `sops updatekeys` + across every file rather than a transparent switch behind a stable identifier. Rotating a *Secret* + is unaffected and stays cheap, which is the operation that actually happens often. +- **Two clouds, but not one more than before.** GCP was already there for Mongo. What changes is + which cloud holds what, not how many consoles exist — though the less-used one still drifts, and + its billing and audit retention go unwatched between incidents. +- **Key Vault audit logging is off by default,** exactly as GCP's Data Access logging was. Per-vault + diagnostic settings into a Log Analytics workspace are what make the offboarding runbook's claim + about reading what someone decrypted true rather than aspirational. +- **Access is standing, not just-in-time.** Entra ID P2 would allow PIM to make the crypto role + eligible rather than active, so decrypt would be time-boxed and approval-gated. The county holds + P1, so that is an upgrade path rather than a property of the design today — worth revisiting at the + next licensing review, because it is the one control neither cloud's plain RBAC offers. diff --git a/docs/adr/0007-dedicated-gcp-project-for-deployment-secrets.md b/docs/adr/0007-dedicated-gcp-project-for-deployment-secrets.md deleted file mode 100644 index b73d8b1..0000000 --- a/docs/adr/0007-dedicated-gcp-project-for-deployment-secrets.md +++ /dev/null @@ -1,41 +0,0 @@ -# Deployment Secrets are encrypted with KMS keys in a GCP project of their own - -The `sops` keyring that encrypts the Ops Repo — one key per Zone — lives in a GCP project created for -operations and holding nothing else. It is deliberately not the project holding the Cloud KMS master -key for MongoDB queryable encryption. - -## Considered Options - -Sharing the existing project is the smaller change and the obvious one. The key material is the same -kind, the administration is the same, and per-key IAM would still stop an operator's decrypt on -`internal` from reaching `isolated`. The argument against it is not about the keys at all. It is about -who holds credentials inside the project. - -The queryable-encryption key is used by an Application at runtime, so its service-account credential -file sits **on an Application host**. The `sops` keys are used by operators at a workstation and, per -ADR 0003, must never be reachable from a host or from CI. One project for both means a compromised -Application host holds a credential inside the same project as the keys that decrypt every Zone's -Secrets. Per-key bindings contain that today; a project-level binding, or `roles/cloudkms.admin` -granted in eighteen months to solve something unrelated, does not. The separation costs one more -project to administer and removes a class of mistake that stays invisible until it matters. - -Access is granted through a Google group per Zone — `sops-internal@`, `sops-bridge@`, -`sops-isolated@` — each holding `roles/cloudkms.cryptoKeyEncrypterDecrypter` on its own key, rather -than through individual principals. Offboarding is then one membership removal instead of three IAM -edits that can be half-finished, and Cloud Audit Logs still name the individual who decrypted, so -nothing is lost from the audit trail. All three groups start with the same members: the per-key split -is what preserves the ability to narrow access later, which costs nothing now and cannot be -retrofitted once Secrets are encrypted to a single shared key. - -## Consequences - -- **Nothing automated holds `cryptoKeyEncrypterDecrypter`** — no service account, no CI identity, no - host. ADR 0003 already required this; the separate project makes it checkable by reading one - project's IAM rather than reasoning about which bindings in a shared project are for what. -- **Two GCP projects to administer,** with the usual risk that the less-used one drifts: its billing, - its audit log retention and its own IAM go unwatched between incidents. `offboard-an-operator.md` - is what keeps it honest, because it is the one routine that has to touch both. -- **Group membership is the record of who could decrypt what,** so it has to be read before it is - revoked. Removing someone from `sops-bridge@` also removes the evidence of which Zones' Secrets now - need rotating — and a rotation believed complete is worse than one never started, because it stops - anyone looking again. From 7c44e5b9f450d819da583bc6b892334753cab505 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 00:31:48 +0000 Subject: [PATCH 18/30] Amend ADR 0003: Azure Key Vault, and /etc rather than /run for host secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two details in the summary of ADR 0003 no longer describe what is implemented, while the decision itself — operator-workstation decryption, no key on a host or in CI — is honoured exactly. The wrapping key moved to Azure Key Vault, one vault per Zone, in ADR 0007. And the plaintext lands in /etc/gcgov/secrets on the host, not /run/secrets: /run is a tmpfs, so anything written there is gone after a reboot and every container fails to start on the way back up. /run/secrets is what the container sees — the bind-mount target, not the host path. Recorded as an amendment note rather than an edit to the decision, the way ADR 0007 already amends this one, so the record stays readable as history. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018NP3QWawCQVvvzFLQMR5iZ --- .../0003-secrets-never-decrypt-in-ci-or-on-hosts.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md b/docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md index f56ab6b..8e8fb88 100644 --- a/docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md +++ b/docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md @@ -1,5 +1,16 @@ # Production secrets never decrypt in CI, and hosts hold no decryption key +> **Amended twice since acceptance.** The decision below — operator-workstation decryption, +> no key on a host or in CI — stands unchanged. Two details in its summary do not: +> +> - **The wrapping key is Azure Key Vault, one vault and one key per Zone**, not GCP KMS. +> Superseded by ADR 0007. (MongoDB queryable encryption keeps its own Cloud KMS key in +> GCP; that is a different key and is untouched.) +> - **The plaintext lands in `/etc/gcgov/secrets//` on the host**, not +> `/run/secrets`. `/run` is a tmpfs, so anything written there is gone after a reboot and +> every container fails to start on the way back up. `/run/secrets/` is what +> the *container* sees — the bind-mount target, not the host path. + Secrets live SOPS-encrypted in the `gcgov/deploy` Ops Repo, encrypted to a **GCP KMS key per Zone** plus one offline age key held as break-glass. An operator decrypts on their own workstation and writes the plaintext to the host as files under `/run/secrets` — a **Provisioning** step deliberately From 57ea4f8543c01acdfa0efb7bd08d030eef2ac415 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 00:51:48 +0000 Subject: [PATCH 19/30] Fix the v7 review findings: routing at domain root, lifecycle refusals, credential handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A max-effort review of this branch surfaced three recurring themes. This addresses them, plus the individual defects underneath. 1. The service fold-in was unfinished. Code moved into src/services/ kept behaviour the framework now forbids and skipped normalization it now provides. - Routes at the domain root. getBasePath() returns '/' there, which is right for the token audience and wrong as a route prefix: the auth, userCrud and documentation routers registered //user and //auth/authorize, which FastRoute stores and matches as literal strings, so every Framework Service endpoint 404'd while /health worked because it alone happened to rtrim. Adds config::getRoutePrefix() ('' at the root) and points all four routers at it. getBaseUrl() had the same trailing-slash defect in the advertised OAuth callback. - Removed a debug error_log() that wrote the user's stored password hash on every MFA-secret verification, and routed the auth service's remaining error_log calls through services\log so they honour logging.destination. - Replaced three exit; calls in the oauth controller with a 302 controllerResponse, so an OAuth sign-in no longer skips the controller, renderer and app _after hooks. 2. The new fail-closed checks threw a class nobody caught. configException extends \LogicException while runApp() caught only routeException, so the checks written to refuse loudly instead produced a bare PHP fatal with the rest of the lifecycle skipped. Same escape for FastRoute's BadRouteException and a \TypeError from a mistyped \app\router. runApp() now catches these, logs the detail in full and renders a generic 500 — the messages carry route patterns, config paths and environment-variable names, so they stay out of the response. An application defining a route the framework already registers now wins it rather than taking every route down with it. 3. Docs and code contradicted each other in both directions. The canonical \app\router example still said interfaces\router, which no longer boots; README advertised three deleted gf commands and a config section that never existed; CLAUDE.md's layout, base-path guidance and never-exit rule were out of date. Also fixed: - userCrud save() ignored its {_id} route parameter, so a body could retarget the write to any account, roles included. The URL now binds. - createAccessTokenResponse() built controllerException with message and code swapped, raising a TypeError instead of the intended 500; the outer catch that caused it is gone, along with two inlined copies of the helper that had drifted. - Roles are narrowed to strings and compared strictly: a non-string truthy element satisfied a loose in_array() against every required role. - cert:generate-auth ignored jwtAuth.keyPath, so it wrote keys where jwtAuth would not look and the error message named itself as the remedy. Both sides now share one resolver. - gf init round-tripped config.json through json_decode(assoc), rewriting {} as [] and silently disabling the services the template declares. - gf env --init is now genuinely additive, as its help text always claimed; --force still rewrites but says what it discards. --list now loads .env, and the "is it set" check is shared with the resolver rather than reimplemented. - gf migrate wrote .env values unquoted, corrupting any secret containing $, # or a space. Verified round-trip against symfony/dotenv. - Readiness probes now fail fast instead of parking a worker for the driver's 30s default, and report status without echoing driver messages that name internal hosts and ports on an unauthenticated endpoint. - mkdir(777) decimal created an unwritable directory; the write after it was unchecked. The bool env processor now fails closed like int. cronMonitor honours the documented empty-url off switch. The OAuth state is encoded on the way out and no longer double-decoded on the way in. Tests: adds coverage for the health service and the auth guard (both previously untested), the domain-root routing regression, role narrowing, the lifecycle catch, route override, gf init JSON preservation, .env quoting and .env preservation. Adds a shared config-seeding trait so tests stop leaking global config, and drops a CoversClass for a class this branch deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R2sLagem1ERvXQGwcoBgA1 --- CLAUDE.md | 31 ++-- README.md | 17 ++- readme/router.php.md | 29 +++- src/cli/commands/certGenerateAuthCommand.php | 8 +- src/cli/commands/envCommand.php | 89 +++++++++-- src/cli/commands/initCommand.php | 59 ++++++-- src/cli/commands/migrateCommand.php | 25 +++- src/cli/internal/run-route.php | 2 +- src/config.php | 15 +- src/framework.php | 18 +++ src/models/authUser.php | 27 +++- src/models/unifiedConfig.php | 36 ++++- src/router.php | 59 +++++++- src/services/auth/guard.php | 5 +- .../auth/providers/oauth/controllers/auth.php | 116 +++++++------- .../providers/oauth/services/multifactor.php | 7 +- src/services/auth/router.php | 2 +- src/services/cronMonitor/cronMonitor.php | 23 ++- src/services/documentation/router.php | 2 +- src/services/environment/configLoader.php | 6 + src/services/environment/dotEnvLoader.php | 8 +- src/services/environment/envVarResolver.php | 26 +++- src/services/health/controllers/health.php | 48 +++++- src/services/health/router.php | 17 +-- src/services/userCrud/controllers/user.php | 26 ++++ src/services/userCrud/router.php | 2 +- tests/Stubs/FakeUserModel.php | 5 + tests/Support/seedsFrameworkConfig.php | 68 +++++++++ tests/Unit/Cli/CommandsTest.php | 1 - tests/Unit/Cli/EnvCommandTest.php | 43 ++++++ tests/Unit/Cli/InitCommandTest.php | 102 +++++++++++++ tests/Unit/Cli/MigrateCommandTest.php | 33 ++++ tests/Unit/LifecycleExceptionTest.php | 100 +++++++++++++ tests/Unit/Models/AuthUserRolesTest.php | 91 +++++++++++ tests/Unit/RouteOverrideTest.php | 82 ++++++++++ tests/Unit/RoutePrefixTest.php | 141 ++++++++++++++++++ tests/Unit/Services/Auth/GuardTest.php | 104 +++++++++++++ .../Services/Documentation/RouterTest.php | 7 +- .../Services/Health/HealthControllerTest.php | 109 ++++++++++++++ tests/Unit/Services/Health/RouterTest.php | 80 ++++++++++ .../Controllers/UserControllerTest.php | 49 ++++++ tests/Unit/Services/UserCrud/RouterTest.php | 7 +- tests/bootstrap.php | 3 + 43 files changed, 1562 insertions(+), 166 deletions(-) create mode 100644 tests/Support/seedsFrameworkConfig.php create mode 100644 tests/Unit/Cli/InitCommandTest.php create mode 100644 tests/Unit/LifecycleExceptionTest.php create mode 100644 tests/Unit/Models/AuthUserRolesTest.php create mode 100644 tests/Unit/RouteOverrideTest.php create mode 100644 tests/Unit/RoutePrefixTest.php create mode 100644 tests/Unit/Services/Auth/GuardTest.php create mode 100644 tests/Unit/Services/Health/HealthControllerTest.php create mode 100644 tests/Unit/Services/Health/RouterTest.php diff --git a/CLAUDE.md b/CLAUDE.md index add3ec5..720e024 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,10 @@ src/ ├── cli/ # the gf command line tool (§16): application, appContext, commands/* ├── interfaces/ # contracts an app must implement (app, router, render, controller, auth\user, ...) ├── models/ # route, routeHandler, controller*Response, authUser, config/* DTOs, customConstraints -├── services/ # log, guid, http, formatting, request, jwtAuth, pdodb, microsoft(deprecated), mongodb/* +├── services/ # framework services: log, guid, http, formatting, request, jwtAuth, pdodb, +│ # chrome, cronMonitor, environment, microsoft(deprecated), mongodb/* +│ # Framework Services (§12): auth/, userCrud/, documentation/ +│ # always-on: health/ ├── exceptions/ # configException, routeException, controllerException, modelException, serviceException, ... └── traits/ # userTrait readme/ # long-form docs (mongodb.md is the authoritative Mongo reference; gf.md for the CLI) @@ -120,7 +123,8 @@ www/index.php Rules a controller method must obey: - **Always return a `controllerResponse` subtype (§6). Never `die()`/`exit`** — it skips the rest of the - lifecycle. (The documentation service's `yaml()` is the one deliberate exception.) + lifecycle. (The documentation service's `yaml()` is the one deliberate exception.) A redirect is a + response too: return a `controllerDataResponse` with a `Location` header and status 302. - Route method parameters are bound positionally from the URL pattern placeholders. --- @@ -148,8 +152,10 @@ $routes[] = new route('GET', 'structure/{_id}', '\app\controllers\structure', $routes[] = new route('POST', 'structure/{_id}', '\app\controllers\structure', 'save', true, [constants::ROLE_STRUCTURE_READ, constants::ROLE_STRUCTURE_WRITE]); $routes[] = new route('CLI', '/cli/cleanup', '\app\controllers\cli\import','cleanup',false); ``` -If the app is not served at the domain root, prepend a base path (commonly -`config::getBasePath()`, which is what service routers use). +If the app is not served at the domain root, prepend a base path: use +`config::getRoutePrefix()`, which is what the framework's own routers use. (`getBasePath()` +returns `/` at the domain root — right for the token audience, wrong for a route prefix, +where it produces `//user`.) ### Authentication guard flow (`framework\router::route()`) For a matched route with `authentication === true`: @@ -194,6 +200,10 @@ matching branch in `framework\renderer::render()`. decides the JSON error shape (template default: `{error, message, status}`). - Exception → status: `routeException`/`controllerException`/`modelException` carry a code used as the HTTP status; uncaught `\Throwable` → 500. +- Anything thrown while routing that is **not** a `routeException` (`configException` from the fail-closed + checks, FastRoute's `BadRouteException`, a `\TypeError` from a mistyped `\app\router`) is caught by + `runApp()`, logged in full, and rendered as a generic 500 — the detail never reaches the client, because + those messages carry route patterns, config paths and environment-variable names. --- @@ -334,7 +344,7 @@ returning group keys, and tag constraints with `groups: [...]`. exposed **directly on `config`** — there are no separate appConfig/environmentConfig objects (v7; `getAppConfig()`/`getEnvironmentConfig()` remain as deprecated pass-throughs returning the unified object): `config::getApp()` (title/guid), `getEmail()`, `getSettings()`, `getType()`, `isLocal()`, -`getRootUrl()`, `getBaseUrl()`, `getBasePath()`, `getLogging()`, `getMongoDatabases()`, +`getRootUrl()`, `getBaseUrl()`, `getBasePath()`, `getRoutePrefix()`, `getLogging()`, `getMongoDatabases()`, `getSqlDatabases()`, `getDefaultSqlDatabase()`, `getSqlDatabaseByName($name)`, `getMicrosoft()`, `getJwtAuth()`, `getTokenIssuedBy()`, `getTokenPermittedFor()`, `getJwtKeyPath()`, `getPayjunction()`, `getAppDictionary()`, `getServices()`, `getCronMonitor()`. @@ -504,7 +514,7 @@ needing routes of its own puts them in `\app\router`, which already runs first i - Code in `src/services//`, namespace `\gcgov\framework\services\`. - `src/services//router.php` implements `\gcgov\framework\interfaces\router` — just `getRoutes()` - and `authentication()`, no lifecycle hooks. Prefix routes with `config::getBasePath()`. + and `authentication()`, no lifecycle hooks. Prefix routes with `config::getRoutePrefix()`. - Controllers under `\gcgov\framework\services\\controllers\…` implementing `controller`. - Config: add a nullable property to `\gcgov\framework\models\config\services` and a DTO beside it in `src/models/config/services/`. Nullable means absent = disabled. Give the router its typed config as a @@ -549,13 +559,14 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea `db:restore` (it required production credentials on every workstation), and `setup` (replaced by the non-interactive `init`, since bootstrap belongs in a scaffolding script or a devcontainer). - **`gf env`** validates that config.json resolves; `--list` prints every referenced variable and - whether it is currently set; `--init` writes the `.env` skeleton (refuses to overwrite without - `--force`). **`gf init --title="…"`** bootstraps a scaffolded app: title, guid, `.env`, JWT keys, + whether it is currently set; `--init` writes the `.env` skeleton, and on an existing file + **appends only the references it does not already declare** — a .env carries values and variables + config.json knows nothing about. `--force` rewrites from config.json alone, discarding both. **`gf init --title="…"`** bootstraps a scaffolded app: title, guid, `.env`, JWT keys, chrome. **`gf migrate`** converts a v6 app — its `plan()` is a pure function of `app.json` + `environment.json`, so it is unit-tested rather than run hopefully. - **chrome-headless-shell**: `chrome:install`/`chrome:update` download the Chrome for Testing Stable build for the current platform into `srv/chrome/{version}/` (manifest: - `srv/chrome/installation.json`; needs ext-zip; `gf setup` auto-installs, `--skip-chrome` opts + `srv/chrome/installation.json`; needs ext-zip; `gf init` auto-installs, `--skip-chrome` opts out; update prunes old versions). Apps consume it via `services\chrome\chrome` (§9); shared logic lives in `services/chrome/chromeInstallation.php`, download orchestration in `src/cli/chromeInstaller.php` (injectable Guzzle client — tests are network-free). @@ -582,7 +593,7 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea - When adding a command: lowercase lowerCamelCase class in `src/cli/commands/`, `#[AsCommand]` attribute, register it in `application::__construct()`, throw `cliException` for user errors, add a mirrored test in `tests/Unit/Cli/`. Keep the logic in a pure static method the test can - call directly (e.g. `migrateCommand::plan()`, `envCommand::renderEnvFile()`) rather than driving + call directly (e.g. `migrateCommand::plan()`, `migrateCommand::encodeEnvValue()`) rather than driving everything through CommandTester. --- diff --git a/README.md b/README.md index 0f0c7ea..ba868a9 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ composer install The framework expects these app classes/files to exist in your `/app` directory: * `\app\app` implementing `\gcgov\framework\interfaces\app` -* `\app\router` implementing `\gcgov\framework\interfaces\router` +* `\app\router` implementing `\gcgov\framework\interfaces\appRouter` * `\app\renderer` implementing `\gcgov\framework\interfaces\render` Controllers should implement `\gcgov\framework\interfaces\controller`. @@ -213,13 +213,18 @@ gf cli /structure/cleanup # run a CLI route (replaces app/cli/{env}.bat) gf cli /structure/cleanup --debug# run with Xdebug (replaces local-debug.bat) gf cli:list # list the app's CLI routes gf cert:generate-auth # JWT signing keys (replaces create-jwt-keys.ps1) -gf db:restore --from=prod # pull a source env's mongo dbs into the local env gf db:run db/script.js # run a mongosh script with config-managed credentials -gf env prod # validate the config.json environments.prod entry resolves -gf setup # bootstrap a scaffolded app (replaces setup.ps1) -gf deploy # tag-based deployment (replaces update-production.ps1) +gf env # validate config.json resolves against this environment +gf env --list # every variable config.json references, and which are set +gf env --init # write/extend the .env skeleton from config.json +gf init --title="My API" # bootstrap a scaffolded app (replaces setup.ps1) +gf migrate # convert a v6 application to the v7 layout ``` +Removed in v7: `deploy` (a Release is an immutable image pinned by digest — see ADR 0002), +`db:restore` (it required production credentials on every workstation), and `setup` +(replaced by the non-interactive `init`). + Tab completion is available for bash/zsh/fish (`gf completion --help`) and PowerShell (`gf completion:powershell`), including dynamic completion of the app's CLI route names. Apps and plugins can add their own gf commands via a `cli\commandProvider` class. @@ -228,7 +233,7 @@ Apps and plugins can add their own gf commands via a `cli\commandProvider` class The legacy per-app entry (`> app/cli/{env}.bat {url-path}`, `local-debug.bat` for XDebug) keeps working, but new apps should use gf. The `scripts/*.ps1` files shipped with the framework are -deprecated in favor of `gf setup` and `gf cert:generate-auth`. +deprecated in favor of `gf init` and `gf cert:generate-auth`. ## Framework Services diff --git a/readme/router.php.md b/readme/router.php.md index 5c058df..542f324 100644 --- a/readme/router.php.md +++ b/readme/router.php.md @@ -6,7 +6,7 @@ namespace app; use gcgov\framework\models\route; -class router implements \gcgov\framework\interfaces\router { +class router implements \gcgov\framework\interfaces\appRouter { public function __construct() { } @@ -68,10 +68,29 @@ class router implements \gcgov\framework\interfaces\router { return true; } - //optional method that can be added to prevent the service authentication checks from running - //private $runFrameworkServiceRouteAuthentication = true; - //public function getRunFrameworkServiceRouteAuthentication(): bool { - // return $this->runFrameworkServiceRouteAuthentication; + /** + * Does this application authenticate its own routes? + * + * Required by \gcgov\framework\interfaces\appRouter. The framework refuses to boot + * when routes declare authentication:true, no authentication service is enabled, and + * this returns false — such routes would be reachable by anyone, because the + * authentication() above returns true for every caller. Return true ONLY if + * authentication() genuinely establishes and verifies the caller's identity. + */ + public function providesAuthentication() : bool { + return false; + } + + //optional: to prevent the Framework Service auth guards from running for some routes, + //also implement \gcgov\framework\interfaces\router\skipsServiceAuthentication: + // + // class router implements \gcgov\framework\interfaces\appRouter, \gcgov\framework\interfaces\router\skipsServiceAuthentication + // + //and add the method it declares. Note the $routeHandler parameter — the opt-out is + //per route, and was duck-typed via method_exists() before the interface existed: + // + //public function getRunFrameworkServiceRouteAuthentication( \gcgov\framework\models\routeHandler $routeHandler ): bool { + // return true; //} } diff --git a/src/cli/commands/certGenerateAuthCommand.php b/src/cli/commands/certGenerateAuthCommand.php index b51c6fe..6c04c64 100644 --- a/src/cli/commands/certGenerateAuthCommand.php +++ b/src/cli/commands/certGenerateAuthCommand.php @@ -12,7 +12,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -#[AsCommand( name: 'cert:generate-auth', description: 'Generate the JWT signing keypairs in srv/jwtCertificates (replaces create-jwt-keys.ps1)' )] +#[AsCommand( name: 'cert:generate-auth', description: 'Generate the JWT signing keypairs in jwtAuth.keyPath (default srv/jwtCertificates)' )] final class certGenerateAuthCommand extends Command { protected function configure(): void { @@ -32,8 +32,10 @@ protected function execute( InputInterface $input, OutputInterface $output ): in throw new cliException( '--count must be at least 1' ); } - $context = appContext::require(); - $certificateDir = $context->getSrvDir() . '/jwtCertificates'; + $context = appContext::require(); + // Resolved through the same accessor jwtAuth reads at runtime, so a configured + // jwtAuth.keyPath gets the keys written where the framework will look for them. + $certificateDir = rtrim( $context->loadConfig()->getJwtKeyPath( $context->getSrvDir() ), '/' ); $io = new SymfonyStyle( $input, $output ); diff --git a/src/cli/commands/envCommand.php b/src/cli/commands/envCommand.php index cf8e576..c9e3d80 100644 --- a/src/cli/commands/envCommand.php +++ b/src/cli/commands/envCommand.php @@ -5,6 +5,7 @@ use gcgov\framework\cli\appContext; use gcgov\framework\cli\cliException; use gcgov\framework\cli\mongoTools; +use gcgov\framework\services\environment\envVarResolver; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -102,26 +103,78 @@ private function listReferences( appContext $context, SymfonyStyle $io ): int { } + /** + * Write, or extend, the .env skeleton. + * + * Additive by default, which is what the help text has always promised: a .env carries + * variables config.json knows nothing about (compose ports, CORS origins, GF_PHP) along + * with the values the developer has already filled in, and this command cannot + * reconstruct any of them. It used to replace the file wholesale whenever --force was + * given, which is exactly what a developer reaching for --force after the + * already-exists refusal would do. + * + * --force keeps its meaning — rewrite from config.json alone — but now says what it + * discards, and is no longer needed merely to pick up a newly added reference. + */ private function writeEnvFile( appContext $context, SymfonyStyle $io, bool $force ): int { - $envPath = $context->getEnvFilePath(); + $envPath = $context->getEnvFilePath(); + $references = $context->configReferences(); + + $existing = file_exists( $envPath ) ? (string)file_get_contents( $envPath ) : ''; - if( file_exists( $envPath ) && !$force ) { - throw new cliException( $envPath . ' already exists. Pass --force to overwrite it, or `gf env --list` to see what it should contain. (A .env usually holds values this command cannot know — overwriting is deliberately opt-in.)' ); + if( $existing!=='' && $force ) { + $io->warning( 'Replacing ' . $envPath . ' from config.json. Every value it holds, and every variable config.json does not reference, is discarded.' ); + $existing = ''; } - $references = $context->configReferences(); - $contents = $this->renderEnvFile( $references ); + if( $existing==='' ) { + $contents = $this->renderEnvFile( $references ); + $added = count( $references ); + } + else { + $declared = self::declaredNames( $existing ); + $missing = array_filter( $references, static fn( bool $isSecret, string $name ): bool => !isset( $declared[ $name ] ) && !isset( $declared[ $name . envVarResolver::SECRET_FILE_SUFFIX ] ), ARRAY_FILTER_USE_BOTH ); + + if( count( $missing )===0 ) { + $io->success( $envPath . ' already declares every variable config.json references. Nothing to add.' ); + + return Command::SUCCESS; + } + + $contents = rtrim( $existing, "\n" ) . "\n\n" . implode( "\n", array_merge( [ '# Added by `gf env --init` from config.json.' ], self::renderReferenceLines( $missing ) ) ) . "\n"; + $added = count( $missing ); + } if( file_put_contents( $envPath, $contents )===false ) { throw new cliException( 'Failed writing ' . $envPath ); } - $io->success( 'Wrote ' . $envPath . ' with ' . count( $references ) . ' variable(s). Fill in the values — the application will not start until every one has one.' ); + $io->success( 'Wrote ' . $envPath . ' with ' . $added . ' variable(s). Fill in the values — the application will not start until every one has one.' ); return Command::SUCCESS; } + /** + * The variable names a .env already declares, so --init can skip them. + * + * Only uncommented `NAME=` assignments count: a commented `# NAME_FILE=` hint is + * guidance, not a declaration. + * + * @return array + */ + private static function declaredNames( string $env ): array { + $names = []; + foreach( preg_split( '/\R/', $env ) ?: [] as $line ) { + if( preg_match( '/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/', $line, $m )===1 ) { + $names[ $m[ 1 ] ] = true; + } + } + + return $names; + } + + /** * @param array $references variable name => is a secret */ @@ -134,6 +187,20 @@ public function renderEnvFile( array $references ): string { '', ]; + return implode( "\n", array_merge( $lines, self::renderReferenceLines( $references ) ) ) . "\n"; + } + + + /** + * The variable lines themselves, shared by the fresh-file and append paths so the two + * cannot describe the secret convention differently. + * + * @param array $references variable name => is a secret + * + * @return string[] + */ + private static function renderReferenceLines( array $references ): array { + $lines = []; $secrets = array_keys( array_filter( $references ) ); $plain = array_keys( array_filter( $references, static fn( bool $isSecret ): bool => !$isSecret ) ); @@ -151,19 +218,13 @@ public function renderEnvFile( array $references ): string { } } - return implode( "\n", $lines ) . "\n"; + return $lines; } /** Whether a variable currently has a value, by either the plain or the _FILE name. */ private function isSet( string $name ): bool { - foreach( [ $name, $name . \gcgov\framework\services\environment\envVarResolver::SECRET_FILE_SUFFIX ] as $candidate ) { - if( ( $_ENV[ $candidate ] ?? $_SERVER[ $candidate ] ?? getenv( $candidate ) ?: '' )!=='' ) { - return true; - } - } - - return false; + return envVarResolver::isSatisfied( $name ); } } diff --git a/src/cli/commands/initCommand.php b/src/cli/commands/initCommand.php index b4c14e5..0b89a90 100644 --- a/src/cli/commands/initCommand.php +++ b/src/cli/commands/initCommand.php @@ -97,28 +97,63 @@ private function writeIdentity( appContext $context, SymfonyStyle $io, string $t throw new cliException( 'Missing ' . $configPath . '. Scaffold from gcgov/framework-app-template, which ships one.' ); } - $raw = (string)file_get_contents( $configPath ); - $decoded = json_decode( $raw, true ); - if( !is_array( $decoded ) ) { - throw new cliException( 'Failed to parse ' . $configPath . ': the file is not a valid JSON object.' ); + $identity = self::applyIdentity( (string)file_get_contents( $configPath ), $title, $guid, $configPath ); + + if( file_put_contents( $configPath, $identity[ 'json' ] )===false ) { + throw new cliException( 'Failed writing ' . $configPath ); + } + + $io->section( 'Identity' ); + $io->text( 'title: ' . $identity[ 'title' ] ); + $io->text( 'guid: ' . $identity[ 'guid' ] . ( $identity[ 'guidKept' ] ? ' (kept)' : '' ) ); + } + + + /** + * Stamp the title and guid into a config.json document, returning the rewritten JSON. + * + * Pure, so the rewrite can be tested directly rather than through the filesystem. + * + * Decoded as objects rather than associative arrays: json_decode( $raw, true ) maps an + * empty JSON object to [], which json_encode writes back as []. In config.json `{}` + * carries meaning — `"services": { "userCrud": {} }` is how a Framework Service is + * enabled, since presence is what activates it — so the assoc round-trip rewrote every + * service block the template declared into an array that no longer hydrates the nullable + * service properties, silently disabling userCrud and documentation on the very first + * command a new project runs. + * + * @return array{json: string, title: string, guid: string, guidKept: bool} + * @throws \gcgov\framework\cli\cliException + */ + public static function applyIdentity( string $rawConfigJson, string $title, string $guid, string $sourceDescription = 'config.json' ): array { + $decoded = json_decode( $rawConfigJson, false ); + if( !$decoded instanceof \stdClass ) { + throw new cliException( 'Failed to parse ' . $sourceDescription . ': the file is not a valid JSON object.' ); } - $existingGuid = (string)( $decoded[ 'app' ][ 'guid' ] ?? '' ); + if( !isset( $decoded->app ) || !$decoded->app instanceof \stdClass ) { + $decoded->app = new \stdClass(); + } + + $existingGuid = isset( $decoded->app->guid ) ? (string)$decoded->app->guid : ''; // The guid is the OAuth client_id: minting a new one for an application that already // has one invalidates every registered client. - $decoded[ 'app' ][ 'guid' ] = $guid!=='' ? $guid : ( $existingGuid!=='' ? $existingGuid : guid::create() ); + $decoded->app->guid = $guid!=='' ? $guid : ( $existingGuid!=='' ? $existingGuid : guid::create() ); if( $title!=='' ) { - $decoded[ 'app' ][ 'title' ] = $title; + $decoded->app->title = $title; } $encoded = json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); - if( $encoded===false || file_put_contents( $configPath, $encoded . "\n" )===false ) { - throw new cliException( 'Failed writing ' . $configPath ); + if( $encoded===false ) { + throw new cliException( 'Failed encoding ' . $sourceDescription ); } - $io->section( 'Identity' ); - $io->text( 'title: ' . ( $decoded[ 'app' ][ 'title' ] ?? '' ) ); - $io->text( 'guid: ' . $decoded[ 'app' ][ 'guid' ] . ( $existingGuid!=='' && $existingGuid===$decoded[ 'app' ][ 'guid' ] ? ' (kept)' : '' ) ); + return [ + 'json' => $encoded . "\n", + 'title' => isset( $decoded->app->title ) ? (string)$decoded->app->title : '', + 'guid' => (string)$decoded->app->guid, + 'guidKept' => $existingGuid!=='' && $existingGuid===$decoded->app->guid, + ]; } diff --git a/src/cli/commands/migrateCommand.php b/src/cli/commands/migrateCommand.php index eed16f2..f3c51e7 100644 --- a/src/cli/commands/migrateCommand.php +++ b/src/cli/commands/migrateCommand.php @@ -505,6 +505,25 @@ private static function readJson( string $path ): array { } + /** + * Quote a value for .env. + * + * The values this command writes are the credentials lifted out of the v6 config — + * MONGO_URI, MICROSOFT_CLIENT_SECRET, SMTP_PASSWORD, PAYJUNCTION_PASSWORD. Written bare + * they are silently corrupted: symfony/dotenv interpolates $VAR in an unquoted value, + * treats a whitespace-preceded # as the start of a comment, and rejects an embedded + * quote outright. Because every %env() reference is required and '' counts as unset, + * the damage surfaces later as a wrong-credential auth failure, not as a startup error. + * + * Single quotes suppress interpolation; an embedded single quote is closed, escaped and + * reopened. Verified against symfony/dotenv 7 for values containing $, ${}, #, spaces, + * both quote characters, backslashes and newlines. + */ + public static function encodeEnvValue( string $value ): string { + return "'" . str_replace( "'", "'\\''", $value ) . "'"; + } + + /** * @param array $env * @param array $secrets @@ -520,10 +539,12 @@ private function writeEnvFile( string $path, array $env, array $secrets ): void '', ]; foreach( $env as $name => $value ) { + $lines[] = $name . '=' . self::encodeEnvValue( $value ); if( $secrets[ $name ] ?? false ) { - $lines[] = '# secret'; + // Same convention `gf env --init` writes, so a migrated .env shows the file + // indirection it will need in production rather than a bare marker. + $lines[] = '# ' . $name . '_FILE=/run/secrets/' . strtolower( $name ); } - $lines[] = $name . '=' . $value; } if( file_exists( $path ) ) { diff --git a/src/cli/internal/run-route.php b/src/cli/internal/run-route.php index d12904c..4a08593 100644 --- a/src/cli/internal/run-route.php +++ b/src/cli/internal/run-route.php @@ -32,7 +32,7 @@ }; if( PHP_SAPI!=='cli' ) { - $gfWriteError( 'application CLI routes must run under the PHP CLI binary, but this process is running the "' . PHP_SAPI . '" SAPI. Point gf at php/php.exe instead of php-cgi/php-fpm with `gf cli --php=`, the GF_PHP environment variable, or "phpPath" in config.json.' ); + $gfWriteError( 'application CLI routes must run under the PHP CLI binary, but this process is running the "' . PHP_SAPI . '" SAPI. Point gf at php/php.exe instead of php-cgi/php-fpm with `gf cli --php=`, or the GF_PHP environment variable.' ); exit( 2 ); } diff --git a/src/config.php b/src/config.php index 2d4b789..43c66b9 100644 --- a/src/config.php +++ b/src/config.php @@ -247,6 +247,17 @@ public static function getBasePath(): string { } + /** + * The base path in the form a route pattern is built from: '' at the domain root, '/api' otherwise. + * Use this, not getBasePath(), when prefixing a route — see {@see unifiedConfig::getRoutePrefix()}. + * + * @throws \gcgov\framework\exceptions\configException + */ + public static function getRoutePrefix(): string { + return self::unifiedConfig()->getRoutePrefix(); + } + + /** @throws \gcgov\framework\exceptions\configException */ public static function getLogging(): logging { return self::unifiedConfig()->logging; @@ -314,9 +325,7 @@ public static function getTokenPermittedFor(): string { * @throws \gcgov\framework\exceptions\configException */ public static function getJwtKeyPath(): string { - $configured = trim( self::unifiedConfig()->jwtAuth->keyPath ); - - return rtrim( $configured!=='' ? str_replace( '\\', '/', $configured ) : self::getSrvDir() . 'jwtCertificates', '/' ) . '/'; + return self::unifiedConfig()->getJwtKeyPath( self::getSrvDir() ); } diff --git a/src/framework.php b/src/framework.php index 27eb529..80dcaf3 100644 --- a/src/framework.php +++ b/src/framework.php @@ -33,6 +33,24 @@ public function runApp() : string { catch( routeException $e ) { $routeException = $e; } + catch( \Throwable $e ) { + // Routing throws several classes that are not routeException, and every one of + // them used to escape runApp() as a bare PHP fatal — skipping \app\router::_after(), + // the renderer and \app\app::_after(), and returning no framework error body at all: + // + // configException the fail-closed checks (authenticated routes with no auth + // service, a missing config.json, an unresolved %env() reference) + // — it extends \LogicException, unrelated to routeException + // BadRouteException an application defining a route the framework already registers + // \TypeError an \app\router that does not implement interfaces\appRouter + // + // Refusing loudly is the whole point of those checks, so they are rendered like any + // other failure. The detail goes to the log and never to the client: these messages + // carry route patterns, config file paths and the names of missing environment + // variables. services\log falls back to stderr when config itself is what failed. + \gcgov\framework\services\log::critical( 'Framework Lifecycle', $e->getMessage(), [ 'exception' => $e ] ); + $routeException = new routeException( 'Server configuration error', 500, $e ); + } \app\router::_after(); //renderer and controller (renderer handles calling controller lifecycle methods) diff --git a/src/models/authUser.php b/src/models/authUser.php index a2e92c8..4fe6b7b 100644 --- a/src/models/authUser.php +++ b/src/models/authUser.php @@ -100,7 +100,7 @@ public function setFromJwtToken( array $tokenUser, array $tokenScopes ): self { $this->externalProvider = $tokenUser[ 'externalProvider' ] ?? ''; $this->name = $tokenUser[ 'name' ] ?? ''; $this->email = $tokenUser[ 'email' ] ?? ''; - $this->roles = $tokenScopes; + $this->roles = self::normalizeRoles( $tokenScopes ); return self::getInstance(); } @@ -117,12 +117,33 @@ public function setFromUser( \gcgov\framework\interfaces\auth\user $user ): self $this->name = $user->getName(); $this->username = $user->getUsername(); $this->email = $user->getEmail(); - $this->roles = $user->getRoles(); + $this->roles = self::normalizeRoles( $user->getRoles() ); return self::getInstance(); } + + /** + * Narrow roles to strings. + * + * $roles is documented string[] but nothing enforced it, and both sources are untyped: + * the token's `scope` claim is whatever JSON it carried, and the user model's roles + * field is whatever the collection holds — BSON deserialization passes scalars through + * untouched. A single non-string truthy element (roles: [true]) satisfied a loose + * in_array() against EVERY required role, so a token carrying one authorized every + * gated route. Narrowing here covers both setters and makes the strict comparisons in + * hasRole() and the auth guard meaningful. + * + * @param mixed[] $roles + * + * @return string[] + */ + private static function normalizeRoles( array $roles ): array { + return array_values( array_filter( $roles, static fn( $role ): bool => is_string( $role ) ) ); + } + + public function hasRole( string $role ): bool { - return in_array( $role, $this->roles ); + return in_array( $role, $this->roles, true ); } } diff --git a/src/models/unifiedConfig.php b/src/models/unifiedConfig.php index 63675ee..7bf07b6 100644 --- a/src/models/unifiedConfig.php +++ b/src/models/unifiedConfig.php @@ -99,7 +99,7 @@ public function getRootUrl(): string { public function getBaseUrl(): string { - return rtrim( $this->rootUrl, '/ ' ) . '/' . trim( $this->basePath, '/ ' ); + return rtrim( rtrim( $this->rootUrl, '/ ' ) . '/' . trim( $this->basePath, '/ ' ), '/' ); } @@ -108,6 +108,40 @@ public function getBasePath(): string { } + /** + * The base path in the form a route pattern is built from: '' at the domain root, + * '/api' otherwise. + * + * {@see getBasePath()} cannot serve this purpose. It returns '/' at the domain root + * — correct for the token audience, which is its other use — and concatenating that + * with a leading-slash route yields '//user', which FastRoute registers and matches + * as that literal string. Every router prefixing a route uses this instead. + */ + public function getRoutePrefix(): string { + return rtrim( $this->getBasePath(), '/' ); + } + + + /** + * Where the JWT signing keypairs live: jwtAuth.keyPath when set, else + * {srvDir}/jwtCertificates. Always returned with a trailing slash. + * + * The srv directory is an argument because the two callers reach it differently — the + * request lifecycle through config::getSrvDir(), the gf CLI through appContext, which + * never boots \app. They previously resolved the location independently, so + * `gf cert:generate-auth` wrote keys to srv/jwtCertificates while jwtAuth looked in the + * configured keyPath and reported the very command that had just run as the remedy. + */ + public function getJwtKeyPath( string $srvDir ): string { + $configured = trim( $this->jwtAuth->keyPath ); + if( $configured!=='' ) { + return rtrim( str_replace( '\\', '/', $configured ), '/' ) . '/'; + } + + return rtrim( str_replace( '\\', '/', $srvDir ), '/' ) . '/jwtCertificates/'; + } + + /** Token issuer, defaulting to the application's own root url. */ public function getTokenIssuedBy(): string { return $this->jwtAuth->tokenIssuedBy!=='' ? $this->jwtAuth->tokenIssuedBy : $this->getRootUrl(); diff --git a/src/router.php b/src/router.php index c02ae88..dcb6e4e 100644 --- a/src/router.php +++ b/src/router.php @@ -213,23 +213,72 @@ public static function getMergedRoutes(): array { * @return \gcgov\framework\models\route[] */ private function getRoutes(): array { - $routes = []; + $serviceRoutes = []; foreach($this->serviceRouters as $serviceRouter) { if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- get service routes' ); } - $serviceRoutes = $serviceRouter->getRoutes(); - $routes = array_merge( $routes, $serviceRoutes ); + $serviceRoutes = array_merge( $serviceRoutes, $serviceRouter->getRoutes() ); } if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- get app routes' ); } $appRoutes = $this->appRouter->getRoutes(); - $routes = array_merge( $routes, $appRoutes ); - return $routes; + // Where the application defines a route the framework already registers, the + // application wins and the framework's is dropped. + // + // FastRoute throws BadRouteException on a duplicate (method, pattern), and that + // exception is neither a routeException nor a configException — so a v6 application + // upgrading with its own /health did not lose /health, it lost EVERY route, with an + // empty 500 on every url. The health router's docblock already promised that such + // an application "keeps working"; nothing implemented it. Overriding is logged + // rather than silent, because a route disappearing from the framework's surface is + // worth noticing. + $appKeys = []; + foreach( $appRoutes as $appRoute ) { + foreach( self::routeKeys( $appRoute ) as $key ) { + $appKeys[ $key ] = true; + } + } + + $routes = []; + foreach( $serviceRoutes as $serviceRoute ) { + $overridden = false; + foreach( self::routeKeys( $serviceRoute ) as $key ) { + if( isset( $appKeys[ $key ] ) ) { + $overridden = true; + break; + } + } + + if( $overridden ) { + log::notice( 'Framework Lifecycle', '-Router- \app\router defines "' . $serviceRoute->route . '"; the framework route of the same name is not registered' ); + continue; + } + + $routes[] = $serviceRoute; + } + + return array_merge( $routes, $appRoutes ); + } + + + /** + * The (method, pattern) pairs a route occupies. httpMethod is string|array, and a route + * registered for several methods collides on each of them independently. + * + * @return string[] + */ + private static function routeKeys( \gcgov\framework\models\route $route ): array { + $keys = []; + foreach( (array)$route->httpMethod as $httpMethod ) { + $keys[] = strtoupper( (string)$httpMethod ) . ' ' . $route->route; + } + + return $keys; } diff --git a/src/services/auth/guard.php b/src/services/auth/guard.php index 4eaa416..a02ddbb 100644 --- a/src/services/auth/guard.php +++ b/src/services/auth/guard.php @@ -52,7 +52,10 @@ public static function authenticate( \gcgov\framework\models\routeHandler $route } foreach( $routeHandler->requiredRoles as $requiredRole ) { - if( !in_array( $requiredRole, $authUser->roles ) ) { + // Strict: $authUser->roles is narrowed to strings by authUser::normalizeRoles(), + // and a loose comparison here would still match a required role against any + // truthy element were that ever to change. + if( !in_array( $requiredRole, $authUser->roles, true ) ) { throw new routeException( 'User does not have the permission "' . $requiredRole . '" required to access this content', 403 ); } } diff --git a/src/services/auth/providers/oauth/controllers/auth.php b/src/services/auth/providers/oauth/controllers/auth.php index 86329f5..4e4c73d 100644 --- a/src/services/auth/providers/oauth/controllers/auth.php +++ b/src/services/auth/providers/oauth/controllers/auth.php @@ -135,12 +135,12 @@ public function oauthGetAuthorize(): controllerDataResponse { } unset( $_SESSION[ 'auth_state' ] ); if( !empty( $_GET[ 'state' ] ) ) { - $_SESSION[ 'auth_state' ] = urldecode( $_GET[ 'state' ] ); + // No urldecode(): PHP has already percent-decoded $_GET, so decoding again turned + // a state of a%252Bb into 'a+b' and %250A into a literal newline. + $_SESSION[ 'auth_state' ] = (string)$_GET[ 'state' ]; } - $this->oauthHybridAuth( $_GET[ 'scope' ] ); - - return new controllerDataResponse(); + return $this->oauthHybridAuth( $_GET[ 'scope' ] ); } @@ -322,21 +322,7 @@ private function refresh_token(): stdAuthResponse { throw new controllerException( 'Refresh token corrupted', 401 ); } - //create token for good user - $authUser = \gcgov\framework\services\request::getAuthUser(); - $authUser->setFromUser( $user ); - - $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); - $accessToken = $jwtService->createAccessToken( $authUser ); - try { - $refreshToken = $jwtService->createRefreshToken( $authUser ); - } - catch( modelException $e ) { - throw new controllerException( 'Failed to create new refresh token', 500 ); - } - - return new stdAuthResponse( $accessToken, $refreshToken ); - + return $this->createAccessTokenResponse( $user ); } @@ -367,26 +353,13 @@ private function authorization_code(): stdAuthResponse { throw new controllerException( 'Authorization code corrupted', 401 ); } - //create token for good user - $authUser = \gcgov\framework\services\request::getAuthUser(); - $authUser->setFromUser( $user ); - - $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); - $accessToken = $jwtService->createAccessToken( $authUser ); - try { - $refreshToken = $jwtService->createRefreshToken( $authUser ); - } - catch( modelException $e ) { - throw new controllerException( $e->getMessage(), 500, $e ); - } - - return new stdAuthResponse( $accessToken, $refreshToken ); + return $this->createAccessTokenResponse( $user ); } /** * @throws \gcgov\framework\exceptions\controllerException */ - public function oauthHybridAuth( string $provider = '' ): void { + public function oauthHybridAuth( string $provider = '' ): controllerDataResponse { $provider = strtolower( $provider ); if( $provider=='google' ) { @@ -399,10 +372,10 @@ public function oauthHybridAuth( string $provider = '' ): void { $provider = "MicrosoftGraph"; if( empty( config::getMicrosoft()->clientId ) ) { - throw new controllerException( 'Microsoft client id has not been defined in the app config file. /app/config/environment.json > microsoft.clientId', 400 ); + throw new controllerException( 'Microsoft client id has not been defined in config.json > microsoft.clientId', 400 ); } if( empty( config::getMicrosoft()->clientSecret ) ) { - throw new controllerException( 'Microsoft client secret has not been defined in the app config file. /app/config/environment.json > microsoft.clientSecret', 400 ); + throw new controllerException( 'Microsoft client secret has not been defined in config.json > microsoft.clientSecret', 400 ); } if( empty( config::getMicrosoft()->tenant ) ) { throw new controllerException( 'Microsoft tenant has not been defined in the app config file. /app/config/environment.json > microsoft.tenant', 400 ); @@ -471,7 +444,7 @@ public function oauthHybridAuth( string $provider = '' ): void { $adapter->disconnect(); } catch( \Exception $e ) { - error_log( $e ); + log::error( 'auth', 'Hybridauth provider authentication failed', [ 'exception' => $e ] ); $message = $e->getMessage(); switch( $e->getCode() ) { case 0 : @@ -503,8 +476,7 @@ public function oauthHybridAuth( string $provider = '' ): void { break; } - header( 'Location: ' . config::getJwtAuth()->redirectAfterLoginUrl . '?errorMessage=' . urlencode( $message ) ); - exit; + return self::redirect( config::getJwtAuth()->redirectAfterLoginUrl . '?errorMessage=' . urlencode( $message ) ); } if( empty( $oauthProfile->email ) ) { @@ -524,8 +496,7 @@ public function oauthHybridAuth( string $provider = '' ): void { rolesForNewUser: $authConfig->defaultNewUserRoles ); } catch( modelException $e ) { - header( 'Location: ' . config::getJwtAuth()->redirectAfterLoginUrl . '?errorMessage=' . urlencode( $e->getMessage() ) ); - exit; + return self::redirect( config::getJwtAuth()->redirectAfterLoginUrl . '?errorMessage=' . urlencode( $e->getMessage() ) ); } try { @@ -540,15 +511,35 @@ public function oauthHybridAuth( string $provider = '' ): void { $appendState = ''; if( !empty( $_SESSION[ 'auth_state' ] ) ) { - $appendState = '&state=' . $_SESSION[ 'auth_state' ]; + // Encoded, like the code parameter beside it. The state is client-supplied, so a + // raw '&' or '=' in it split into extra query parameters in the redirect — which + // broke the client's CSRF state comparison, and let whoever chose the state + // append parameters of their own to the URL the browser is sent to. + $appendState = '&state=' . urlencode( (string)$_SESSION[ 'auth_state' ] ); } if( session_status()==PHP_SESSION_ACTIVE ) { session_destroy(); } - header( 'Location: ' . config::getJwtAuth()->redirectAfterLoginUrl . '?code=' . urlencode( (string)$authorizationCode->_id ) . $appendState ); - exit; + return self::redirect( config::getJwtAuth()->redirectAfterLoginUrl . '?code=' . urlencode( (string)$authorizationCode->_id ) . $appendState ); + } + + + /** + * A 302 as a controllerResponse. + * + * This path used to call header() then exit, which skipped controller::_after(), + * \app\renderer::_after() and \app\app::_after() on every OAuth sign-in — the success + * path included — so an application that releases resources or flushes state in those + * hooks never ran them on the most security-relevant request it serves. CLAUDE.md §4 + * allows exactly one exception to the never-exit rule, and it is not this file. + */ + private static function redirect( string $url ): controllerDataResponse { + $response = new controllerDataResponse( null, [ new \gcgov\framework\models\controllerResponseHeader( 'Location', $url ) ] ); + $response->setHttpStatus( 302 ); + + return $response; } @@ -574,8 +565,12 @@ public function createExternalAppToken( string $appName, \DateInterval $tokenExp $jwt = new \gcgov\framework\services\jwtAuth\jwtAuth(); $token = $jwt->createAccessToken( $authUser, $tokenExpiration ); + // 0770, octal. This was written as the decimal literal 777, which is octal 1411: + // sticky bit set, owner r--, group --x, other --x. mkdir still returned true, so the + // guard below passed, and the write that follows then failed for any non-root + // process because the owner had neither write nor execute on its own directory. if( !file_exists( config::getRootDir() . '/externalAppTokens/' ) ) { - $created = mkdir( config::getRootDir() . '/externalAppTokens/', 777, true ); + $created = mkdir( config::getRootDir() . '/externalAppTokens/', 0770, true ); if( !$created ) { log::warning( 'auth', 'Directory "' . config::getRootDir() . '/externalAppTokens/" does not exist and could not be created automatically. Create directory to continue.' ); throw new controllerException( 'Cannot create token because externalAppTokens directory does not exist' ); @@ -584,7 +579,12 @@ public function createExternalAppToken( string $appName, \DateInterval $tokenExp $tokenFilePath = config::getRootDir() . '/externalAppTokens/' . formatting::fileName( $appName ) . '.txt'; - file_put_contents( $tokenFilePath, $token->toString() ); + // Checked: an unchecked write here reported a token file that was never created, + // and the caller had no way to tell. + if( file_put_contents( $tokenFilePath, $token->toString() )===false ) { + log::error( 'auth', 'Failed writing external app token to "' . $tokenFilePath . '"' ); + throw new controllerException( 'Failed to write the external app token file', 500 ); + } return new controllerDataResponse( $tokenFilePath ); } @@ -593,22 +593,22 @@ private function createAccessTokenResponse( \gcgov\framework\interfaces\auth\use //create token for valid user $authUser = \gcgov\framework\services\request::getAuthUser(); $authUser->setFromUser( $user ); - try { - $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); - $accessToken = $jwtService->createAccessToken( $authUser ); - - try { - $refreshToken = $jwtService->createRefreshToken( $authUser ); - } - catch( modelException $e ) { - throw new controllerException( 'Server failed to create refresh token', 500, $e ); - } - return new stdAuthResponse( $accessToken, $refreshToken ); + // One try, one catch. The outer catch here used to wrap the inner one and re-throw + // with the constructor arguments swapped — $e->getCode() as the message and + // $e->getMessage() as the int $code — so every failure on this path raised a + // TypeError instead of the 500 it meant to raise, and the renderer's + // controllerException branch never ran. + try { + $jwtService = new \gcgov\framework\services\jwtAuth\jwtAuth(); + $accessToken = $jwtService->createAccessToken( $authUser ); + $refreshToken = $jwtService->createRefreshToken( $authUser ); } catch( \Exception $e ) { - throw new controllerException( $e->getCode(), $e->getMessage(), $e ); + throw new controllerException( 'Server failed to create authentication tokens', 500, $e ); } + + return new stdAuthResponse( $accessToken, $refreshToken ); } /** diff --git a/src/services/auth/providers/oauth/services/multifactor.php b/src/services/auth/providers/oauth/services/multifactor.php index 4d905e6..72281b7 100644 --- a/src/services/auth/providers/oauth/services/multifactor.php +++ b/src/services/auth/providers/oauth/services/multifactor.php @@ -8,6 +8,7 @@ use gcgov\framework\exceptions\modelException; use gcgov\framework\services\auth\providers\oauth\models\configureMfaResponse; use gcgov\framework\services\auth\providers\oauth\models\requireMfaResponse; +use gcgov\framework\services\log; use gcgov\framework\services\mongodb\models\auth\userMultifactor; use RobThree\Auth\TwoFactorAuthException; @@ -60,7 +61,7 @@ public static function configureMfaResponse( \MongoDB\BSON\ObjectId $userId, ?\L userMultifactor::save( $userMultifactor ); } catch( modelException $e ) { - error_log( $e ); + log::error( 'auth', 'Failed to save MFA secret', [ 'exception' => $e ] ); throw new controllerException( 'Failed to save MFA secret', 500 ); } @@ -81,7 +82,6 @@ public static function verifyMfaSecret( \MongoDB\BSON\ObjectId $userId, \MongoDB try { $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); $user = $userClassName::getOne( $userId ); - error_log( $user->password ); $userMultifactor = userMultifactor::getOneBy( [ '_id' => $userMultifactorId, 'userId' => $user->_id ] ); } catch( modelDocumentNotFoundException|modelException $e ) { @@ -162,8 +162,7 @@ public static function isMfaCodeCorrect( \MongoDB\BSON\ObjectId $userId, string userMultifactor::save( $userMultifactor ); } catch( \Exception $e ) { - error_log( 'Failed to save MFA timeslice' ); - error_log( $e ); + log::error( 'auth', 'Failed to save MFA timeslice', [ 'exception' => $e ] ); } return true; diff --git a/src/services/auth/router.php b/src/services/auth/router.php index 6bb01de..621b883 100644 --- a/src/services/auth/router.php +++ b/src/services/auth/router.php @@ -25,7 +25,7 @@ public function __construct( private readonly authConfig $config ) { public function getRoutes(): array { - $basePath = config::getBasePath(); + $basePath = config::getRoutePrefix(); $routes = [ new route( 'GET', $basePath . '/.well-known/jwks.json', self::SHARED, 'jwks', false, description: 'Public keys for validating tokens this application issued.' ), diff --git a/src/services/cronMonitor/cronMonitor.php b/src/services/cronMonitor/cronMonitor.php index 748ff47..05d7c3f 100644 --- a/src/services/cronMonitor/cronMonitor.php +++ b/src/services/cronMonitor/cronMonitor.php @@ -18,18 +18,33 @@ */ class cronMonitor { - private string $jobId; - private \GuzzleHttp\Client $client; - private \GuzzleHttp\Promise\PromiseInterface $jobPromise; + private string $jobId; + private ?\GuzzleHttp\Client $client = null; + private ?\GuzzleHttp\Promise\PromiseInterface $jobPromise = null; public function __construct( string $jobId ) { - $this->jobId = $jobId; + $this->jobId = $jobId; + + // An empty url disables reporting, as the config docblock and CLAUDE.md §8 both + // say. Without this guard the documented off switch did nothing: the constructor + // built a client with an empty base_uri and fired a request at a hostless relative + // URI, and because the rejection only surfaces in end()'s wait(), every cron run on + // a default-configured application blocked on one doomed request and then issued a + // second. The constructor is also the one place in this fail-safe class with no + // try/catch, so its contract only held from the second line on. + if( !config::getCronMonitor()->isConfigured() ) { + return; + } + $this->client = new \GuzzleHttp\Client( [ 'base_uri' => config::getCronMonitor()->url ] ); // Fired asynchronously so the start ping overlaps the job rather than delaying it. $this->jobPromise = $this->client->requestAsync( 'GET', 'jobHistory/start/' . $this->jobId ); } public function end(): void { + if( $this->client===null || $this->jobPromise===null ) { + return; + } $runId = ''; //make sure the job started and get the run id from it's response diff --git a/src/services/documentation/router.php b/src/services/documentation/router.php index a8c1d27..5a9a4fc 100644 --- a/src/services/documentation/router.php +++ b/src/services/documentation/router.php @@ -16,7 +16,7 @@ class router implements \gcgov\framework\interfaces\router { public function getRoutes(): array { return [ - new route( 'GET', config::getBasePath() . '/documentation.yaml', '\gcgov\framework\services\documentation\controllers\documentation', 'yaml', false, description: 'OpenAPI document generated from source annotations.' ) + new route( 'GET', config::getRoutePrefix() . '/documentation.yaml', '\gcgov\framework\services\documentation\controllers\documentation', 'yaml', false, description: 'OpenAPI document generated from source annotations.' ) ]; } diff --git a/src/services/environment/configLoader.php b/src/services/environment/configLoader.php index 060beb0..22bbd6e 100644 --- a/src/services/environment/configLoader.php +++ b/src/services/environment/configLoader.php @@ -60,6 +60,12 @@ public static function references( string $rootDir ): array { throw new environmentException( 'Missing config file: ' . $configFile ); } + // The references themselves do not need resolving, but the caller asks whether each + // one is SET — and on a developer machine the answers live in {root}/.env. Without + // this, `gf env --list` reported every variable MISSING while `gf env` on the same + // machine resolved the same config.json successfully. + dotEnvLoader::loadOnce( $rootDir ); + $decoded = json_decode( (string)file_get_contents( $configFile ), false ); if( !$decoded instanceof \stdClass ) { throw new environmentException( 'Failed to parse ' . $configFile . ': the file is not a valid JSON object.' ); diff --git a/src/services/environment/dotEnvLoader.php b/src/services/environment/dotEnvLoader.php index 69e82c2..a30ad5a 100644 --- a/src/services/environment/dotEnvLoader.php +++ b/src/services/environment/dotEnvLoader.php @@ -18,11 +18,9 @@ * its own — a project keeping only machine-local values in `.env.local` loads * exactly like one with only `.env`. * - * There is deliberately no APP_ENV cascade: environment selection is simply - * which variables the process environment (or .env) supplies. The gf CLI reads - * a *foreign* environment's values via the `environments.{name}` section of - * config.json, referencing distinctly-named variables (e.g. PROD_MONGO_URI) - * that live in the same `.env`. + * There is deliberately no APP_ENV cascade: an Environment IS the variable set the + * process is given, so environment selection is simply which variables the process + * environment (or .env) supplies. Nothing is activated, copied, or selected by name. */ final class dotEnvLoader { diff --git a/src/services/environment/envVarResolver.php b/src/services/environment/envVarResolver.php index 7ce2fc9..a4b3792 100644 --- a/src/services/environment/envVarResolver.php +++ b/src/services/environment/envVarResolver.php @@ -345,9 +345,18 @@ private static function lookupSecret( string $varName, string $expression, strin private static function applyProcessor( string $processor, mixed $value, string $expression, string $sourceDescription ): mixed { switch( $processor ) { case 'bool': + // Fails closed, like "int" immediately below. The fallback used to be + // `?? (bool)$value`, which turned every unrecognised value — "flase", + // "disabled", "off ", "2" — into TRUE with no error: a typo in + // AUTH_BLOCK_NEW_USERS or LOGGING_LIFECYCLE resolved silently to the wrong + // setting and `gf env` reported success. ADR 0001 removed the default: + // processor to keep exactly this from happening one processor over. $bool = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); + if( $bool===null ) { + throw new environmentException( 'Cannot apply "bool" to value "' . (string)$value . '" for "%env(' . $expression . ')%" in ' . $sourceDescription . '. Use one of: 1/0, true/false, yes/no, on/off.' ); + } - return $bool ?? (bool)$value; + return $bool; case 'int': if( !is_numeric( trim( (string)$value ) ) ) { @@ -436,4 +445,19 @@ private static function lookupEnv( string $name ): ?string { return ( $value===null || $value==='' ) ? null : $value; } + + /** + * Whether a reference is satisfied by the current environment, by either the plain name + * or its {NAME}_FILE companion. + * + * Shared with `gf env --list` so the diagnostic and the resolver cannot disagree about + * what "set" means. The command reimplemented this inline and got two things wrong that + * lookupEnv() already handles: it skipped the blocked-CGI-name rule, and its + * `?? ... ?: ''` chain collapsed a legitimate "0" to '' — reporting MISSING for every + * variable holding the value that %env(bool:...)% false is written as. + */ + public static function isSatisfied( string $name ): bool { + return self::lookupEnv( $name )!==null || self::lookupEnv( $name . self::SECRET_FILE_SUFFIX )!==null; + } + } diff --git a/src/services/health/controllers/health.php b/src/services/health/controllers/health.php index 4e4a60f..f077030 100644 --- a/src/services/health/controllers/health.php +++ b/src/services/health/controllers/health.php @@ -3,7 +3,9 @@ namespace gcgov\framework\services\health\controllers; use gcgov\framework\config; +use gcgov\framework\models\config\environment\mongoDatabase; use gcgov\framework\models\controllerDataResponse; +use gcgov\framework\services\log; /** * Liveness and readiness, deliberately kept apart. @@ -15,6 +17,19 @@ */ final class health implements \gcgov\framework\interfaces\controller { + /** + * How long a dependency gets to answer a readiness probe. + * + * Sized to the probe interval, not to a user request. The driver's default + * serverSelectionTimeoutMS is 30 seconds, so an unreachable database parked one worker + * per probe for 30 seconds each — with an orchestrator probing every few seconds and a + * fresh client per configured database, every worker ends up sitting in a hung probe + * and real traffic 502s. That is precisely the crash loop the liveness/readiness split + * exists to prevent, arriving through readiness instead. + */ + private const PROBE_TIMEOUT_MS = 2000; + + public static function _before(): void { } @@ -46,14 +61,17 @@ public function ready(): controllerDataResponse { try { foreach( config::getMongoDatabases() as $mongoDatabase ) { - $checks[ 'mongo:' . $mongoDatabase->database ] = self::pingMongo( $mongoDatabase->database ); + $checks[ 'mongo:' . $mongoDatabase->database ] = self::pingMongo( $mongoDatabase ); if( $checks[ 'mongo:' . $mongoDatabase->database ]!=='ok' ) { $healthy = false; } } } catch( \Throwable $e ) { - $checks[ 'config' ] = 'failed: ' . $e->getMessage(); + // Logged, not returned — see pingMongo(). A configException message carries the + // config file path and the name of the unresolved environment variable. + log::error( 'health', 'Readiness could not read configuration', [ 'exception' => $e ] ); + $checks[ 'config' ] = 'failed'; $healthy = false; } @@ -81,15 +99,33 @@ private static function version(): string { } - /** @return string 'ok', or a description of why not — never a thrown exception. */ - private static function pingMongo( string $databaseName ): string { + /** + * @return string 'ok' or 'failed' — never a thrown exception, and never the reason. + * + * The reason goes to the log. These routes are registered unauthenticated on every + * application, and a driver failure message names internal hostnames, ports and + * replica-set topology — a probe of a public /health/ready would otherwise map the + * inside of a Zone that is not reachable from outside it. + * + * Built as its own short-timeout client rather than through mdb, which takes its + * timeouts from the application's own clientParams. A configured clientParams still + * wins, so a database that genuinely needs longer can say so. + */ + private static function pingMongo( mongoDatabase $mongoDatabase ): string { try { - ( new \gcgov\framework\services\mongodb\tools\mdb( database: $databaseName ) )->db->command( [ 'ping' => 1 ] ); + $client = new \MongoDB\Client( $mongoDatabase->uri, array_merge( [ + 'serverSelectionTimeoutMS' => self::PROBE_TIMEOUT_MS, + 'connectTimeoutMS' => self::PROBE_TIMEOUT_MS, + 'socketTimeoutMS' => self::PROBE_TIMEOUT_MS, + ], $mongoDatabase->clientParams ) ); + $client->{$mongoDatabase->database}->command( [ 'ping' => 1 ] ); return 'ok'; } catch( \Throwable $e ) { - return 'failed: ' . $e->getMessage(); + log::warning( 'health', 'Readiness ping failed for database "' . $mongoDatabase->database . '"', [ 'exception' => $e ] ); + + return 'failed'; } } diff --git a/src/services/health/router.php b/src/services/health/router.php index 6782389..6eab433 100644 --- a/src/services/health/router.php +++ b/src/services/health/router.php @@ -10,26 +10,19 @@ * the application — every application gets it, because a deploy pipeline cannot gate on an * endpoint that some applications chose not to have. * - * These routes are merged FIRST, before Framework Services and before the application, so - * an application that happens to define its own /health keeps working: FastRoute rejects - * duplicate route definitions, so any collision surfaces at boot rather than in production. + * These routes are merged FIRST, before Framework Services and before the application, and + * router::getRoutes() drops a framework route the application also defines — so an + * application that happens to define its own /health keeps both that route and the rest of + * its surface. The override is logged. */ final class router implements \gcgov\framework\interfaces\router { - public static function _before(): void { - } - - - public static function _after(): void { - } - - /** * @return \gcgov\framework\models\route[] * @throws \gcgov\framework\exceptions\configException */ public function getRoutes(): array { - $basePath = rtrim( config::getBasePath(), '/' ); + $basePath = config::getRoutePrefix(); return [ new route( 'GET', $basePath . '/health', '\gcgov\framework\services\health\controllers\health', 'live', false, description: 'Liveness: the process is able to serve. No I/O.' ), diff --git a/src/services/userCrud/controllers/user.php b/src/services/userCrud/controllers/user.php index dcec0e6..7fcfb1c 100644 --- a/src/services/userCrud/controllers/user.php +++ b/src/services/userCrud/controllers/user.php @@ -157,6 +157,32 @@ public function save( string $_id ): controllerDataResponse { $userJSON = file_get_contents( 'php://input' ); try { $user = $userClassName::jsonDeserialize( $userJSON ); + } + catch( modelException $e ) { + throw new controllerException( $e->getMessage(), $e->getCode(), $e ); + } + + // The URL decides which document is written — not the body. This method used to + // ignore $_id entirely, so a caller holding User.Write could POST to their own + // /user/{_id} with a body naming any other account and overwrite it, roles + // included. The URL is also the only part a reverse proxy, an audit log or a + // route-level policy can see, so it has to be the part that binds. + // + // Compared as strings rather than coerced to a type: getId() is declared + // string|int|ObjectId, and an application may substitute its own user model. + if( $_id==='new' ) { + // A create must create. Without this, POST /user/new carrying an existing + // account's _id would overwrite that account — the same hole by another route. + unset( $user->_id ); + } + elseif( !isset( $user->_id ) || (string)$user->_id==='' ) { + throw new controllerException( 'The request body must carry the _id it is being saved to', 400 ); + } + elseif( (string)$user->_id!==$_id ) { + throw new controllerException( 'The _id in the request body does not match the _id in the url', 400 ); + } + + try { $userClassName::save( $user ); } catch( modelException $e ) { diff --git a/src/services/userCrud/router.php b/src/services/userCrud/router.php index bac6d09..7cfa3e2 100644 --- a/src/services/userCrud/router.php +++ b/src/services/userCrud/router.php @@ -18,7 +18,7 @@ class router implements \gcgov\framework\interfaces\router { private const CONTROLLER = '\gcgov\framework\services\userCrud\controllers\user'; public function getRoutes(): array { - $basePath = config::getBasePath(); + $basePath = config::getRoutePrefix(); return [ new route( 'GET', $basePath . '/user', self::CONTROLLER, 'getAll', true, [ 'User.Read' ] ), diff --git a/tests/Stubs/FakeUserModel.php b/tests/Stubs/FakeUserModel.php index ab93bfb..90344dc 100644 --- a/tests/Stubs/FakeUserModel.php +++ b/tests/Stubs/FakeUserModel.php @@ -64,6 +64,11 @@ public static function save( object &$object ): mixed { if ( !( $object instanceof self ) ) { throw new \InvalidArgumentException( 'Expected ' . self::class ); } + // Mirrors the real model: an insert mints the _id, so an object arriving without + // one is a create rather than an error. + if ( !isset( $object->_id ) || $object->_id === '' ) { + $object->_id = 'generated-' . count( self::$records ); + } self::$records[ $object->_id ] = $object; return $object; } diff --git a/tests/Support/seedsFrameworkConfig.php b/tests/Support/seedsFrameworkConfig.php new file mode 100644 index 0000000..984fd16 --- /dev/null +++ b/tests/Support/seedsFrameworkConfig.php @@ -0,0 +1,68 @@ +isInitialized() ? $property->getValue() : null; + self::$seedsFrameworkConfigCaptured = true; + } + + $config = new unifiedConfig(); + if( $mutate!==null ) { + $mutate( $config ); + } + $property->setValue( null, $config ); + + return $config; + } + + + /** Put back the configuration that was installed before this test ran. */ + protected function restoreConfig(): void { + if( !self::$seedsFrameworkConfigCaptured ) { + return; + } + + ( new \ReflectionProperty( config::class, 'unifiedConfig' ) )->setValue( null, self::$seedsFrameworkConfigOriginal ); + self::$seedsFrameworkConfigCaptured = false; + self::$seedsFrameworkConfigOriginal = null; + } + + + protected function tearDown(): void { + $this->restoreConfig(); + parent::tearDown(); + } + +} diff --git a/tests/Unit/Cli/CommandsTest.php b/tests/Unit/Cli/CommandsTest.php index 222bef7..95c440b 100644 --- a/tests/Unit/Cli/CommandsTest.php +++ b/tests/Unit/Cli/CommandsTest.php @@ -18,7 +18,6 @@ #[CoversClass(certGenerateAuthCommand::class)] #[CoversClass(completionPowershellCommand::class)] #[CoversClass(envCommand::class)] -#[CoversClass(setupCommand::class)] final class CommandsTest extends TestCase { private string $tempRootDir = ''; diff --git a/tests/Unit/Cli/EnvCommandTest.php b/tests/Unit/Cli/EnvCommandTest.php index b3e44e0..3db8664 100644 --- a/tests/Unit/Cli/EnvCommandTest.php +++ b/tests/Unit/Cli/EnvCommandTest.php @@ -50,4 +50,47 @@ public function testEmptyReferenceSetStillProducesAUsableFile(): void { self::assertStringEndsWith( "\n", $rendered ); } + + /** + * `gf env --init` promises in its own help text that it "leaves anything already in the + * file alone". It used to write the skeleton over the top of the file whenever --force + * was given — which is exactly what a developer does after the already-exists refusal — + * destroying every filled-in value and every variable config.json knows nothing about. + */ + public function testDeclaredNamesFindsExistingAssignments(): void { + $declared = self::declaredNames( <<<'ENV' + # a comment + APP_TYPE=local + MONGO_URI='mongodb://localhost' + export EXPORTED=1 + SPACED = value + # MONGO_URI_FILE=/run/secrets/mongo_uri + COMPOSE_PORT=8080 + ENV ); + + self::assertArrayHasKey( 'APP_TYPE', $declared ); + self::assertArrayHasKey( 'MONGO_URI', $declared ); + self::assertArrayHasKey( 'EXPORTED', $declared, 'an export prefix is still a declaration' ); + self::assertArrayHasKey( 'SPACED', $declared ); + self::assertArrayHasKey( 'COMPOSE_PORT', $declared, 'variables config.json never mentions still count' ); + } + + + public function testDeclaredNamesIgnoresCommentedHints(): void { + $declared = self::declaredNames( "# MONGO_URI_FILE=/run/secrets/mongo_uri +#APP_TYPE=local +" ); + + self::assertArrayNotHasKey( 'MONGO_URI_FILE', $declared, 'a commented hint is guidance, not a declaration' ); + self::assertArrayNotHasKey( 'APP_TYPE', $declared ); + } + + + /** Reflection, because the parser is a private detail of the command. */ + private static function declaredNames( string $env ): array { + $method = new \ReflectionMethod( envCommand::class, 'declaredNames' ); + + return $method->invoke( null, $env ); + } + } diff --git a/tests/Unit/Cli/InitCommandTest.php b/tests/Unit/Cli/InitCommandTest.php new file mode 100644 index 0000000..cd49f0b --- /dev/null +++ b/tests/Unit/Cli/InitCommandTest.php @@ -0,0 +1,102 @@ +encodedFragment( $rewritten, [ 'services', 'userCrud' ] ) ); + self::assertSame( '{}', $this->encodedFragment( $rewritten, [ 'services', 'documentation' ] ) ); + self::assertSame( '{}', $this->encodedFragment( $rewritten, [ 'appDictionary' ] ) ); + } + + + public function testRewrittenConfigStillHydratesItsServices(): void { + $original = '{"app":{"title":"","guid":""},"services":{"userCrud":{},"documentation":{}}}'; + + $rewritten = initCommand::applyIdentity( $original, 'Timesheet API', 'abc-123' )[ 'json' ]; + $config = \gcgov\framework\models\unifiedConfig::jsonDeserialize( $rewritten ); + + self::assertNotNull( $config->services->userCrud, 'an empty block must still enable the service' ); + self::assertNotNull( $config->services->documentation ); + } + + + public function testTitleAndGuidAreStamped(): void { + $identity = initCommand::applyIdentity( '{"app":{"title":"","guid":""}}', 'Timesheet API', 'abc-123' ); + $decoded = json_decode( $identity[ 'json' ], false ); + + self::assertSame( 'Timesheet API', $decoded->app->title ); + self::assertSame( 'abc-123', $decoded->app->guid ); + self::assertSame( 'Timesheet API', $identity[ 'title' ] ); + self::assertFalse( $identity[ 'guidKept' ] ); + } + + + /** The guid is the OAuth client_id: reminting it would invalidate every registered client. */ + public function testExistingGuidIsKeptWhenNoneIsSupplied(): void { + $identity = initCommand::applyIdentity( '{"app":{"title":"Old","guid":"keep-me"}}', 'New Title', '' ); + $decoded = json_decode( $identity[ 'json' ], false ); + + self::assertSame( 'keep-me', $decoded->app->guid ); + self::assertSame( 'New Title', $decoded->app->title ); + self::assertTrue( $identity[ 'guidKept' ] ); + } + + + public function testAGuidIsMintedWhenThereIsNone(): void { + $identity = initCommand::applyIdentity( '{"app":{"title":"","guid":""}}', 'API', '' ); + + self::assertNotSame( '', $identity[ 'guid' ] ); + self::assertFalse( $identity[ 'guidKept' ] ); + } + + + public function testUnrelatedSectionsAreLeftIntact(): void { + $original = '{"app":{"title":"","guid":""},"mongoDatabases":[{"default":true,"database":"appdb"}],"type":"local"}'; + + $decoded = json_decode( initCommand::applyIdentity( $original, 'API', 'g' )[ 'json' ], false ); + + self::assertSame( 'local', $decoded->type ); + self::assertSame( 'appdb', $decoded->mongoDatabases[ 0 ]->database ); + } + + + public function testNonObjectJsonIsRejected(): void { + $this->expectException( \gcgov\framework\cli\cliException::class ); + + initCommand::applyIdentity( '[1,2,3]', 'API', 'g' ); + } + + + /** @param string[] $path */ + private function encodedFragment( string $json, array $path ): string { + $value = json_decode( $json, false ); + foreach( $path as $key ) { + self::assertObjectHasProperty( $key, $value, 'expected ' . implode( '.', $path ) . ' to survive' ); + $value = $value->{$key}; + } + + return (string)json_encode( $value ); + } + +} diff --git a/tests/Unit/Cli/MigrateCommandTest.php b/tests/Unit/Cli/MigrateCommandTest.php index bf6de73..d236e30 100644 --- a/tests/Unit/Cli/MigrateCommandTest.php +++ b/tests/Unit/Cli/MigrateCommandTest.php @@ -6,6 +6,7 @@ use gcgov\framework\cli\commands\migrateCommand; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** @@ -187,4 +188,36 @@ public function testDeadFilesPresentFindsBothFixedPathsAndEnvironmentVariants(): rmdir( $root ); } + + /** + * The values gf migrate writes are the credentials lifted out of the v6 config. Written + * bare they were silently corrupted: symfony/dotenv interpolates $VAR in an unquoted + * value, treats a whitespace-preceded # as a comment, and rejects an embedded quote — + * and because every %env() reference is required, the damage surfaced later as a + * wrong-credential auth failure rather than a startup error. + */ + #[DataProvider('hostileEnvValues')] + public function testEnvValuesAreQuotedSoDotenvReadsThemBackUnchanged( string $value, string $description ): void { + $encoded = migrateCommand::encodeEnvValue( $value ); + $parsed = ( new \Symfony\Component\Dotenv\Dotenv() )->parse( 'SECRET=' . $encoded . "\n", '.env' ); + + self::assertSame( $value, $parsed[ 'SECRET' ], $description ); + } + + + /** @return array */ + public static function hostileEnvValues(): array { + return [ + 'plain' => [ 'simpleValue', 'an ordinary value is unaffected' ], + 'dollar signs' => [ 'pa$$w0rd', '$ would otherwise be interpolated away' ], + 'braced variable' => [ 'a${HOME}b', '${...} would otherwise be expanded' ], + 'hash' => [ 'mongodb://u:p#1@host/db', '# would otherwise start a comment' ], + 'space' => [ 'two words', 'an unquoted space is a format error' ], + 'single quote' => [ "it's", 'the quote that terminates the quoting we add' ], + 'double quote' => [ 'say "hi"', 'the other quote character' ], + 'backslash' => [ 'back\\slash', 'a backslash must not escape anything' ], + 'realistic uri' => [ 'mongodb+srv://user:P@ss#w0rd$x@cluster.example.net/db?retryWrites=true', 'the shape gf migrate actually writes' ], + ]; + } + } diff --git a/tests/Unit/LifecycleExceptionTest.php b/tests/Unit/LifecycleExceptionTest.php new file mode 100644 index 0000000..03aa085 --- /dev/null +++ b/tests/Unit/LifecycleExceptionTest.php @@ -0,0 +1,100 @@ +routingTryBlock( $source ); + + self::assertStringContainsString( 'catch( routeException $e )', $routingBlock ); + self::assertStringContainsString( 'catch( \Throwable $e )', $routingBlock, 'configException, BadRouteException and TypeError all reach here' ); + } + + + /** The lifecycle hooks after routing must still run once a config failure is caught. */ + public function testLifecycleContinuesAfterTheRoutingCatch(): void { + $source = (string)file_get_contents( __DIR__ . '/../../src/framework.php' ); + + $afterCatch = substr( $source, strpos( $source, 'catch( \Throwable $e )' ) ?: 0 ); + + self::assertStringContainsString( '\app\router::_after();', $afterCatch ); + self::assertStringContainsString( '\app\renderer::_before();', $afterCatch ); + self::assertStringContainsString( '\app\renderer::_after();', $afterCatch ); + self::assertStringContainsString( '\app\app::_after();', $afterCatch ); + } + + + /** + * A config failure message names route patterns, the config file path and unresolved + * environment variables, so it is logged rather than returned to the caller. + */ + public function testCaughtConfigFailureIsLoggedAndGenericised(): void { + $source = (string)file_get_contents( __DIR__ . '/../../src/framework.php' ); + + $routingBlock = $this->routingTryBlock( $source ); + + self::assertStringContainsString( 'log::critical', $routingBlock ); + self::assertStringNotContainsString( "routeException( $e->getMessage()", $routingBlock ); + self::assertMatchesRegularExpression( '/new routeException\(\s*\'[^\']+\',\s*500/', $routingBlock ); + } + + + private function routingTryBlock( string $source ): string { + $start = strpos( $source, 'new \gcgov\framework\router()' ); + $end = strpos( $source, '\app\router::_after();' ); + self::assertIsInt( $start ); + self::assertIsInt( $end ); + + return substr( $source, $start, $end - $start ); + } + +} diff --git a/tests/Unit/Models/AuthUserRolesTest.php b/tests/Unit/Models/AuthUserRolesTest.php new file mode 100644 index 0000000..72771fa --- /dev/null +++ b/tests/Unit/Models/AuthUserRolesTest.php @@ -0,0 +1,91 @@ +setFromJwtToken( [], [] ); + } + + + public function testStringRolesSurviveUnchanged(): void { + $authUser = authUser::getInstance()->setFromJwtToken( [], [ 'User.Read', 'User.Write' ] ); + + $this->assertSame( [ 'User.Read', 'User.Write' ], $authUser->roles ); + $this->assertTrue( $authUser->hasRole( 'User.Read' ) ); + $this->assertFalse( $authUser->hasRole( 'User.Delete' ) ); + } + + + #[DataProvider('nonStringScopeClaims')] + public function testNonStringScopeElementsGrantNothing( array $scope, string $description ): void { + $authUser = authUser::getInstance()->setFromJwtToken( [], $scope ); + + $this->assertFalse( $authUser->hasRole( 'User.Write' ), $description ); + $this->assertFalse( $authUser->hasRole( 'Anything.At.All' ), $description ); + foreach( $authUser->roles as $role ) { + $this->assertIsString( $role, 'roles must be string[] as documented' ); + } + } + + + /** @return array, string}> */ + public static function nonStringScopeClaims(): array { + return [ + 'boolean true' => [ [ true ], 'true == "User.Write" under a loose comparison' ], + 'integer one' => [ [ 1 ], 'a truthy int must not stand in for a role name' ], + 'float' => [ [ 1.0 ], 'a truthy float must not stand in for a role name' ], + 'nested array' => [ [ [ 'User.Write' ] ], 'a nested array is not a role' ], + 'object' => [ [ new \stdClass() ], 'an object is not a role' ], + 'null' => [ [ null ], 'null is not a role' ], + ]; + } + + + public function testMixedScopeKeepsOnlyTheRealRoles(): void { + $authUser = authUser::getInstance()->setFromJwtToken( [], [ 'User.Read', true, 'User.Write', 1 ] ); + + $this->assertSame( [ 'User.Read', 'User.Write' ], $authUser->roles ); + $this->assertTrue( $authUser->hasRole( 'User.Read' ) ); + $this->assertTrue( $authUser->hasRole( 'User.Write' ) ); + $this->assertFalse( $authUser->hasRole( 'User.Delete' ) ); + } + + + /** The same narrowing has to apply to roles arriving from the user model, not only the token. */ + public function testRolesFromTheUserModelAreNarrowedToo(): void { + require_once __DIR__ . '/../../Stubs/FakeUserModel.php'; + + $user = new \app\models\user(); + $user->_id = '507f1f77bcf86cd799439011'; + $user->name = 'Test User'; + $user->email = 'test@example.gov'; + // A roles array written out of band — a migration, an admin tool, a direct DB write. + $user->roles = [ 'User.Read', true ]; + + $authUser = authUser::getInstance()->setFromUser( $user ); + + $this->assertSame( [ 'User.Read' ], $authUser->roles ); + $this->assertFalse( $authUser->hasRole( 'User.Write' ) ); + } + +} diff --git a/tests/Unit/RouteOverrideTest.php b/tests/Unit/RouteOverrideTest.php new file mode 100644 index 0000000..89fc69b --- /dev/null +++ b/tests/Unit/RouteOverrideTest.php @@ -0,0 +1,82 @@ +routeKeys( new route( [ 'GET', 'CLI' ], '/cli/report', '\app\controllers\report', 'run' ) ); + + self::assertSame( [ 'GET /cli/report', 'CLI /cli/report' ], $keys ); + } + + + public function testRouteKeysNormaliseTheMethodCase(): void { + $keys = $this->routeKeys( new route( 'get', '/widget', '\app\controllers\widget', 'getAll' ) ); + + self::assertSame( [ 'GET /widget' ], $keys ); + } + + + /** + * A collision is per (method, pattern): an application POSTing to a path the framework + * only serves with GET is not a collision at all. + */ + public function testDifferentMethodsOnTheSamePathDoNotCollide(): void { + $framework = $this->routeKeys( new route( 'GET', '/api/health', '\gcgov\framework\services\health\controllers\health', 'live' ) ); + $app = $this->routeKeys( new route( 'POST', '/api/health', '\app\controllers\status', 'record' ) ); + + self::assertSame( [], array_intersect( $framework, $app ) ); + } + + + public function testSameMethodAndPathCollide(): void { + $framework = $this->routeKeys( new route( 'GET', '/api/health', '\gcgov\framework\services\health\controllers\health', 'live' ) ); + $app = $this->routeKeys( new route( 'GET', '/api/health', '\app\controllers\status', 'health' ) ); + + self::assertSame( [ 'GET /api/health' ], array_values( array_intersect( $framework, $app ) ) ); + } + + + /** + * The merged table must never contain a duplicate (method, pattern) — that is exactly + * what FastRoute rejects, and rejecting it takes the whole application down. + */ + public function testMergedRoutesContainNoDuplicateMethodAndPattern(): void { + $seen = []; + foreach( router::getMergedRoutes() as $mergedRoute ) { + foreach( $this->routeKeys( $mergedRoute ) as $key ) { + self::assertArrayNotHasKey( $key, $seen, 'duplicate route "' . $key . '" would make FastRoute reject every route' ); + $seen[ $key ] = true; + } + } + + self::assertNotSame( [], $seen ); + } + + + /** @return string[] */ + private function routeKeys( route $route ): array { + $method = new \ReflectionMethod( router::class, 'routeKeys' ); + + return $method->invoke( null, $route ); + } + +} diff --git a/tests/Unit/RoutePrefixTest.php b/tests/Unit/RoutePrefixTest.php new file mode 100644 index 0000000..dda1c92 --- /dev/null +++ b/tests/Unit/RoutePrefixTest.php @@ -0,0 +1,141 @@ +seedConfig( static fn( unifiedConfig $c ) => $c->basePath = '' ); + + $this->assertSame( '/', config::getBasePath(), 'getBasePath() keeps its "/" for the token audience' ); + $this->assertSame( '', config::getRoutePrefix(), 'a route prefix must contribute nothing at the domain root' ); + } + + + public function testRoutePrefixIsNormalisedBasePathOtherwise(): void { + $this->seedConfig( static fn( unifiedConfig $c ) => $c->basePath = 'api/v1' ); + + $this->assertSame( '/api/v1', config::getRoutePrefix() ); + } + + + #[DataProvider('untidyBasePaths')] + public function testRoutePrefixToleratesUntidyConfiguredValues( string $configured, string $expected ): void { + $this->seedConfig( static fn( unifiedConfig $c ) => $c->basePath = $configured ); + + $this->assertSame( $expected, config::getRoutePrefix() ); + } + + + /** @return array */ + public static function untidyBasePaths(): array { + return [ + 'leading slash' => [ '/api', '/api' ], + 'trailing slash' => [ 'api/', '/api' ], + 'both' => [ '/api/', '/api' ], + 'whitespace' => [ ' api ', '/api' ], + 'only a slash' => [ '/', '' ], + 'only spaces' => [ ' ', '' ], + ]; + } + + + /** + * The regression itself: no framework route may contain '//' at the domain root. + */ + public function testNoFrameworkRouteDoublesItsSlashesAtDomainRoot(): void { + $this->seedConfig( static function( unifiedConfig $c ): void { + $c->basePath = ''; + $c->rootUrl = 'https://example.gov'; + } ); + + $routers = [ + 'health' => new \gcgov\framework\services\health\router(), + 'userCrud' => new \gcgov\framework\services\userCrud\router(), + 'documentation' => new \gcgov\framework\services\documentation\router(), + 'auth' => new \gcgov\framework\services\auth\router( $this->oauthAuthConfig() ), + ]; + + foreach( $routers as $name => $router ) { + foreach( $router->getRoutes() as $route ) { + $this->assertStringStartsWith( '/', $route->route, $name . ' route must be rooted' ); + $this->assertStringNotContainsString( '//', $route->route, $name . ' route "' . $route->route . '" would never match a request' ); + } + } + } + + + public function testFrameworkRoutesCarryTheBasePathWhenThereIsOne(): void { + $this->seedConfig( static function( unifiedConfig $c ): void { + $c->basePath = 'api'; + $c->rootUrl = 'https://example.gov'; + } ); + + $routes = ( new \gcgov\framework\services\userCrud\router() )->getRoutes(); + + $this->assertSame( '/api/user', $routes[ 0 ]->route ); + } + + + /** + * getBaseUrl() has the same shape of defect: it is concatenated with '/auth/authorize' + * to build the advertised openid-configuration and the OAuth callback, so a trailing + * slash at the domain root produced 'https://host//auth/hybridauth/...' — which fails + * redirect-URI matching at the provider. + */ + public function testBaseUrlHasNoTrailingSlashAtDomainRoot(): void { + $this->seedConfig( static function( unifiedConfig $c ): void { + $c->basePath = ''; + $c->rootUrl = 'https://example.gov'; + } ); + + $this->assertSame( 'https://example.gov', config::getBaseUrl() ); + $this->assertStringNotContainsString( '//auth', config::getBaseUrl() . '/auth/authorize' ); + } + + + public function testBaseUrlStillJoinsRootUrlAndBasePath(): void { + $this->seedConfig( static function( unifiedConfig $c ): void { + $c->basePath = 'api/v1'; + $c->rootUrl = 'https://example.gov/'; + } ); + + $this->assertSame( 'https://example.gov/api/v1', config::getBaseUrl() ); + } + + + private function oauthAuthConfig(): \gcgov\framework\models\config\services\auth { + $auth = new \gcgov\framework\models\config\services\auth(); + $auth->provider = \gcgov\framework\models\config\services\auth::PROVIDER_OAUTH; + + return $auth; + } + +} diff --git a/tests/Unit/Services/Auth/GuardTest.php b/tests/Unit/Services/Auth/GuardTest.php new file mode 100644 index 0000000..6f81035 --- /dev/null +++ b/tests/Unit/Services/Auth/GuardTest.php @@ -0,0 +1,104 @@ + */ + private array $server = []; + /** @var array */ + private array $get = []; + + protected function setUp(): void { + $this->server = $_SERVER; + $this->get = $_GET; + unset( $_SERVER[ 'HTTP_AUTHORIZATION' ], $_GET[ 'fileAccessToken' ] ); + } + + + protected function tearDown(): void { + $_SERVER = $this->server; + $_GET = $this->get; + } + + + public function testMissingAuthorizationHeaderIs401(): void { + $this->expectException( routeException::class ); + $this->expectExceptionCode( 401 ); + $this->expectExceptionMessage( 'Missing Authorization' ); + + guard::authenticate( $this->routeHandler() ); + } + + + /** + * A token in a URL ends up in access logs, Referer headers and browser history, so a + * route has to opt in before one is accepted. Without the opt-in the query parameter is + * ignored entirely — not merely rejected later. + */ + public function testFileAccessTokenIsIgnoredOnRoutesThatDoNotOptIn(): void { + $_GET[ 'fileAccessToken' ] = 'a.b.c'; + + $this->expectException( routeException::class ); + $this->expectExceptionCode( 401 ); + $this->expectExceptionMessage( 'Missing Authorization' ); + + guard::authenticate( $this->routeHandler( allowShortLivedUrlTokens: false ) ); + } + + + /** + * With the opt-in the token is read, so the guard gets past readToken() and fails + * somewhere later instead — never with 'Missing Authorization'. + */ + public function testFileAccessTokenIsReadOnRoutesThatOptIn(): void { + $_GET[ 'fileAccessToken' ] = 'not-a-real-token'; + + try { + guard::authenticate( $this->routeHandler( allowShortLivedUrlTokens: true ) ); + $this->fail( 'an unparseable token must not authenticate' ); + } + catch( \Throwable $e ) { + $this->assertStringNotContainsString( 'Missing Authorization', $e->getMessage() ); + } + } + + + public function testAuthorizationHeaderIsPreferredOverTheQueryParameter(): void { + $_SERVER[ 'HTTP_AUTHORIZATION' ] = 'Bearer not-a-real-token'; + $_GET[ 'fileAccessToken' ] = 'also-not-real'; + + try { + guard::authenticate( $this->routeHandler( allowShortLivedUrlTokens: true ) ); + $this->fail( 'an unparseable token must not authenticate' ); + } + catch( \Throwable $e ) { + $this->assertStringNotContainsString( 'Missing Authorization', $e->getMessage() ); + } + } + + + private function routeHandler( bool $allowShortLivedUrlTokens = false ): routeHandler { + return new routeHandler( '\app\controllers\widget', 'getOne', true, [ 'Widget.Read' ], $allowShortLivedUrlTokens ); + } + +} diff --git a/tests/Unit/Services/Documentation/RouterTest.php b/tests/Unit/Services/Documentation/RouterTest.php index e5a96fc..25c0e46 100644 --- a/tests/Unit/Services/Documentation/RouterTest.php +++ b/tests/Unit/Services/Documentation/RouterTest.php @@ -4,6 +4,7 @@ namespace gcgov\framework\tests\Unit\Services\Documentation; +use gcgov\framework\tests\Support\seedsFrameworkConfig; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use gcgov\framework\services\documentation\router; @@ -12,15 +13,15 @@ #[CoversClass(router::class)] final class RouterTest extends TestCase { + use seedsFrameworkConfig; + /** * Seed the base path explicitly rather than relying on whatever configuration a * previously-run test happened to leave behind. Multi-segment on purpose: it catches * a router that assumes the base path is one path element. */ protected function setUp(): void { - $config = new \gcgov\framework\models\unifiedConfig(); - $config->basePath = 'api/v1'; - ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->setValue( null, $config ); + $this->seedConfig( static fn( \gcgov\framework\models\unifiedConfig $c ) => $c->basePath = 'api/v1' ); } diff --git a/tests/Unit/Services/Health/HealthControllerTest.php b/tests/Unit/Services/Health/HealthControllerTest.php new file mode 100644 index 0000000..8719cad --- /dev/null +++ b/tests/Unit/Services/Health/HealthControllerTest.php @@ -0,0 +1,109 @@ +seedConfig(); + + $response = ( new health() )->live(); + $data = $response->getData(); + + $this->assertSame( 200, $response->getHttpStatus() ); + $this->assertSame( 'ok', $data[ 'status' ] ); + $this->assertArrayNotHasKey( 'checks', $data, 'liveness must not touch dependencies' ); + } + + + public function testVersionFallsBackWhenAppVersionIsUnset(): void { + $this->seedConfig(); + + $this->assertSame( 'unknown', ( new health() )->live()->getData()[ 'version' ] ); + } + + + public function testVersionReportsTheDeployedRelease(): void { + $this->seedConfig(); + putenv( 'APP_VERSION=1.4.2' ); + + $this->assertSame( '1.4.2', ( new health() )->live()->getData()[ 'version' ] ); + } + + + public function testReadinessIsOkWithNoConfiguredDatabases(): void { + $this->seedConfig(); + + $response = ( new health() )->ready(); + + $this->assertSame( 200, $response->getHttpStatus() ); + $this->assertSame( 'ok', $response->getData()[ 'status' ] ); + $this->assertSame( [], $response->getData()[ 'checks' ] ); + } + + + /** + * An unreachable database must produce 503 — and must not put the driver's message, + * which names internal hostnames, ports and replica-set topology, into the body of an + * unauthenticated endpoint. + */ + public function testUnreachableDatabaseIs503AndDisclosesNothing(): void { + $this->seedConfig( static function( unifiedConfig $c ): void { + $database = new mongoDatabase(); + $database->default = true; + $database->database = 'appdb'; + // Unroutable by RFC 5737, so this fails without depending on a live server. + $database->uri = 'mongodb://192.0.2.1:27017'; + $database->clientParams = [ 'serverSelectionTimeoutMS' => 50, 'connectTimeoutMS' => 50 ]; + $c->mongoDatabases = [ $database ]; + } ); + + $response = ( new health() )->ready(); + $data = $response->getData(); + + $this->assertSame( 503, $response->getHttpStatus() ); + $this->assertSame( 'unavailable', $data[ 'status' ] ); + $this->assertSame( 'failed', $data[ 'checks' ][ 'mongo:appdb' ] ); + + $serialized = json_encode( $data ); + $this->assertIsString( $serialized ); + $this->assertStringNotContainsString( '192.0.2.1', $serialized, 'the probe must not publish the database host' ); + $this->assertStringNotContainsString( '27017', $serialized, 'the probe must not publish the database port' ); + } + + + /** + * The probe carries its own short timeouts so an unreachable database cannot park a + * worker for the driver's 30s default — with a probe every few seconds that exhausts + * the pool and 502s real traffic. + */ + public function testProbeTimeoutIsSizedToAProbeInterval(): void { + $timeout = ( new \ReflectionClassConstant( health::class, 'PROBE_TIMEOUT_MS' ) )->getValue(); + + $this->assertLessThanOrEqual( 5000, $timeout, 'a readiness probe must fail fast' ); + $this->assertGreaterThan( 0, $timeout ); + } + +} diff --git a/tests/Unit/Services/Health/RouterTest.php b/tests/Unit/Services/Health/RouterTest.php new file mode 100644 index 0000000..6fa3f54 --- /dev/null +++ b/tests/Unit/Services/Health/RouterTest.php @@ -0,0 +1,80 @@ +assertContains( + \gcgov\framework\interfaces\router::class, + class_implements( router::class ) ?: [] + ); + } + + + /** + * The router interface deliberately declares no lifecycle hooks — only \app\router's + * are ever invoked — so a service router must not carry hooks that will never fire. + */ + public function testRouterDeclaresNoLifecycleHooks(): void { + $this->assertFalse( method_exists( router::class, '_before' ) ); + $this->assertFalse( method_exists( router::class, '_after' ) ); + } + + + public function testBothProbesAreRegisteredUnauthenticated(): void { + $this->seedConfig( static fn( unifiedConfig $c ) => $c->basePath = 'api' ); + + $routes = ( new router() )->getRoutes(); + + $this->assertCount( 2, $routes ); + foreach( $routes as $route ) { + $this->assertInstanceOf( route::class, $route ); + $this->assertSame( 'GET', $route->httpMethod ); + $this->assertFalse( $route->authentication, 'a probe an orchestrator calls cannot require a token' ); + $this->assertSame( [], $route->requiredRoles ); + $this->assertNotSame( '', $route->description, 'probes appear in gf cli:list and shell completion' ); + } + } + + + public function testLivenessAndReadinessArePathsUnderTheBasePath(): void { + $this->seedConfig( static fn( unifiedConfig $c ) => $c->basePath = 'api' ); + + $routes = ( new router() )->getRoutes(); + + $this->assertSame( '/api/health', $routes[ 0 ]->route ); + $this->assertSame( 'live', $routes[ 0 ]->method ); + $this->assertSame( '/api/health/ready', $routes[ 1 ]->route ); + $this->assertSame( 'ready', $routes[ 1 ]->method ); + } + + + public function testProbePathsAreSingleSlashedAtDomainRoot(): void { + $this->seedConfig( static fn( unifiedConfig $c ) => $c->basePath = '' ); + + $routes = ( new router() )->getRoutes(); + + $this->assertSame( '/health', $routes[ 0 ]->route ); + $this->assertSame( '/health/ready', $routes[ 1 ]->route ); + } + + + public function testAuthenticationAllowsTheProbes(): void { + $routeHandler = $this->createStub( \gcgov\framework\models\routeHandler::class ); + $this->assertTrue( ( new router() )->authentication( $routeHandler ) ); + } + +} diff --git a/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php b/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php index e309dbf..87428b1 100644 --- a/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php +++ b/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php @@ -99,6 +99,55 @@ public function testSaveDeserializesPayloadAndPersists(): void { } ); } + /** + * The URL decides which document is written. This method used to ignore its $_id + * entirely, so a caller holding User.Write could POST to their own /user/{_id} with a + * body naming any other account and overwrite it — roles included. + */ + public function testSaveRejectsABodyTargetingADifferentUser(): void { + $victim = new FakeUser(); + $victim->_id = 'victim'; + $victim->name = 'Victim'; + FakeUser::$records[ 'victim' ] = $victim; + + $this->withPhpInput( json_encode( [ '_id' => 'victim', 'name' => 'Owned' ] ), function() { + try { + ( new user() )->save( 'attacker' ); + $this->fail( 'the body must not be able to retarget the write' ); + } + catch( controllerException $e ) { + $this->assertSame( 400, $e->getCode() ); + } + } ); + + $this->assertSame( 'Victim', FakeUser::$records[ 'victim' ]->name, 'the other account must be untouched' ); + } + + + public function testSaveRejectsABodyWithNoId(): void { + $this->withPhpInput( json_encode( [ 'name' => 'Nameless' ] ), function() { + $this->expectException( controllerException::class ); + $this->expectExceptionCode( 400 ); + ( new user() )->save( 'u1' ); + } ); + } + + + /** POST /user/new must create, not overwrite whatever _id the body happens to carry. */ + public function testSaveToNewDoesNotOverwriteAnExistingAccount(): void { + $victim = new FakeUser(); + $victim->_id = 'victim'; + $victim->name = 'Victim'; + FakeUser::$records[ 'victim' ] = $victim; + + $this->withPhpInput( json_encode( [ '_id' => 'victim', 'name' => 'Owned' ] ), function() { + ( new user() )->save( 'new' ); + } ); + + $this->assertSame( 'Victim', FakeUser::$records[ 'victim' ]->name, 'the named account must be untouched' ); + } + + public function testSaveWrapsModelExceptionInControllerException(): void { FakeUser::$nextException = new modelException( 'validation failure', 422 ); diff --git a/tests/Unit/Services/UserCrud/RouterTest.php b/tests/Unit/Services/UserCrud/RouterTest.php index 736b3e9..b5c34a3 100644 --- a/tests/Unit/Services/UserCrud/RouterTest.php +++ b/tests/Unit/Services/UserCrud/RouterTest.php @@ -4,6 +4,7 @@ namespace gcgov\framework\tests\Unit\Services\UserCrud; +use gcgov\framework\tests\Support\seedsFrameworkConfig; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use gcgov\framework\services\userCrud\router; @@ -12,14 +13,14 @@ #[CoversClass(router::class)] final class RouterTest extends TestCase { + use seedsFrameworkConfig; + /** * Seed the base path explicitly rather than relying on whatever configuration a * previously-run test happened to leave behind. */ protected function setUp(): void { - $config = new \gcgov\framework\models\unifiedConfig(); - $config->basePath = 'api'; - ( new \ReflectionProperty( \gcgov\framework\config::class, 'unifiedConfig' ) )->setValue( null, $config ); + $this->seedConfig( static fn( \gcgov\framework\models\unifiedConfig $c ) => $c->basePath = 'api' ); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3a7e88d..afc1343 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -11,6 +11,9 @@ require __DIR__ . '/Shims/MongoDBShims.php'; } +// Shared test helpers. tests/ is not PSR-4 autoloaded, so they are required here. +require __DIR__ . '/Support/seedsFrameworkConfig.php'; + // Several framework call sites reflect on \app\app to derive directories. // Stub the class so tests that touch config::getAppDir() can boot. if ( !class_exists( '\app\app' ) ) { From 72758f1ebbba6792c7c59e8ecd152bed9b12f3f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 00:54:46 +0000 Subject: [PATCH 20/30] Close the remaining review findings: MFA request validation, docs scan, CLI env, deprecation shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verifyMfaSecret passed a nullable, client-supplied userMultifactorId to a non-nullable parameter, so a body omitting it produced a TypeError and an opaque 500 where the deserialization guard was meant to yield 400. Both MFA endpoints now build the caller's ObjectId through one helper that answers 401 rather than InvalidArgumentException when the token carries no data.userId claim. - The documentation service excluded {root}/vendor while adding the framework's own src to the scan list. Under a normal Composer install the framework lives inside that tree, so the exclusion was a prefix match over everything just added: the Framework Service annotations the change exists to publish were dropped again, and only a symlinked dev checkout behaved as documented. The scan list is explicit, so the exclusion is removed — and a service that is not enabled is now excluded instead, since documenting it advertises endpoints that 404. - gf cli chose the PHP interpreter before anything loaded .env, so a GF_PHP set there was invisible and the route ran on whatever PHP was on PATH. - The deprecated appConfig shim promised v6 call sites "including ones that serialize the object" would keep working, but no longer extended the class that provided serialization. It now implements JsonSerializable, and the docblock states plainly what a v6 appConfig could do that a view onto loaded configuration cannot. getAppConfig() is memoized again, keyed on the unifiedConfig it views so replacing the configuration cannot leave a stale view behind. - gf migrate now names the two router contract changes nothing else surfaces: \app\router must implement appRouter, and the service-auth opt-out is an interface rather than a duck-typed method, so a leftover v6 method is silently ignored and self-authenticated routes start returning 401. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R2sLagem1ERvXQGwcoBgA1 --- src/cli/commands/cliCommand.php | 7 ++++ src/cli/commands/migrateCommand.php | 9 ++++++ src/config.php | 24 ++++++++++++-- src/models/appConfig.php | 32 +++++++++++++++---- .../auth/providers/oauth/controllers/auth.php | 31 ++++++++++++++++-- .../controllers/documentation.php | 26 +++++++++++++-- 6 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/cli/commands/cliCommand.php b/src/cli/commands/cliCommand.php index 68a5c5d..2320daf 100644 --- a/src/cli/commands/cliCommand.php +++ b/src/cli/commands/cliCommand.php @@ -45,6 +45,13 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $context = appContext::require(); $context->assertAppLoadable(); + // Load .env before choosing the interpreter. findPhpBinary() falls back to + // getenv('GF_PHP'), and dotEnvLoader enables usePutenv() precisely so getenv() sees + // .env values — but nothing on this path had loaded it, so a GF_PHP set in {root}/.env + // (which the loader's own docblock offers as the example) was invisible and the route + // silently ran on whatever PHP happened to be on PATH. + \gcgov\framework\services\environment\dotEnvLoader::loadOnce( $context->rootDir ); + $commandLine = array_merge( phpProcess::findPhpBinary( $input->getOption( 'php' ) ), phpProcess::requiredIniFlags() ); if( $input->getOption( 'debug' ) ) { diff --git a/src/cli/commands/migrateCommand.php b/src/cli/commands/migrateCommand.php index f3c51e7..258fb96 100644 --- a/src/cli/commands/migrateCommand.php +++ b/src/cli/commands/migrateCommand.php @@ -238,6 +238,15 @@ protected function execute( InputInterface $input, OutputInterface $output ): in } $io->text( 'Still to do by hand: the Dockerfile and compose entry, the Zone this application belongs in, and moving its secrets into the ops repository.' ); + $io->section( '\app\router' ); + $io->text( 'Two router contracts changed in a way nothing else will tell you about:' ); + $io->text( ' · It must now implement \gcgov\framework\interfaces\appRouter (which adds providesAuthentication()).' ); + $io->text( ' Implementing only \gcgov\framework\interfaces\router is a TypeError on the first request.' ); + $io->text( ' · The service-auth opt-out is now the \gcgov\framework\interfaces\router\skipsServiceAuthentication' ); + $io->text( ' interface, not a duck-typed method. A getRunFrameworkServiceRouteAuthentication() left over from v6' ); + $io->text( ' is silently ignored, so routes the application authenticates itself start returning 401 — and the' ); + $io->text( ' method now takes a $routeHandler.' ); + return Command::SUCCESS; } diff --git a/src/config.php b/src/config.php index 43c66b9..03cf46e 100644 --- a/src/config.php +++ b/src/config.php @@ -29,6 +29,12 @@ final class config { private static string $rootDir = ''; + /** @deprecated v7 — memoized view backing the deprecated getAppConfig() pass-through. */ + private static ?\gcgov\framework\models\appConfig $appConfig = null; + + /** The unifiedConfig $appConfig is a view onto, so the memo cannot go stale. */ + private static ?unifiedConfig $appConfigSource = null; + private static string $appDir = ''; private static string $modelsDir = ''; @@ -185,13 +191,25 @@ public static function getEnvironmentConfig(): unifiedConfig { /** * @deprecated v7 — use the flattened static accessors instead: `config::getAppConfig()->settings` becomes * `config::getSettings()`, `->app` becomes `config::getApp()`, `->email` becomes `config::getEmail()`. - * Returns a v6-shaped VIEW (app/email/settings only) over the unified config, so existing - * call sites — including ones that serialize the object — keep their exact v6 behavior. + * Returns a v6-shaped VIEW (app/email/settings only) over the unified config. Reading the + * sections and serializing the object both work; see the class docblock for what a v6 + * appConfig could do that this cannot. * @throws \gcgov\framework\exceptions\configException */ #[\JetBrains\PhpStorm\Deprecated( reason: 'v7: config values are exposed directly on config', replacement: '\gcgov\framework\config' )] public static function getAppConfig(): \gcgov\framework\models\appConfig { - return new \gcgov\framework\models\appConfig( self::unifiedConfig() ); + // Memoized, as it was in v6. A fresh view per call is not only an allocation on a + // path that may run per document — it also breaks identity, so a v6 call site that + // compared or cached the object saw a different one every time. Keyed on the + // unifiedConfig it views, so replacing the configuration (as the tests do) does not + // leave a view onto the old sections behind. + $unifiedConfig = self::unifiedConfig(); + if( self::$appConfig===null || self::$appConfigSource!==$unifiedConfig ) { + self::$appConfigSource = $unifiedConfig; + self::$appConfig = new \gcgov\framework\models\appConfig( $unifiedConfig ); + } + + return self::$appConfig; } diff --git a/src/models/appConfig.php b/src/models/appConfig.php index 378c152..d38b887 100644 --- a/src/models/appConfig.php +++ b/src/models/appConfig.php @@ -7,14 +7,20 @@ use gcgov\framework\models\config\app\settings; /** - * @deprecated v7 — a read-only VIEW over unifiedConfig limited to the former - * app.json sections, returned by the deprecated config::getAppConfig() - * pass-through so v6 call sites (including ones that serialize the - * object) see exactly the v6 shape and nothing more. New code reads - * config::getApp() / getEmail() / getSettings() directly. + * @deprecated v7 — a read-only VIEW over unifiedConfig limited to the former app.json + * sections, returned by the deprecated config::getAppConfig() pass-through so + * v6 call sites see the v6 shape. New code reads config::getApp() / + * getEmail() / getSettings() directly. + * + * Not a full v6 substitute, and deliberately so: v6's appConfig extended + * \andrewsauder\jsonDeserialize\jsonDeserialize, whose static + * ::jsonDeserialize() and no-argument constructor make no sense for a view + * onto configuration that is already loaded. Reading the three sections and + * json_encode()ing the object both work; `new appConfig()` with no argument + * and `appConfig::jsonDeserialize()` do not. */ #[\JetBrains\PhpStorm\Deprecated( reason: 'v7: read config::getApp()/getEmail()/getSettings() directly', replacement: '\gcgov\framework\config' )] -class appConfig { +class appConfig implements \JsonSerializable { public app $app; @@ -31,4 +37,18 @@ public function __construct( unifiedConfig $unifiedConfig ) { $this->settings = $unifiedConfig->settings; } + + /** + * v6 call sites serialize this object; the class it used to extend supplied that. + * + * @return array{app: app, email: email, settings: settings} + */ + public function jsonSerialize(): array { + return [ + 'app' => $this->app, + 'email' => $this->email, + 'settings' => $this->settings, + ]; + } + } diff --git a/src/services/auth/providers/oauth/controllers/auth.php b/src/services/auth/providers/oauth/controllers/auth.php index 4e4c73d..30ba65d 100644 --- a/src/services/auth/providers/oauth/controllers/auth.php +++ b/src/services/auth/providers/oauth/controllers/auth.php @@ -526,6 +526,25 @@ public function oauthHybridAuth( string $provider = '' ): controllerDataResponse } + /** + * The authenticated user's id as an ObjectId. + * + * `new ObjectId( $authUser->userId )` threw InvalidArgumentException when the token + * carried no data.userId claim, which surfaced as an opaque 500 rather than the 401 it + * actually is. + * + * @throws \gcgov\framework\exceptions\controllerException + */ + private static function authUserObjectId( \gcgov\framework\models\authUser $authUser ): \MongoDB\BSON\ObjectId { + try { + return new \MongoDB\BSON\ObjectId( $authUser->userId ); + } + catch( \Throwable $e ) { + throw new controllerException( 'The access token does not identify a user', 401, $e ); + } + } + + /** * A 302 as a controllerResponse. * @@ -624,9 +643,17 @@ public function verifyMfaSecret(): controllerDataResponse { throw new controllerException( 'Provided data is not in a valid format', 400, $e ); } + // Validated rather than passed straight through: userMultifactorId is nullable and + // client-supplied, while multifactor::verifyMfaSecret() declares it non-nullable, so + // a body omitting the field produced a TypeError and an opaque 500 where the + // deserialization guard above was meant to yield a 400. + if( $verifyMfaSecretRequest->userMultifactorId===null ) { + throw new controllerException( 'userMultifactorId is required', 400 ); + } + $authUser = \gcgov\framework\services\request::getAuthUser(); - $response = multifactor::verifyMfaSecret( new \MongoDB\BSON\ObjectId( $authUser->userId ), $verifyMfaSecretRequest->userMultifactorId, $verifyMfaSecretRequest->code ); + multifactor::verifyMfaSecret( self::authUserObjectId( $authUser ), $verifyMfaSecretRequest->userMultifactorId, $verifyMfaSecretRequest->code ); $userClassName = \gcgov\framework\services\request::getUserClassFqdn(); return new controllerDataResponse( $this->createAccessTokenResponse( $userClassName::getOne($authUser->userId) ) ); @@ -647,7 +674,7 @@ public function verifyMfaCode(): controllerDataResponse { $authUser = \gcgov\framework\services\request::getAuthUser(); - $valid = multifactor::isMfaCodeCorrect( new \MongoDB\BSON\ObjectId( $authUser->userId ), $verifyMfaCodeRequest->code ); + $valid = multifactor::isMfaCodeCorrect( self::authUserObjectId( $authUser ), $verifyMfaCodeRequest->code ); if(!$valid) { throw new controllerException('Invalid code', 500); } diff --git a/src/services/documentation/controllers/documentation.php b/src/services/documentation/controllers/documentation.php index 2098fcc..e4a8baf 100644 --- a/src/services/documentation/controllers/documentation.php +++ b/src/services/documentation/controllers/documentation.php @@ -68,15 +68,35 @@ private function getScanDirectories(): array { * framework's must not also appear in the document as a second schema of the same name. * * @return string[] + * @throws \gcgov\framework\exceptions\configException */ private function getExcludeDirectoriesFiles(): array { $frameworkSrc = dirname( __DIR__, 3 ); $exclusions = []; - $vendor = config::getRootDir() . '/vendor'; - if( file_exists( $vendor ) ) { - $exclusions[] = $vendor; + // No blanket {root}/vendor exclusion. Under a normal Composer install the framework + // itself lives at {root}/vendor/gcgov/framework, so excluding the whole tree was a + // prefix match over every framework path just added to the scan list — the + // Framework Service annotations this class exists to publish were added and then + // immediately dropped again, and only a symlinked development checkout behaved as + // documented. The scan list is explicit (see getScanDirectories()), so nothing under + // vendor is reached except the framework, which is what we want reached. + + // A Framework Service that is not enabled contributes no routes, so documenting its + // annotations would advertise endpoints that 404. + $services = config::getServices(); + if( $services->auth===null ) { + $exclusions[] = $frameworkSrc . '/services/auth'; + } + else { + $exclusions[] = $frameworkSrc . '/services/auth/providers/' . ( $services->auth->isOauth() ? 'msFront' : 'oauth' ); + } + if( $services->userCrud===null ) { + $exclusions[] = $frameworkSrc . '/services/userCrud'; + } + if( $services->documentation===null ) { + $exclusions[] = $frameworkSrc . '/services/documentation'; } if( class_exists( '\app\models\user' ) ) { From e7a44cddf3da9ad9c64b014f333c26352d59db68 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 01:41:19 +0000 Subject: [PATCH 21/30] Enforce requiredRoles in the router, not in the optional auth service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requiredRoles is declared on \gcgov\framework\models\route and carried into routeHandler — framework-level models, present on every route of every application — but the only code that read it was guard::authenticate(), inside the optional auth service. Two supported configurations therefore declared roles that nothing checked, while looking protected in the route table, in `gf cli:list` and in review: · No services.auth block, with \app\router::providesAuthentication() returning true. The boot check is satisfied and userCrud::authentication() returns true unconditionally, so the framework's own /user routes ran with User.Read and User.Write checked by nobody — roles the application author never wrote and had no reason to know about. · Any route where skipsServiceAuthentication skipped the service guards, taking the one role check in the codebase with them. assertAuthenticationIsProvided() cannot see either case: it asks whether *something* authenticates, never whether anything enforces roles. Enforcement moves to router::assertRequiredRoles(), called after the app router and every service router have run, so it holds however the caller was authenticated. The guard keeps the part only it can do — validate the token and establish authUser — and its copy of the loop is deleted rather than duplicated, since two enforcement paths are what let the answers diverge. For the auth-service path the observable behaviour is unchanged: same check, same message, same 403, one step later in the chain. Fails closed on the case the boot check cannot detect: a route that declares roles when nothing established a user is refused with a 401, because an \app\router::authentication() that returns true is indistinguishable from one that verified something. The client gets a generic message; the log gets the route and the remedy. This is a behaviour change for an application that authenticates its own routes without recording the caller, so both interfaces that can reach it now say so explicitly: providesAuthentication() and skipsServiceAuthentication both spell out that the authenticator must populate the request-scoped authUser via request::getAuthUser()->setFromUser(), and that opting out of the service guards never opts out of requiredRoles. Also corrects the contracts that documented the gap: route.php's @param said roles must be implemented in \app\router::authentication(), CLAUDE.md's guard flow credited the auth service, and userCrud's docblock argued "installed but unguarded is no longer reachable" from the boot check alone — now true for both halves rather than one. A route declaring requiredRoles with authentication:false is contradictory: it returns before the guard chain, so its roles can never be checked. That is warned at boot rather than refused — the declaration was already inert, so failing an application's boot over it would break something that works rather than protect anything. Tests: the 401-with-no-user case is the regression this exists for and previously passed silently. Also covers the empty-roles route (which must not start requiring authentication), exact and subset role holdings, and non-string scope elements through the new call path. GuardTest asserts the guard has not kept a second copy of the check, so the move is provably a move. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R2sLagem1ERvXQGwcoBgA1 --- CLAUDE.md | 14 +- src/interfaces/appRouter.php | 7 + .../router/skipsServiceAuthentication.php | 7 + src/models/route.php | 2 +- src/router.php | 62 ++++++++ src/services/auth/guard.php | 22 +-- src/services/userCrud/router.php | 8 +- tests/Unit/RequiredRolesTest.php | 137 ++++++++++++++++++ .../RouterAuthenticationGuaranteeTest.php | 17 +++ tests/Unit/Services/Auth/GuardTest.php | 31 +++- 10 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 tests/Unit/RequiredRolesTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 720e024..875bf3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,8 +163,15 @@ For a matched route with `authentication === true`: 2. Then **each enabled service router's** `authentication()` runs — unless `\app\router` implements `\gcgov\framework\interfaces\router\skipsServiceAuthentication` and returns `false` for that route. 3. The auth service validates the JWT from the `Authorization: Bearer …` header - (or `?fileAccessToken=` when `allowShortLivedUrlTokens`), populate the request-scoped `authUser`, and - enforce `requiredRoles` (missing header → 401, missing role → 403). + (or `?fileAccessToken=` when `allowShortLivedUrlTokens`) and populates the request-scoped + `authUser` (missing header → 401). +4. Finally `framework\router` itself enforces the route's `requiredRoles` against that + `authUser` — **after** the whole chain, so it holds however the caller was authenticated, + including on routes that opted out at step 2 (missing role → 403; no user established at + all → 401). Roles are declared on `route`, so the framework enforces them; `\app\router` + does not have to implement anything for them to take effect, but a router that + authenticates its own routes must record the caller with + `request::getAuthUser()->setFromUser($user)` or those routes are refused. Routes with `authentication === false` skip all of this. A `routeException` thrown anywhere in this flow becomes the HTTP error response. @@ -475,6 +482,9 @@ List routes with `gf cli:list`; debug with `gf cli /path --debug`. - There's no auth without `services.auth`; it registers a **global guard** over every `authentication:true` route. The framework refuses to boot if authenticated routes exist without it (and without `\app\router::providesAuthentication()`), rather than serving them unprotected. +- `requiredRoles` is enforced by `framework\router`, not by the auth service — so it applies to + self-authenticated routes and to `skipsServiceAuthentication` opt-outs too. Whatever authenticates + must populate `authUser`, or a role-gated route 401s. - Set `logging.lifecycle=true` in `config.json` to trace the entire pipeline when debugging routing/auth. - Every `%env()` reference is required — there is no default and `FOO=` counts as unset. `gf env` says which one is missing. diff --git a/src/interfaces/appRouter.php b/src/interfaces/appRouter.php index 530bdcd..8c0c91b 100644 --- a/src/interfaces/appRouter.php +++ b/src/interfaces/appRouter.php @@ -22,6 +22,13 @@ interface appRouter extends router, lifecycle\before, lifecycle\after { * Return true only if this router's own authentication() genuinely establishes and * verifies the caller's identity. Returning true without doing so re-opens exactly * the hole the check exists to close. + * + * "Establishes" is literal: populate the request-scoped user with + * `\gcgov\framework\services\request::getAuthUser()->setFromUser( $user )`. The + * framework enforces each route's requiredRoles against it after the guard chain + * ({@see \gcgov\framework\router::assertRequiredRoles()}), so a router that verifies + * identity without recording it leaves those routes refused with a 401 rather than + * silently unchecked. */ public function providesAuthentication() : bool; } diff --git a/src/interfaces/router/skipsServiceAuthentication.php b/src/interfaces/router/skipsServiceAuthentication.php index 14df776..ff7990c 100644 --- a/src/interfaces/router/skipsServiceAuthentication.php +++ b/src/interfaces/router/skipsServiceAuthentication.php @@ -10,6 +10,13 @@ * and the enabled authentication service must not also run for them. The application's * own authentication() still runs; only the service guards are skipped. * + * This opts out of AUTHENTICATION, never of authorization. A route's requiredRoles are + * enforced by {@see \gcgov\framework\router::assertRequiredRoles()} after the whole guard + * chain, so they hold for opted-out routes too. The practical consequence: a route that + * declares requiredRoles and opts out must establish the caller itself, with + * `\gcgov\framework\services\request::getAuthUser()->setFromUser( $user )`. Without that + * there is no user to check the roles against and the request is refused with a 401. + * * This was previously duck-typed — the framework looked for the method with * method_exists() and no interface declared it, so neither PHPStan nor an IDE could see * it and a typo in the name silently disabled the opt-out. diff --git a/src/models/route.php b/src/models/route.php index 39d124c..26a7913 100644 --- a/src/models/route.php +++ b/src/models/route.php @@ -45,7 +45,7 @@ class route { * @param string $class Fully qualified class name to initialize when this url is triggered. Ie: '\app\controllers\widget' * @param string $method Method inside the $class to call when this URL is triggered. Ie: 'getOne'. Method must have paramters that match the route pattern placeholders. In this example, getOne method must accept one parameter. Ie: getOne( string $_id ) * @param bool $authentication Whether authentication is required to access this route. Functionality to respond to this must be implemented in \app\router\authentication() - * @param array $requiredRoles If authentication is required, the roles required of the use to access this route. Functionality to respond to this must be implemented in \app\router\authentication(). If not using roles for a route or at all, just skip including this parameter. + * @param array $requiredRoles If authentication is required, the roles the user must hold to reach this route. Enforced by the framework in router::assertRequiredRoles() once the guard chain has run — not by \app\router::authentication(), which no longer has to implement anything for roles to take effect. If not using roles for a route or at all, just skip including this parameter. * @param bool $allowShortLivedUrlTokens Authentication token can be provided in url * @param string $description Optional human readable description of the route; surfaced by `gf cli:list` and shell completion */ diff --git a/src/router.php b/src/router.php index dcb6e4e..559b710 100644 --- a/src/router.php +++ b/src/router.php @@ -145,6 +145,8 @@ public function route(): \gcgov\framework\models\routeHandler { } } + self::assertRequiredRoles( $routeHandler ); + if(config::getLogging()->lifecycle) { log::debug( 'Framework Lifecycle', '-Router- return route handler to framework\framework' ); } @@ -158,6 +160,55 @@ public function route(): \gcgov\framework\models\routeHandler { } + /** + * Enforce the route's declared requiredRoles. + * + * requiredRoles is declared on route and carried into routeHandler — framework-level + * models present on every route of every application — but the only code that ever read + * it was the guard inside the OPTIONAL auth service. Two supported configurations + * therefore declared roles that nothing checked, while looking protected in the route + * table, in `gf cli:list` and in review: + * + * · no services.auth block, with \app\router::providesAuthentication() returning true. + * The boot check is satisfied, and userCrud::authentication() returns true + * unconditionally — so the framework's own /user routes ran with User.Read and + * User.Write checked by nobody. + * · any route where skipsServiceAuthentication skipped the service guards, taking the + * one role check in the codebase with them. + * + * Enforcing here — after the app router and every service router have run — puts the + * check at the layer that declares the field, and means the answer no longer depends on + * which optional service happens to be enabled. The auth service's guard keeps doing + * what only it can do: validate the token and populate authUser. + * + * @throws \gcgov\framework\exceptions\routeException + */ + private static function assertRequiredRoles( \gcgov\framework\models\routeHandler $routeHandler ): void { + if( count( $routeHandler->requiredRoles )===0 ) { + return; + } + + $authUser = \gcgov\framework\services\request::getAuthUser(); + + // The route names roles but nothing established a user, so there is nothing to check + // them against. Fail closed: this is the case the boot check cannot see, because an + // \app\router::authentication() that returns true is indistinguishable from one that + // verified something. The detail goes to the log — it is a deployment + // misconfiguration, not something the caller can act on. + if( $authUser->userId==='' ) { + log::warning( 'Framework Lifecycle', '-Router- route "' . $routeHandler->class . '::' . $routeHandler->method . '" requires role(s) ' . implode( ', ', $routeHandler->requiredRoles ) . ' but no authenticated user was established. Enable services.auth, or have the authenticator populate the request-scoped authUser via request::getAuthUser()->setFromUser().' ); + + throw new routeException( 'Authentication failed', 401 ); + } + + foreach( $routeHandler->requiredRoles as $requiredRole ) { + if( !$authUser->hasRole( $requiredRole ) ) { + throw new routeException( 'User does not have the permission "' . $requiredRole . '" required to access this content', 403 ); + } + } + } + + /** * Routes that declare authentication:true are only actually guarded by an * authentication service, or by an application that authenticates its own routes. @@ -173,6 +224,17 @@ public function route(): \gcgov\framework\models\routeHandler { * @throws \gcgov\framework\exceptions\configException */ public static function assertAuthenticationIsProvided( array $routes, bool $authServiceEnabled, bool $appProvidesAuthentication ): void { + // Contradictory rather than dangerous, and independent of how the application + // authenticates: an unauthenticated route returns before the guard chain, so its + // roles can never be checked by anything. Warned rather than refused — such a route + // works today with its roles inert, and failing an application's boot over a + // declaration that never did anything is disproportionate. + foreach( $routes as $route ) { + if( !$route->authentication && count( $route->requiredRoles )>0 ) { + log::warning( 'Framework Lifecycle', '-Router- route "' . $route->route . '" declares requiredRoles but authentication:false, so the roles are never checked. Set authentication:true, or drop the roles.' ); + } + } + if( $authServiceEnabled || $appProvidesAuthentication ) { return; } diff --git a/src/services/auth/guard.php b/src/services/auth/guard.php index a02ddbb..07c2deb 100644 --- a/src/services/auth/guard.php +++ b/src/services/auth/guard.php @@ -11,7 +11,12 @@ * Provider-independent: both providers mint framework access tokens with the same keys * and claims, so verifying one is the same work either way. This existed as ~50 near * identical lines in each auth package; the only differences were a local variable name - * and the wording of the 403. + * and the wording of a message. + * + * Authentication only. Authorization — the route's requiredRoles — belongs to + * {@see \gcgov\framework\router::assertRequiredRoles()}, because a route declaring roles + * must have them enforced whether or not this optional service is the thing that + * authenticated the caller. */ final class guard { @@ -51,15 +56,12 @@ public static function authenticate( \gcgov\framework\models\routeHandler $route throw new routeException( 'Token validation failed: ' . implode( ', ', $violationMessages ), 401, $e ); } - foreach( $routeHandler->requiredRoles as $requiredRole ) { - // Strict: $authUser->roles is narrowed to strings by authUser::normalizeRoles(), - // and a loose comparison here would still match a required role against any - // truthy element were that ever to change. - if( !in_array( $requiredRole, $authUser->roles, true ) ) { - throw new routeException( 'User does not have the permission "' . $requiredRole . '" required to access this content', 403 ); - } - } - + // requiredRoles is NOT checked here. It is declared on the framework's own route + // model and enforced by router::assertRequiredRoles() once the whole guard chain has + // run, so it holds for routes this service never sees — an application that + // authenticates itself, or one that opts out through skipsServiceAuthentication. + // This guard's job is the part only it can do: validate the token and establish the + // user the router then checks. return true; } diff --git a/src/services/userCrud/router.php b/src/services/userCrud/router.php index 7cfa3e2..0368776 100644 --- a/src/services/userCrud/router.php +++ b/src/services/userCrud/router.php @@ -30,9 +30,11 @@ public function getRoutes(): array { /** - * This service enforces nothing itself. Its routes require authentication, and the - * framework refuses to boot when authenticated routes exist with no authentication - * service enabled — so "installed but unguarded" is no longer reachable. + * This service enforces nothing itself, and no longer needs to. Its routes require + * authentication, the framework refuses to boot when authenticated routes exist with no + * authentication service enabled, and the User.Read / User.Write these routes declare + * are enforced by router::assertRequiredRoles() whatever authenticated the caller — so + * "installed but unguarded" is unreachable for both halves, not just the first. */ public function authentication( \gcgov\framework\models\routeHandler $routeHandler ): bool { return true; diff --git a/tests/Unit/RequiredRolesTest.php b/tests/Unit/RequiredRolesTest.php new file mode 100644 index 0000000..6f53221 --- /dev/null +++ b/tests/Unit/RequiredRolesTest.php @@ -0,0 +1,137 @@ +setFromJwtToken( [], [] ); + } + + + /** A route that declares no roles must not start requiring authentication. */ + public function testRouteWithoutRolesPassesEvenWithNoUser(): void { + $this->expectNotToPerformAssertions(); + + $this->assertRoles( $this->handler( [] ) ); + } + + + /** + * The regression the change exists for: roles declared, nobody authenticated. This + * previously sailed through — the only role check in the codebase was never reached. + */ + public function testRolesDeclaredWithNoUserEstablishedIs401(): void { + $this->expectException( routeException::class ); + $this->expectExceptionCode( 401 ); + + $this->assertRoles( $this->handler( [ 'User.Read' ] ) ); + } + + + public function testUserHoldingEveryRequiredRolePasses(): void { + $this->authenticate( [ 'User.Read', 'User.Write' ] ); + + $this->expectNotToPerformAssertions(); + + $this->assertRoles( $this->handler( [ 'User.Read', 'User.Write' ] ) ); + } + + + public function testUserMissingARoleIs403(): void { + $this->authenticate( [ 'User.Read' ] ); + + $this->expectException( routeException::class ); + $this->expectExceptionCode( 403 ); + $this->expectExceptionMessage( 'User does not have the permission "User.Write" required to access this content' ); + + $this->assertRoles( $this->handler( [ 'User.Read', 'User.Write' ] ) ); + } + + + /** Every required role must be held — a subset is not enough. */ + public function testHoldingASubsetIs403(): void { + $this->authenticate( [ 'User.Read', 'Widget.Read' ] ); + + $this->expectException( routeException::class ); + $this->expectExceptionCode( 403 ); + + $this->assertRoles( $this->handler( [ 'User.Read', 'User.Write', 'Widget.Read' ] ) ); + } + + + public function testUnrelatedRolesDoNotSatisfyTheRequirement(): void { + $this->authenticate( [ 'Widget.Read', 'Widget.Write' ] ); + + $this->expectException( routeException::class ); + $this->expectExceptionCode( 403 ); + + $this->assertRoles( $this->handler( [ 'User.Read' ] ) ); + } + + + /** + * The narrowing in authUser::normalizeRoles() has to hold through this call path too: a + * non-string truthy element in the token's scope claim satisfied a loose comparison + * against every required role. + */ + #[DataProvider('nonStringScopes')] + public function testNonStringScopeElementsDoNotSatisfyARole( array $scope ): void { + $this->authenticate( $scope ); + + $this->expectException( routeException::class ); + $this->expectExceptionCode( 403 ); + + $this->assertRoles( $this->handler( [ 'User.Write' ] ) ); + } + + + /** @return array}> */ + public static function nonStringScopes(): array { + return [ + 'boolean true' => [ [ true ] ], + 'integer one' => [ [ 1 ] ], + 'float' => [ [ 1.0 ] ], + ]; + } + + + /** Establish a user the way the auth guard does, so roles have something to check against. */ + private function authenticate( array $roles ): void { + authUser::getInstance()->setFromJwtToken( [ 'userId' => '507f1f77bcf86cd799439011' ], $roles ); + } + + + private function handler( array $requiredRoles ): routeHandler { + return new routeHandler( '\app\controllers\widget', 'getOne', true, $requiredRoles ); + } + + + /** @throws routeException */ + private function assertRoles( routeHandler $routeHandler ): void { + ( new \ReflectionMethod( router::class, 'assertRequiredRoles' ) )->invoke( null, $routeHandler ); + } + +} diff --git a/tests/Unit/RouterAuthenticationGuaranteeTest.php b/tests/Unit/RouterAuthenticationGuaranteeTest.php index 2581c8d..da9f277 100644 --- a/tests/Unit/RouterAuthenticationGuaranteeTest.php +++ b/tests/Unit/RouterAuthenticationGuaranteeTest.php @@ -80,4 +80,21 @@ public function testEmptyRouteTableIsFine(): void { $this->expectNotToPerformAssertions(); } + + /** + * requiredRoles on an unauthenticated route is contradictory — the route returns before + * the guard chain, so the roles can never be checked. It is warned, not refused: the + * declaration was already inert, so failing an application's boot over it would break + * something that works rather than protect anything. + */ + public function testRolesOnAnUnauthenticatedRouteWarnRatherThanRefuse(): void { + $this->expectNotToPerformAssertions(); + + router::assertAuthenticationIsProvided( + [ new route( 'GET', '/widget', '\app\controllers\widget', 'getAll', false, [ 'Widget.Read' ] ) ], + false, + false + ); + } + } diff --git a/tests/Unit/Services/Auth/GuardTest.php b/tests/Unit/Services/Auth/GuardTest.php index 6f81035..530d652 100644 --- a/tests/Unit/Services/Auth/GuardTest.php +++ b/tests/Unit/Services/Auth/GuardTest.php @@ -16,9 +16,12 @@ * * Everything past token validation needs real signing keys, so what is covered here is the * part that runs before them: where a token is read from, and the per-route opt-in that - * governs whether a URL-borne token is accepted. The role comparison the guard performs is - * covered in tests/Unit/Models/AuthUserRolesTest.php, where the narrowing it depends on - * lives. + * governs whether a URL-borne token is accepted. + * + * The guard deliberately does NOT check requiredRoles. That moved to + * router::assertRequiredRoles() so it applies however the caller was authenticated — see + * tests/Unit/RequiredRolesTest.php — and this class asserts the guard has not quietly kept + * a second copy, which is the duplication that let the two enforcement paths disagree. */ #[CoversClass(guard::class)] final class GuardTest extends TestCase { @@ -101,4 +104,26 @@ private function routeHandler( bool $allowShortLivedUrlTokens = false ): routeHa return new routeHandler( '\app\controllers\widget', 'getOne', true, [ 'Widget.Read' ], $allowShortLivedUrlTokens ); } + + /** + * Authorization is not this class's job. A second copy of the role loop here would + * reintroduce exactly the split that made requiredRoles enforceable on one path and not + * the other. + */ + public function testGuardDoesNotCheckRequiredRolesItself(): void { + $source = (string)file_get_contents( __DIR__ . '/../../../../src/services/auth/guard.php' ); + + self::assertStringNotContainsString( 'requiredRoles as $requiredRole', $source, 'the role loop belongs to router::assertRequiredRoles()' ); + self::assertStringNotContainsString( 'required to access this content', $source, 'the 403 belongs to router::assertRequiredRoles()' ); + } + + + /** Establishing the user is this class's job, and the router's check depends on it. */ + public function testGuardPopulatesTheRequestScopedAuthUser(): void { + $source = (string)file_get_contents( __DIR__ . '/../../../../src/services/auth/guard.php' ); + + self::assertStringContainsString( 'request::getAuthUser()', $source ); + self::assertStringContainsString( 'setFromJwtToken', $source ); + } + } From 50ab8ab7bd6909908a44c228b8492c5ff97f5d59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:50:08 +0000 Subject: [PATCH 22/30] Fix create-user fatal, route-override matching, and CLI bootstrap gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - userCrud: POST /user/new assigns a fresh ObjectId instead of unset(): the model's typed $_id is read unconditionally by factory::save(), so an unset property was a fatal uninitialized-property Error on every create. The test stub's save() now mirrors that unconditional read so the suite fails the way production did. - router: the app-overrides-framework dedup now keys routes the way FastRoute defines a duplicate — by compiled shape (patternShapes()), not spelling — so user/{id} vs user/{_id} no longer slips past the filter into a BadRouteException that 500s every url; a static app route shadowed by a variable service route also drops the service route. The override notice and the roles-without-authentication warning are gated behind lifecycle logging: routes rebuild per request, and both logged one identical line per request forever. - health: when services.auth is enabled, /health/ready checks that the key directory holds usable signing keys, so an unmounted or empty key mount fails the deploy gate instead of surfacing as a configException at the first production sign-in. ready() also loses its duplicated array keys and over-wide try block. - cert:generate-auth: resolves jwtAuth.keyPath without demanding the whole config.json resolve, so `gf init` can generate keys on a fresh scaffold whose .env is still empty (this also repairs the command's own tests, which ran without a config.json and threw); a relative keyPath anchors to the application root. - gf env: declaredNames() parses .env with the same symfony/dotenv parser the runtime loads it with (the regex miscounted multi-line quoted values); reserved CGI meta-variable names are reported as RESERVED rather than MISSING and written as guidance rather than dead lines; the secret _FILE hint carries the /run/secrets// segment the deployment convention uses and is shared with gf migrate via one helper. - envVarResolver: public isReservedName(); tests pin the fail-closed bool processor and isSatisfied(). - test suite health: AuthUserRolesTest's stub-loading test runs in a separate process — it defined \app\models\user for the whole run and failed RequestTest's default-model assertion; LifecycleExceptionTest's double-quoted assertion interpolated an undefined $e and checked the wrong string. - readme/gf.md: describe the --init append behavior and the configured cert keyPath, both changed in v7 but undocumented in the authoritative CLI reference. composer ci: phpstan clean, 787 tests green (the branch baseline had two errors, one failure and two warnings). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012SmBhj1hdgf4hFcvCN78m5 --- readme/gf.md | 8 +- src/cli/commands/certGenerateAuthCommand.php | 77 +++++++++- src/cli/commands/envCommand.php | 68 +++++++-- src/cli/commands/migrateCommand.php | 6 +- src/router.php | 144 ++++++++++++++---- src/services/environment/envVarResolver.php | 11 ++ src/services/health/controllers/health.php | 59 ++++++- src/services/userCrud/controllers/user.php | 5 +- tests/Stubs/FakeUserModel.php | 10 +- tests/Unit/Cli/CommandsTest.php | 58 +++++++ tests/Unit/Cli/EnvCommandTest.php | 45 +++++- tests/Unit/LifecycleExceptionTest.php | 4 +- tests/Unit/Models/AuthUserRolesTest.php | 14 +- tests/Unit/RouteOverrideTest.php | 68 ++++++++- .../Environment/EnvVarResolverTest.php | 39 +++++ .../Services/Health/HealthControllerTest.php | 56 +++++++ .../Controllers/UserControllerTest.php | 20 +++ 17 files changed, 617 insertions(+), 75 deletions(-) diff --git a/readme/gf.md b/readme/gf.md index b5f59fd..d4097a1 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -87,7 +87,7 @@ $routes[] = new route( 'CLI', '/cli/generate-shifts', '\app\controllers\cli\gene ## JWT signing keys: `gf cert:generate-auth` ``` -gf cert:generate-auth # 5 RSA-2048 keypairs -> srv/jwtCertificates + guids.json +gf cert:generate-auth # 5 RSA-2048 keypairs -> jwtAuth.keyPath (default srv/jwtCertificates) + guids.json gf cert:generate-auth --count=3 --yes ``` @@ -173,7 +173,7 @@ before the application does. ```bash gf env # resolve config.json against the current environment gf env --list # every variable it references, whether each is a secret, whether each is set -gf env --init # write a .env skeleton from that list (--force to overwrite) +gf env --init # write a .env skeleton, or append what an existing file lacks (--force rewrites) ``` Validation prints the resolved type, urls, logging destination and Mongo connections (URIs @@ -182,7 +182,9 @@ without resolving anything, so they work on a fresh clone with no `.env` at all. Because the manifest is derived from `config.json`, it cannot drift from it. `.env` also holds variables `config.json` never sees — compose ports, CORS origins — which live in the template's -`.env.example`; `--init` does not touch an existing file. +`.env.example`. On an existing file `--init` appends only the references the file does not +already declare, leaving every filled-in value and unrelated variable alone; `--force` rewrites +from `config.json` alone, discarding both. Full reference: **[Environment variables in config](environment-variables.md)**. diff --git a/src/cli/commands/certGenerateAuthCommand.php b/src/cli/commands/certGenerateAuthCommand.php index 6c04c64..b317d50 100644 --- a/src/cli/commands/certGenerateAuthCommand.php +++ b/src/cli/commands/certGenerateAuthCommand.php @@ -4,6 +4,9 @@ use gcgov\framework\cli\appContext; use gcgov\framework\cli\cliException; +use gcgov\framework\services\environment\dotEnvLoader; +use gcgov\framework\services\environment\environmentException; +use gcgov\framework\services\environment\envVarResolver; use gcgov\framework\services\guid; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -33,11 +36,11 @@ protected function execute( InputInterface $input, OutputInterface $output ): in } $context = appContext::require(); + $io = new SymfonyStyle( $input, $output ); + // Resolved through the same accessor jwtAuth reads at runtime, so a configured // jwtAuth.keyPath gets the keys written where the framework will look for them. - $certificateDir = rtrim( $context->loadConfig()->getJwtKeyPath( $context->getSrvDir() ), '/' ); - - $io = new SymfonyStyle( $input, $output ); + $certificateDir = self::resolveCertificateDir( $context, $io ); $existingKeys = glob( $certificateDir . '/*.pem' ) ?: []; if( count( $existingKeys )>0 && !$input->getOption( 'yes' ) ) { @@ -102,4 +105,72 @@ protected function execute( InputInterface $input, OutputInterface $output ): in return Command::SUCCESS; } + + /** + * The directory the keys belong in: jwtAuth.keyPath through the runtime's accessor — + * but without demanding that the REST of config.json resolve. Key generation needs + * one path, and it runs earliest of all: `gf init` calls it on a scaffold whose .env + * is still empty, where loadConfig() fails on the first of many unrelated references + * (MONGO_URI among them) and key generation used to fail with it, breaking init's + * own contract of producing JWT keys. + * + * A relative configured path is anchored to the application root rather than the + * cwd, so one committed value serves the host CLI wherever gf is invoked from. + */ + private static function resolveCertificateDir( appContext $context, SymfonyStyle $io ): string { + try { + $dir = $context->loadConfig()->getJwtKeyPath( $context->getSrvDir() ); + } + catch( cliException $e ) { + $dir = self::keyPathWithoutFullConfig( $context, $io, $e ); + } + + $dir = rtrim( str_replace( '\\', '/', $dir ), '/' ); + if( !preg_match( '~^(?:/|[A-Za-z]:/)~', $dir ) ) { + $dir = $context->rootDir . '/' . $dir; + } + + return $dir; + } + + + /** + * jwtAuth.keyPath alone, from the raw config document. A literal is used as-is; a + * %env()% reference is resolved by itself; only when that one reference has no value + * does this fall back to the default directory — loudly, because a configured path + * silently ignored would put the keys where the runtime will not look. + */ + private static function keyPathWithoutFullConfig( appContext $context, SymfonyStyle $io, cliException $loadFailure ): string { + $default = $context->getSrvDir() . '/jwtCertificates'; + + $raw = file_exists( $context->getConfigPath() ) ? (string)file_get_contents( $context->getConfigPath() ) : ''; + $decoded = json_decode( $raw ); + $configured = $decoded instanceof \stdClass && isset( $decoded->jwtAuth->keyPath ) ? trim( (string)$decoded->jwtAuth->keyPath ) : ''; + + if( $configured==='' ) { + return $default; + } + + if( str_contains( $configured, '%env(' ) ) { + // loadConfig() may have thrown before its own .env load ran. + dotEnvLoader::loadOnce( $context->rootDir ); + try { + $configured = trim( (string)envVarResolver::resolveDecoded( (object)[ 'keyPath' => $configured ], 'config.json jwtAuth.keyPath' )->keyPath ); + } + catch( environmentException $e ) { + $io->warning( 'config.json does not fully resolve (' . $loadFailure->getMessage() . ') and jwtAuth.keyPath itself has no value yet (' . $e->getMessage() . '). Writing the keys to the default ' . $default . ' — if that variable is meant to point somewhere else, set it and re-run cert:generate-auth.' ); + + return $default; + } + } + + if( $configured==='' ) { + return $default; + } + + $io->note( 'config.json does not fully resolve yet — key generation needs only jwtAuth.keyPath, so continuing with it.' ); + + return $configured; + } + } diff --git a/src/cli/commands/envCommand.php b/src/cli/commands/envCommand.php index c9e3d80..e33ea53 100644 --- a/src/cli/commands/envCommand.php +++ b/src/cli/commands/envCommand.php @@ -7,6 +7,7 @@ use gcgov\framework\cli\mongoTools; use gcgov\framework\services\environment\envVarResolver; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -95,7 +96,12 @@ private function listReferences( appContext $context, SymfonyStyle $io ): int { $rows = []; foreach( $references as $name => $isSecret ) { - $rows[] = [ $name, $isSecret ? 'secret' : '', $this->isSet( $name ) ? 'set' : 'MISSING' ]; + // A reserved CGI meta-variable name is never resolvable, whatever is set — + // reporting it as MISSING sends the developer filling in a value forever. + $state = envVarResolver::isReservedName( $name ) + ? 'RESERVED — a CGI meta-variable name is never resolved; rename it' + : ( $this->isSet( $name ) ? 'set' : 'MISSING' ); + $rows[] = [ $name, $isSecret ? 'secret' : '', $state ]; } $io->table( [ 'Variable', 'Kind', 'Current environment' ], $rows ); @@ -132,7 +138,7 @@ private function writeEnvFile( appContext $context, SymfonyStyle $io, bool $forc $added = count( $references ); } else { - $declared = self::declaredNames( $existing ); + $declared = self::declaredNames( $existing, $envPath ); $missing = array_filter( $references, static fn( bool $isSecret, string $name ): bool => !isset( $declared[ $name ] ) && !isset( $declared[ $name . envVarResolver::SECRET_FILE_SUFFIX ] ), ARRAY_FILTER_USE_BOTH ); if( count( $missing )===0 ) { @@ -158,17 +164,27 @@ private function writeEnvFile( appContext $context, SymfonyStyle $io, bool $forc /** * The variable names a .env already declares, so --init can skip them. * - * Only uncommented `NAME=` assignments count: a commented `# NAME_FILE=` hint is - * guidance, not a declaration. + * Parsed with the same symfony/dotenv parser the framework loads the file with, so + * "declared" here is exactly what the runtime will see. The hand-rolled regex this + * replaces disagreed with it on multi-line quoted values: a NAME= at line start + * inside one counted as a declaration, and --init skipped appending a variable the + * file does not actually define. (A commented `# NAME_FILE=` hint is guidance, not a + * declaration, in both readings.) * * @return array + * @throws \gcgov\framework\cli\cliException */ - private static function declaredNames( string $env ): array { + private static function declaredNames( string $env, string $envPath ): array { + try { + $parsed = ( new Dotenv() )->parse( $env, $envPath ); + } + catch( \Symfony\Component\Dotenv\Exception\FormatException $e ) { + throw new cliException( 'Cannot read ' . $envPath . ': ' . $e->getMessage(), 0, $e ); + } + $names = []; - foreach( preg_split( '/\R/', $env ) ?: [] as $line ) { - if( preg_match( '/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/', $line, $m )===1 ) { - $names[ $m[ 1 ] ] = true; - } + foreach( array_keys( $parsed ) as $name ) { + $names[ (string)$name ] = true; } return $names; @@ -205,7 +221,7 @@ private static function renderReferenceLines( array $references ): array { $plain = array_keys( array_filter( $references, static fn( bool $isSecret ): bool => !$isSecret ) ); foreach( $plain as $name ) { - $lines[] = $name . '='; + $lines[] = self::referenceLine( $name ); } if( count( $secrets )>0 ) { @@ -213,8 +229,10 @@ private static function renderReferenceLines( array $references ): array { $lines[] = '# Secrets. In production these are provisioned as files and read through'; $lines[] = '# the companion {NAME}_FILE variable instead — set one or the other, never both.'; foreach( $secrets as $name ) { - $lines[] = $name . '='; - $lines[] = '# ' . $name . '_FILE=/run/secrets/' . strtolower( $name ); + $lines[] = self::referenceLine( $name ); + if( !envVarResolver::isReservedName( $name ) ) { + $lines[] = self::secretFileHint( $name ); + } } } @@ -222,6 +240,32 @@ private static function renderReferenceLines( array $references ): array { } + /** + * A live `NAME=` line — or, for a reserved CGI meta-variable name, guidance instead: + * the resolver never satisfies such a name, so a live line would be filled in and + * still report MISSING forever. The fix is renaming the reference, not a value. + */ + private static function referenceLine( string $name ): string { + return envVarResolver::isReservedName( $name ) + ? '# ' . $name . ' is a reserved CGI meta-variable name the framework never resolves — rename the %env(' . $name . ')% reference in config.json' + : $name . '='; + } + + + /** + * The commented `{NAME}_FILE` hint written beside a secret's plain line — the one + * writer of the convention, shared with `gf migrate` so the two commands cannot + * describe it differently. The path carries the per-application segment the + * deployment convention uses (bin/provision writes /etc/gcgov/secrets//, + * mounted into the container at /run/secrets//): a set _FILE never falls + * back, so a hint without the segment pointed everyone who uncommented it at a file + * that never exists. + */ + public static function secretFileHint( string $name ): string { + return '# ' . $name . envVarResolver::SECRET_FILE_SUFFIX . '=/run/secrets//' . strtolower( $name ); + } + + /** Whether a variable currently has a value, by either the plain or the _FILE name. */ private function isSet( string $name ): bool { return envVarResolver::isSatisfied( $name ); diff --git a/src/cli/commands/migrateCommand.php b/src/cli/commands/migrateCommand.php index 258fb96..7f23e3f 100644 --- a/src/cli/commands/migrateCommand.php +++ b/src/cli/commands/migrateCommand.php @@ -550,9 +550,9 @@ private function writeEnvFile( string $path, array $env, array $secrets ): void foreach( $env as $name => $value ) { $lines[] = $name . '=' . self::encodeEnvValue( $value ); if( $secrets[ $name ] ?? false ) { - // Same convention `gf env --init` writes, so a migrated .env shows the file - // indirection it will need in production rather than a bare marker. - $lines[] = '# ' . $name . '_FILE=/run/secrets/' . strtolower( $name ); + // The one writer of the secret-file convention, shared with `gf env --init`, + // so the two commands cannot describe the indirection differently. + $lines[] = envCommand::secretFileHint( $name ); } } diff --git a/src/router.php b/src/router.php index 559b710..3506a81 100644 --- a/src/router.php +++ b/src/router.php @@ -228,10 +228,14 @@ public static function assertAuthenticationIsProvided( array $routes, bool $auth // authenticates: an unauthenticated route returns before the guard chain, so its // roles can never be checked by anything. Warned rather than refused — such a route // works today with its roles inert, and failing an application's boot over a - // declaration that never did anything is disproportionate. - foreach( $routes as $route ) { - if( !$route->authentication && count( $route->requiredRoles )>0 ) { - log::warning( 'Framework Lifecycle', '-Router- route "' . $route->route . '" declares requiredRoles but authentication:false, so the roles are never checked. Set authentication:true, or drop the roles.' ); + // declaration that never did anything is disproportionate. Behind the lifecycle + // flag because routes are rebuilt per request: unconditional, this was one + // identical log line per request for the life of the deployment. + if( config::getLogging()->lifecycle ) { + foreach( $routes as $route ) { + if( !$route->authentication && count( $route->requiredRoles )>0 ) { + log::warning( 'Framework Lifecycle', '-Router- route "' . $route->route . '" declares requiredRoles but authentication:false, so the roles are never checked. Set authentication:true, or drop the roles.' ); + } } } @@ -296,51 +300,131 @@ private function getRoutes(): array { // exception is neither a routeException nor a configException — so a v6 application // upgrading with its own /health did not lose /health, it lost EVERY route, with an // empty 500 on every url. The health router's docblock already promised that such - // an application "keeps working"; nothing implemented it. Overriding is logged - // rather than silent, because a route disappearing from the framework's surface is - // worth noticing. - $appKeys = []; + // an application "keeps working"; nothing implemented it. + return array_merge( self::serviceRoutesNotOverridden( $serviceRoutes, $appRoutes ), $appRoutes ); + } + + + /** + * The service routes the application's own routes do NOT override. + * + * "Override" is judged the way FastRoute judges a duplicate — by the compiled shape of + * the pattern, never its spelling. user/{id} and user/{_id} are the same route to the + * dispatcher (a placeholder's name never reaches its regex), so comparing raw pattern + * strings kept both registered, and BadRouteException at dispatcher build took every + * url down — the exact outage this filter exists to prevent. A static application + * route inside a variable service route's shape (user/me under the service's + * user/{_id}) drops the service route for the same reason: service routes register + * first, and FastRoute rejects a static route shadowed by an earlier variable one. + * + * Pure and public so the collision rules are testable with synthetic routes. + * + * @param \gcgov\framework\models\route[] $serviceRoutes + * @param \gcgov\framework\models\route[] $appRoutes + * + * @return \gcgov\framework\models\route[] + */ + public static function serviceRoutesNotOverridden( array $serviceRoutes, array $appRoutes ): array { + $appKeys = []; + $appStaticPaths = []; foreach( $appRoutes as $appRoute ) { - foreach( self::routeKeys( $appRoute ) as $key ) { - $appKeys[ $key ] = true; + foreach( (array)$appRoute->httpMethod as $httpMethod ) { + $method = strtoupper( (string)$httpMethod ); + foreach( self::patternShapes( $appRoute->route ) as $shape ) { + $appKeys[ $method . ' ' . $shape[ 'signature' ] ] = true; + if( $shape[ 'regex' ]===null ) { + $appStaticPaths[ $method ][] = $shape[ 'signature' ]; + } + } } } - $routes = []; + $kept = []; foreach( $serviceRoutes as $serviceRoute ) { - $overridden = false; - foreach( self::routeKeys( $serviceRoute ) as $key ) { - if( isset( $appKeys[ $key ] ) ) { - $overridden = true; - break; + if( self::isOverriddenBy( $serviceRoute, $appKeys, $appStaticPaths ) ) { + if( config::getLogging()->lifecycle ) { + // Behind the lifecycle flag: routes are rebuilt per request, and an + // application using the override path deliberately would otherwise + // emit one identical notice per request for the life of the deploy. + log::notice( 'Framework Lifecycle', '-Router- \app\router defines "' . $serviceRoute->route . '"; the framework route of the same shape is not registered' ); } - } - - if( $overridden ) { - log::notice( 'Framework Lifecycle', '-Router- \app\router defines "' . $serviceRoute->route . '"; the framework route of the same name is not registered' ); continue; } - $routes[] = $serviceRoute; + $kept[] = $serviceRoute; } - return array_merge( $routes, $appRoutes ); + return $kept; } /** - * The (method, pattern) pairs a route occupies. httpMethod is string|array, and a route - * registered for several methods collides on each of them independently. + * @param array $appKeys 'METHOD signature' the app occupies + * @param array $appStaticPaths method => the app's static paths + */ + private static function isOverriddenBy( \gcgov\framework\models\route $serviceRoute, array $appKeys, array $appStaticPaths ): bool { + foreach( (array)$serviceRoute->httpMethod as $httpMethod ) { + $method = strtoupper( (string)$httpMethod ); + foreach( self::patternShapes( $serviceRoute->route ) as $shape ) { + if( isset( $appKeys[ $method . ' ' . $shape[ 'signature' ] ] ) ) { + return true; + } + if( $shape[ 'regex' ]!==null ) { + foreach( $appStaticPaths[ $method ] ?? [] as $staticPath ) { + if( preg_match( '~^' . $shape[ 'regex' ] . '$~', $staticPath )===1 ) { + return true; + } + } + } + } + } + + return false; + } + + + /** + * The shapes a FastRoute pattern occupies, one per optional-segment variant. + * + * `signature` keys the shape the way the dispatcher compiles it: literals verbatim, + * each placeholder reduced to {its-regex} — so user/{id} and user/{_id} share a + * signature while user/{id:\d+} has its own. `regex` is the anchored expression for a + * variant carrying placeholders (null for a purely static one), used by the + * static-shadowing check. A pattern the parser rejects falls back to its literal + * spelling: FastRoute reports the malformed pattern itself at dispatcher build. * - * @return string[] + * @return array{signature: string, regex: string|null}[] */ - private static function routeKeys( \gcgov\framework\models\route $route ): array { - $keys = []; - foreach( (array)$route->httpMethod as $httpMethod ) { - $keys[] = strtoupper( (string)$httpMethod ) . ' ' . $route->route; + public static function patternShapes( string $pattern ): array { + try { + $variants = ( new \FastRoute\RouteParser\Std() )->parse( $pattern ); + } + catch( \FastRoute\BadRouteException ) { + return [ [ 'signature' => $pattern, 'regex' => null ] ]; + } + + $shapes = []; + foreach( $variants as $variant ) { + $signature = ''; + $regex = ''; + $variable = false; + foreach( $variant as $part ) { + if( is_array( $part ) ) { + // [ placeholder name, placeholder regex ] — the name never compiles. + $variable = true; + $signature .= '{' . (string)$part[ 1 ] . '}'; + $regex .= '(' . (string)$part[ 1 ] . ')'; + } + else { + $signature .= (string)$part; + $regex .= preg_quote( (string)$part, '~' ); + } + } + + $shapes[] = [ 'signature' => $signature, 'regex' => $variable ? $regex : null ]; } - return $keys; + return $shapes; } diff --git a/src/services/environment/envVarResolver.php b/src/services/environment/envVarResolver.php index a4b3792..dbcf4a9 100644 --- a/src/services/environment/envVarResolver.php +++ b/src/services/environment/envVarResolver.php @@ -460,4 +460,15 @@ public static function isSatisfied( string $name ): bool { return self::lookupEnv( $name )!==null || self::lookupEnv( $name . self::SECRET_FILE_SUFFIX )!==null; } + + /** + * Whether a name is reserved: a CGI meta-variable name this resolver never satisfies + * from the ambient environment (see the class docblock). Public so `gf env` can say + * "reserved — rename it" instead of reporting an unsatisfiable variable as MISSING + * and writing a dead line into .env for the developer to fill in forever. + */ + public static function isReservedName( string $name ): bool { + return self::isBlockedName( $name ); + } + } diff --git a/src/services/health/controllers/health.php b/src/services/health/controllers/health.php index f077030..7c980a5 100644 --- a/src/services/health/controllers/health.php +++ b/src/services/health/controllers/health.php @@ -60,21 +60,40 @@ public function ready(): controllerDataResponse { $healthy = true; try { - foreach( config::getMongoDatabases() as $mongoDatabase ) { - $checks[ 'mongo:' . $mongoDatabase->database ] = self::pingMongo( $mongoDatabase ); - if( $checks[ 'mongo:' . $mongoDatabase->database ]!=='ok' ) { - $healthy = false; - } - } + $mongoDatabases = config::getMongoDatabases(); + $authEnabled = config::getServices()->auth!==null; + $jwtKeyPath = $authEnabled ? config::getJwtKeyPath() : ''; } catch( \Throwable $e ) { // Logged, not returned — see pingMongo(). A configException message carries the // config file path and the name of the unresolved environment variable. log::error( 'health', 'Readiness could not read configuration', [ 'exception' => $e ] ); + $mongoDatabases = []; + $authEnabled = false; + $jwtKeyPath = ''; $checks[ 'config' ] = 'failed'; $healthy = false; } + // pingMongo() never throws — see its contract — so the loop needs no guard. + foreach( $mongoDatabases as $mongoDatabase ) { + $status = self::pingMongo( $mongoDatabase ); + $checks[ 'mongo:' . $mongoDatabase->database ] = $status; + $healthy = $healthy && $status==='ok'; + } + + // When the auth service is enabled, usable signing keys are a dependency like the + // database. Nothing else checks them before traffic arrives: sign-in is the first + // thing that constructs jwtAuth, so a missing or empty key mount (APP_JWT_KEY_PATH + // pointing at an unprovisioned directory) passed every health gate — deploy green, + // proxy green — and surfaced only as a configException on the first production + // sign-in. + if( $authEnabled ) { + $status = self::jwtKeysStatus( $jwtKeyPath ); + $checks[ 'jwtKeys' ] = $status; + $healthy = $healthy && $status==='ok'; + } + $response = new controllerDataResponse( [ 'status' => $healthy ? 'ok' : 'unavailable', 'version' => self::version(), @@ -129,4 +148,32 @@ private static function pingMongo( mongoDatabase $mongoDatabase ): string { } } + + /** + * @return string 'ok' or 'failed' — never a thrown exception, and never the path: + * like pingMongo(), the reason goes to the log rather than to a route that is + * registered unauthenticated on every application. + * + * Mirrors jwtAuth's own key discovery: guids.json naming at least one guid whose + * private and public pem files both exist beside it. + */ + private static function jwtKeysStatus( string $keyPath ): string { + $guidsFile = $keyPath . 'guids.json'; + if( file_exists( $guidsFile ) ) { + $guids = json_decode( (string)file_get_contents( $guidsFile ) ); + foreach( is_array( $guids ) ? $guids : [] as $guid ) { + if( !is_string( $guid ) ) { + continue; + } + if( file_exists( $keyPath . 'private-' . $guid . '.pem' ) && file_exists( $keyPath . 'public-' . $guid . '.pem' ) ) { + return 'ok'; + } + } + } + + log::warning( 'health', 'Readiness found no usable JWT signing keys in the configured key directory. Generate them with `vendor/bin/gf cert:generate-auth`, or point jwtAuth.keyPath (APP_JWT_KEY_PATH) at the provisioned directory.' ); + + return 'failed'; + } + } diff --git a/src/services/userCrud/controllers/user.php b/src/services/userCrud/controllers/user.php index 7fcfb1c..6a4b2bd 100644 --- a/src/services/userCrud/controllers/user.php +++ b/src/services/userCrud/controllers/user.php @@ -173,7 +173,10 @@ public function save( string $_id ): controllerDataResponse { if( $_id==='new' ) { // A create must create. Without this, POST /user/new carrying an existing // account's _id would overwrite that account — the same hole by another route. - unset( $user->_id ); + // The fresh identity is ASSIGNED rather than unset(): the model's $_id is a + // typed ObjectId that save() reads unconditionally, so an unset property is a + // fatal uninitialized-property Error on the framework's own create endpoint. + $user->_id = new \MongoDB\BSON\ObjectId(); } elseif( !isset( $user->_id ) || (string)$user->_id==='' ) { throw new controllerException( 'The request body must carry the _id it is being saved to', 400 ); diff --git a/tests/Stubs/FakeUserModel.php b/tests/Stubs/FakeUserModel.php index 90344dc..20398ae 100644 --- a/tests/Stubs/FakeUserModel.php +++ b/tests/Stubs/FakeUserModel.php @@ -64,12 +64,10 @@ public static function save( object &$object ): mixed { if ( !( $object instanceof self ) ) { throw new \InvalidArgumentException( 'Expected ' . self::class ); } - // Mirrors the real model: an insert mints the _id, so an object arriving without - // one is a create rather than an error. - if ( !isset( $object->_id ) || $object->_id === '' ) { - $object->_id = 'generated-' . count( self::$records ); - } - self::$records[ $object->_id ] = $object; + // Mirrors the real factory: save() reads $object->_id unconditionally to build its + // update filter, so an unset typed property is an Error here exactly as it is in + // production. A fresh identity comes from the caller — save() never mints one. + self::$records[ (string) $object->_id ] = $object; return $object; } diff --git a/tests/Unit/Cli/CommandsTest.php b/tests/Unit/Cli/CommandsTest.php index 95c440b..35d7405 100644 --- a/tests/Unit/Cli/CommandsTest.php +++ b/tests/Unit/Cli/CommandsTest.php @@ -88,6 +88,64 @@ public function testCertGenerateAuthCreatesKeypairsAndGuidsJson(): void { $this->assertFileExists( $certificateDir . '/.gitignore', 'gitignore is copied from the jwtAuth service directory' ); } + /** A configured jwtAuth.keyPath wins, and a relative one anchors to the app root. */ + public function testCertGenerateAuthHonorsAConfiguredKeyPath(): void { + if( !extension_loaded( 'openssl' ) ) { + $this->markTestSkipped( 'ext-openssl not loaded' ); + } + + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ 'jwtAuth' => [ 'keyPath' => 'srv/authKeys' ] ] ) ); + + $commandTester = new CommandTester( new certGenerateAuthCommand() ); + $exitCode = $commandTester->execute( [ '--count' => '1', '--yes' => true ] ); + + $this->assertSame( 0, $exitCode ); + $this->assertFileExists( $this->tempRootDir . '/srv/authKeys/guids.json' ); + } + + + /** + * `gf init` runs this command on a scaffold whose .env is still empty, so a failure + * to resolve UNRELATED references (MONGO_URI among them) must not block key + * generation — it needs only jwtAuth.keyPath. + */ + public function testCertGenerateAuthSucceedsWhenOnlyOtherReferencesAreUnresolved(): void { + if( !extension_loaded( 'openssl' ) ) { + $this->markTestSkipped( 'ext-openssl not loaded' ); + } + + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ + 'type' => '%env(GF_TEST_CERT_ABSENT)%', + 'jwtAuth' => [ 'keyPath' => 'srv/authKeys' ], + ] ) ); + + $commandTester = new CommandTester( new certGenerateAuthCommand() ); + $exitCode = $commandTester->execute( [ '--count' => '1', '--yes' => true ] ); + + $this->assertSame( 0, $exitCode ); + $this->assertFileExists( $this->tempRootDir . '/srv/authKeys/guids.json', 'the configured keyPath must still be honored' ); + } + + + /** Only when the keyPath reference ITSELF has no value does the default apply — loudly. */ + public function testCertGenerateAuthFallsBackToTheDefaultWhenTheKeyPathReferenceIsUnset(): void { + if( !extension_loaded( 'openssl' ) ) { + $this->markTestSkipped( 'ext-openssl not loaded' ); + } + + file_put_contents( $this->tempRootDir . '/config.json', json_encode( [ + 'jwtAuth' => [ 'keyPath' => '%env(GF_TEST_CERT_KEYPATH_ABSENT)%' ], + ] ) ); + + $commandTester = new CommandTester( new certGenerateAuthCommand() ); + $exitCode = $commandTester->execute( [ '--count' => '1', '--yes' => true ] ); + + $this->assertSame( 0, $exitCode ); + $this->assertFileExists( $this->tempRootDir . '/srv/jwtCertificates/guids.json' ); + $this->assertStringContainsString( 'GF_TEST_CERT_KEYPATH_ABSENT', $commandTester->getDisplay(), 'the fallback must name the unresolved variable' ); + } + + public function testCertGenerateAuthRegenerationReplacesOldKeys(): void { if( !extension_loaded( 'openssl' ) ) { $this->markTestSkipped( 'ext-openssl not loaded' ); diff --git a/tests/Unit/Cli/EnvCommandTest.php b/tests/Unit/Cli/EnvCommandTest.php index 3db8664..8b51e87 100644 --- a/tests/Unit/Cli/EnvCommandTest.php +++ b/tests/Unit/Cli/EnvCommandTest.php @@ -27,7 +27,7 @@ public function testSecretsAreGroupedAndShowTheFileAlternative(): void { $rendered = ( new envCommand() )->renderEnvFile( [ 'APP_TYPE' => false, 'MONGO_URI' => true ] ); self::assertStringContainsString( "MONGO_URI=\n", $rendered ); - self::assertStringContainsString( '# MONGO_URI_FILE=/run/secrets/mongo_uri', $rendered ); + self::assertStringContainsString( '# MONGO_URI_FILE=/run/secrets//mongo_uri', $rendered ); self::assertStringContainsString( 'never commit', strtolower( $rendered ) ); // Plain variables come first, secrets in their own labelled block after. @@ -63,21 +63,19 @@ public function testDeclaredNamesFindsExistingAssignments(): void { APP_TYPE=local MONGO_URI='mongodb://localhost' export EXPORTED=1 - SPACED = value - # MONGO_URI_FILE=/run/secrets/mongo_uri + # MONGO_URI_FILE=/run/secrets//mongo_uri COMPOSE_PORT=8080 ENV ); self::assertArrayHasKey( 'APP_TYPE', $declared ); self::assertArrayHasKey( 'MONGO_URI', $declared ); self::assertArrayHasKey( 'EXPORTED', $declared, 'an export prefix is still a declaration' ); - self::assertArrayHasKey( 'SPACED', $declared ); self::assertArrayHasKey( 'COMPOSE_PORT', $declared, 'variables config.json never mentions still count' ); } public function testDeclaredNamesIgnoresCommentedHints(): void { - $declared = self::declaredNames( "# MONGO_URI_FILE=/run/secrets/mongo_uri + $declared = self::declaredNames( "# MONGO_URI_FILE=/run/secrets//mongo_uri #APP_TYPE=local " ); @@ -86,11 +84,46 @@ public function testDeclaredNamesIgnoresCommentedHints(): void { } + /** + * Declared-ness is judged by the same symfony/dotenv parser the runtime loads the + * file with: a quoted value spans lines, so a NAME= at line start inside one is part + * of the VALUE, not a declaration. The hand-rolled regex this replaced counted it, + * told --init the variable was covered, and the app then failed at startup on a + * reference the tool had just reported as declared. + */ + public function testDeclaredNamesUsesTheRuntimeParserForMultiLineValues(): void { + $declared = self::declaredNames( "GREETING=\"first line\nMONGO_URI=not-a-declaration\"\nAPP_TYPE=local\n" ); + + self::assertArrayHasKey( 'GREETING', $declared ); + self::assertArrayHasKey( 'APP_TYPE', $declared ); + self::assertArrayNotHasKey( 'MONGO_URI', $declared, 'text inside a quoted value is not a declaration' ); + } + + + public function testDeclaredNamesRejectsAFileTheRuntimeCannotParse(): void { + $this->expectException( \gcgov\framework\cli\cliException::class ); + self::declaredNames( "SPACED = value\n" ); + } + + + /** + * A reserved CGI meta-variable name can never be resolved, so a live `NAME=` line + * would be filled in and still report MISSING forever — guidance is written instead. + */ + public function testReservedNamesAreWrittenAsGuidanceNotDeadAssignments(): void { + $rendered = ( new envCommand() )->renderEnvFile( [ 'SERVER_API_TOKEN' => false, 'APP_TYPE' => false ] ); + + self::assertStringNotContainsString( "\nSERVER_API_TOKEN=", $rendered ); + self::assertStringContainsString( 'reserved', $rendered ); + self::assertStringContainsString( "APP_TYPE=\n", $rendered, 'ordinary names still get live lines' ); + } + + /** Reflection, because the parser is a private detail of the command. */ private static function declaredNames( string $env ): array { $method = new \ReflectionMethod( envCommand::class, 'declaredNames' ); - return $method->invoke( null, $env ); + return $method->invoke( null, $env, '.env' ); } } diff --git a/tests/Unit/LifecycleExceptionTest.php b/tests/Unit/LifecycleExceptionTest.php index 03aa085..3310f40 100644 --- a/tests/Unit/LifecycleExceptionTest.php +++ b/tests/Unit/LifecycleExceptionTest.php @@ -83,7 +83,9 @@ public function testCaughtConfigFailureIsLoggedAndGenericised(): void { $routingBlock = $this->routingTryBlock( $source ); self::assertStringContainsString( 'log::critical', $routingBlock ); - self::assertStringNotContainsString( "routeException( $e->getMessage()", $routingBlock ); + // Single-quoted on purpose: the literal text being asserted absent contains + // `$e->getMessage()`, which double quotes would interpolate at runtime. + self::assertStringNotContainsString( 'routeException( $e->getMessage()', $routingBlock ); self::assertMatchesRegularExpression( '/new routeException\(\s*\'[^\']+\',\s*500/', $routingBlock ); } diff --git a/tests/Unit/Models/AuthUserRolesTest.php b/tests/Unit/Models/AuthUserRolesTest.php index 72771fa..6905643 100644 --- a/tests/Unit/Models/AuthUserRolesTest.php +++ b/tests/Unit/Models/AuthUserRolesTest.php @@ -7,6 +7,8 @@ use gcgov\framework\models\authUser; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; /** @@ -71,7 +73,17 @@ public function testMixedScopeKeepsOnlyTheRealRoles(): void { } - /** The same narrowing has to apply to roles arriving from the user model, not only the token. */ + /** + * The same narrowing has to apply to roles arriving from the user model, not only the + * token. + * + * In a separate process because the stub defines \app\models\user — and a process + * that holds that class answers request::getUserClassFqdn() differently for every + * later test (RequestTest asserts the framework default). Same isolation, and same + * reason, as UserControllerTest. + */ + #[RunInSeparateProcess] + #[PreserveGlobalState( false )] public function testRolesFromTheUserModelAreNarrowedToo(): void { require_once __DIR__ . '/../../Stubs/FakeUserModel.php'; diff --git a/tests/Unit/RouteOverrideTest.php b/tests/Unit/RouteOverrideTest.php index 89fc69b..2b1322e 100644 --- a/tests/Unit/RouteOverrideTest.php +++ b/tests/Unit/RouteOverrideTest.php @@ -55,6 +55,58 @@ public function testSameMethodAndPathCollide(): void { } + /** + * FastRoute compiles a placeholder's regex, never its name, so user/{id} and + * user/{_id} are the SAME route to the dispatcher — and must collide here, or both + * register and BadRouteException takes every url down: the exact whole-surface outage + * the override mechanism exists to prevent, for a v6 app whose placeholder is merely + * spelled differently from the framework's. + */ + public function testPlaceholderSpellingDoesNotDefeatTheCollision(): void { + $framework = $this->routeKeys( new route( 'GET', '/api/user/{_id}', '\gcgov\framework\services\userCrud\controllers\user', 'getOne' ) ); + $app = $this->routeKeys( new route( 'GET', '/api/user/{userId}', '\app\controllers\user', 'getOne' ) ); + + self::assertSame( $framework, $app ); + } + + + /** A custom placeholder regex compiles differently, so it is a different shape. */ + public function testACustomPlaceholderRegexIsADifferentShape(): void { + $plain = $this->routeKeys( new route( 'GET', '/api/user/{id}', '\app\controllers\user', 'getOne' ) ); + $constrained = $this->routeKeys( new route( 'GET', '/api/user/{id:\d+}', '\app\controllers\user', 'getOne' ) ); + + self::assertSame( [], array_intersect( $plain, $constrained ) ); + } + + + public function testOptionalSegmentsOccupyOneSlotPerVariant(): void { + $keys = $this->routeKeys( new route( 'GET', '/api/widget[/{id}]', '\app\controllers\widget', 'get' ) ); + + self::assertCount( 2, $keys ); + } + + + /** + * A static application route inside a variable service route's shape is also fatal: + * service routes register first, and FastRoute rejects a static route shadowed by an + * earlier variable one. The application's path must win there too. + */ + public function testAStaticAppRouteOverridesTheVariableServiceRouteThatWouldShadowIt(): void { + $service = [ new route( 'GET', '/api/user/{_id}', '\gcgov\framework\services\userCrud\controllers\user', 'getOne' ) ]; + $app = [ new route( 'GET', '/api/user/me', '\app\controllers\user', 'me' ) ]; + + self::assertSame( [], router::serviceRoutesNotOverridden( $service, $app ) ); + } + + + public function testAnUnrelatedStaticAppRouteDropsNothing(): void { + $service = [ new route( 'GET', '/api/user/{_id}', '\gcgov\framework\services\userCrud\controllers\user', 'getOne' ) ]; + $app = [ new route( 'GET', '/api/widget/me', '\app\controllers\widget', 'me' ) ]; + + self::assertSame( $service, router::serviceRoutesNotOverridden( $service, $app ) ); + } + + /** * The merged table must never contain a duplicate (method, pattern) — that is exactly * what FastRoute rejects, and rejecting it takes the whole application down. @@ -72,11 +124,21 @@ public function testMergedRoutesContainNoDuplicateMethodAndPattern(): void { } - /** @return string[] */ + /** + * The (method, shape) slots a route occupies, built on the router's public + * patternShapes() — the same signatures the override filter compares. + * + * @return string[] + */ private function routeKeys( route $route ): array { - $method = new \ReflectionMethod( router::class, 'routeKeys' ); + $keys = []; + foreach( (array)$route->httpMethod as $httpMethod ) { + foreach( router::patternShapes( $route->route ) as $shape ) { + $keys[] = strtoupper( (string)$httpMethod ) . ' ' . $shape[ 'signature' ]; + } + } - return $method->invoke( null, $route ); + return $keys; } } diff --git a/tests/Unit/Services/Environment/EnvVarResolverTest.php b/tests/Unit/Services/Environment/EnvVarResolverTest.php index 5c348b3..89a0ca8 100644 --- a/tests/Unit/Services/Environment/EnvVarResolverTest.php +++ b/tests/Unit/Services/Environment/EnvVarResolverTest.php @@ -145,6 +145,45 @@ public function testBoolProcessorProducesATypedBool(): void { } + /** + * ADR 0001: the bool processor fails closed. It used to fall back to `(bool)$value`, + * which turned every unrecognised value — "flase", "disabled", "2" — into TRUE with + * no error, so a typo'd AUTH_BLOCK_NEW_USERS resolved silently to the wrong setting + * while `gf env` reported success. + */ + public function testBoolProcessorRejectsAnUnrecognisedValue(): void { + $this->setEnv( 'GF_TEST_FLAG', 'flase' ); + + $this->expectException( environmentException::class ); + $this->expectExceptionMessageMatches( '/bool/' ); + $this->resolve( '{"flag":"%env(bool:GF_TEST_FLAG)%"}' ); + } + + + public function testIsSatisfiedAcceptsThePlainOrTheFileName(): void { + $this->trackEnv( 'GF_TEST_SAT' ); + self::assertFalse( envVarResolver::isSatisfied( 'GF_TEST_SAT' ) ); + + $this->setEnv( 'GF_TEST_SAT' . envVarResolver::SECRET_FILE_SUFFIX, '/run/secrets/app/sat' ); + self::assertTrue( envVarResolver::isSatisfied( 'GF_TEST_SAT' ) ); + } + + + /** A reserved CGI name is never satisfiable, even when genuinely set. */ + public function testIsSatisfiedIsFalseForAReservedNameEvenWhenSet(): void { + $this->setEnv( 'SERVER_API_TOKEN', 'value' ); + + self::assertFalse( envVarResolver::isSatisfied( 'SERVER_API_TOKEN' ) ); + } + + + public function testIsReservedNameMatchesPrefixesAndExactNames(): void { + self::assertTrue( envVarResolver::isReservedName( 'SERVER_API_TOKEN' ) ); + self::assertTrue( envVarResolver::isReservedName( 'CONTENT_TYPE' ) ); + self::assertFalse( envVarResolver::isReservedName( 'MONGO_URI' ) ); + } + + public function testProcessorsApplyRightToLeft(): void { $path = tempnam( sys_get_temp_dir(), 'gf' ); self::assertIsString( $path ); diff --git a/tests/Unit/Services/Health/HealthControllerTest.php b/tests/Unit/Services/Health/HealthControllerTest.php index 8719cad..323533a 100644 --- a/tests/Unit/Services/Health/HealthControllerTest.php +++ b/tests/Unit/Services/Health/HealthControllerTest.php @@ -94,6 +94,62 @@ public function testUnreachableDatabaseIs503AndDisclosesNothing(): void { } + /** + * When the auth service is enabled, usable signing keys are a readiness dependency: + * a missing or empty key mount used to pass every health gate — deploy green, proxy + * green — and surface only as a configException on the first production sign-in. + */ + public function testMissingJwtKeysFailReadinessWhenAuthIsEnabled(): void { + $this->seedConfig( static function( unifiedConfig $c ): void { + $c->services->auth = new \gcgov\framework\models\config\services\auth(); + $c->jwtAuth->keyPath = sys_get_temp_dir() . '/gcgov-health-missing-keys-' . uniqid(); + } ); + + $response = ( new health() )->ready(); + + $this->assertSame( 503, $response->getHttpStatus() ); + $this->assertSame( 'failed', $response->getData()[ 'checks' ][ 'jwtKeys' ] ); + } + + + public function testProvisionedJwtKeysPassReadinessWhenAuthIsEnabled(): void { + $keyDir = sys_get_temp_dir() . '/gcgov-health-keys-' . uniqid(); + mkdir( $keyDir, 0777, true ); + file_put_contents( $keyDir . '/guids.json', json_encode( [ 'abc' ] ) ); + file_put_contents( $keyDir . '/private-abc.pem', 'pem' ); + file_put_contents( $keyDir . '/public-abc.pem', 'pem' ); + + try { + $this->seedConfig( static function( unifiedConfig $c ) use ( $keyDir ): void { + $c->services->auth = new \gcgov\framework\models\config\services\auth(); + $c->jwtAuth->keyPath = $keyDir; + } ); + + $response = ( new health() )->ready(); + + $this->assertSame( 200, $response->getHttpStatus() ); + $this->assertSame( 'ok', $response->getData()[ 'checks' ][ 'jwtKeys' ] ); + } + finally { + unlink( $keyDir . '/guids.json' ); + unlink( $keyDir . '/private-abc.pem' ); + unlink( $keyDir . '/public-abc.pem' ); + rmdir( $keyDir ); + } + } + + + /** Readiness without the auth service must not demand keys nothing will read. */ + public function testJwtKeysAreNotCheckedWhenAuthIsDisabled(): void { + $this->seedConfig(); + + $response = ( new health() )->ready(); + + $this->assertSame( 200, $response->getHttpStatus() ); + $this->assertArrayNotHasKey( 'jwtKeys', $response->getData()[ 'checks' ] ); + } + + /** * The probe carries its own short timeouts so an unreachable database cannot park a * worker for the driver's 30s default — with a probe every few seconds that exhausts diff --git a/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php b/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php index 87428b1..01f03c0 100644 --- a/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php +++ b/tests/Unit/Services/UserCrud/Controllers/UserControllerTest.php @@ -148,6 +148,26 @@ public function testSaveToNewDoesNotOverwriteAnExistingAccount(): void { } + /** + * The fresh identity must be ASSIGNED, not unset: the real model's $_id is a typed + * ObjectId that factory::save() reads unconditionally, so an unset _id made every + * POST /user/new a fatal uninitialized-property Error — the framework's own create + * endpoint could never succeed. The stub's save() mirrors that unconditional read. + */ + public function testSaveToNewAssignsAFreshIdAndPersistsTheNewAccount(): void { + $this->withPhpInput( json_encode( [ '_id' => 'victim', 'name' => 'Newcomer' ] ), function() { + $response = ( new user() )->save( 'new' ); + $this->assertInstanceOf( controllerDataResponse::class, $response ); + } ); + + $created = array_filter( FakeUser::$records, static fn( FakeUser $record ): bool => $record->name==='Newcomer' ); + $this->assertCount( 1, $created, 'the create must persist the new account' ); + $createdId = (string) array_key_first( $created ); + $this->assertNotSame( 'victim', $createdId, 'the body id must not survive a create' ); + $this->assertNotSame( '', $createdId, 'save() must receive an initialized _id' ); + } + + public function testSaveWrapsModelExceptionInControllerException(): void { FakeUser::$nextException = new modelException( 'validation failure', 422 ); From 2b20b148a0eb342e653f6a3ce9b2cff19c66e0d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:55:34 +0000 Subject: [PATCH 23/30] Lowercase JWT key GUIDs at generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows guid::create() returns uppercase GUIDs (com_create_guid), and the ops repository's provisioning lowercases every secret filename it writes to the host — so an uppercase GUID put a lowercase pem file on the case-sensitive host while guids.json kept the uppercase spelling jwtAuth looks files up by, and every sign-in failed with nothing pointing at the casing. Lowercasing at the source keeps the filename and its guids.json entry in agreement everywhere. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012SmBhj1hdgf4hFcvCN78m5 --- src/cli/commands/certGenerateAuthCommand.php | 7 ++++++- tests/Unit/Cli/CommandsTest.php | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/certGenerateAuthCommand.php b/src/cli/commands/certGenerateAuthCommand.php index b317d50..1c2486e 100644 --- a/src/cli/commands/certGenerateAuthCommand.php +++ b/src/cli/commands/certGenerateAuthCommand.php @@ -71,7 +71,12 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $guids = []; for( $i = 0; $i<$count; $i++ ) { - $keyGuid = guid::create(); + // Lowercased at the source: on Windows guid::create() returns uppercase GUIDs + // (com_create_guid), and the ops repository's provisioning lowercases every + // secret filename it writes to the host — so an uppercase GUID here would put + // a lowercase file on a case-sensitive filesystem while guids.json still + // names the uppercase spelling jwtAuth looks up, and every sign-in fails. + $keyGuid = strtolower( guid::create() ); $guids[] = $keyGuid; $privateKey = openssl_pkey_new( [ diff --git a/tests/Unit/Cli/CommandsTest.php b/tests/Unit/Cli/CommandsTest.php index 35d7405..103c6ff 100644 --- a/tests/Unit/Cli/CommandsTest.php +++ b/tests/Unit/Cli/CommandsTest.php @@ -77,6 +77,10 @@ public function testCertGenerateAuthCreatesKeypairsAndGuidsJson(): void { $this->assertCount( 2, $guids ); foreach( $guids as $guid ) { + // Provisioning lowercases every secret filename it writes to the host, so the + // GUID inside the filename — and its guids.json spelling — must already be + // lowercase, or jwtAuth looks up a file that does not exist there. + $this->assertSame( strtolower( (string)$guid ), $guid, 'key GUIDs must be lowercase' ); $this->assertFileExists( $certificateDir . '/private-' . $guid . '.pem' ); $this->assertFileExists( $certificateDir . '/public-' . $guid . '.pem' ); $publicKey = openssl_pkey_get_public( (string)file_get_contents( $certificateDir . '/public-' . $guid . '.pem' ) ); From ea0512441337b8eb7113f4286b45e3403aea353f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 12:25:27 +0000 Subject: [PATCH 24/30] Add gf user:create, and document running a v7 app locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freshly scaffolded v7 application could not be brought up on a developer machine by following its own instructions. Three of the four blockers were in the app template; this commit carries the framework half. - gf user:create creates the account you sign in as, saved through the model the application actually resolves so the password is hashed by it and every model hook runs. An app with services.auth enabled had no way to get its first user: blockNewUsers defaults true, every /user route needs a caller already holding User.Write, and a hand written mongosh document has no password anyone can sign in with because the model hashes on write. --force updates an existing email in place, leaving options you did not pass — the password included — alone, so it is also how you grant a role. The option mapping and role parsing are pure statics, driven directly by the test rather than through a database. - gf init now appends to an existing .env instead of skipping it. The step delegated to `env --init`, which is additive by design, so it tops up the references a file lacks and leaves filled-in values alone. Skipping broke the documented bootstrap the moment it began with `cp .env.example .env`: the file existed, the application's own variables were never appended, and `gf env` then failed on the first of them. - Documented the replica-set requirement, which was written down nowhere. save/saveMany/delete/deleteMany/deleteManyBy each open a transaction when not handed a session, and MongoDB offers transactions only on a replica set or mongos — so a standalone mongod serves every read and fails every write. That is the failure a new application hits first, and it survives a smoke test because the list endpoints work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014PQDyvhd1iz7g1AF6RsCdT --- CLAUDE.md | 14 +- README.md | 1 + readme/gf.md | 37 ++++ readme/mongodb.md | 20 ++ src/cli/application.php | 1 + src/cli/commands/initCommand.php | 18 +- src/cli/commands/userCreateCommand.php | 263 +++++++++++++++++++++++ tests/Unit/Cli/InitCommandTest.php | 81 +++++++ tests/Unit/Cli/UserCreateCommandTest.php | 157 ++++++++++++++ 9 files changed, 580 insertions(+), 12 deletions(-) create mode 100644 src/cli/commands/userCreateCommand.php create mode 100644 tests/Unit/Cli/UserCreateCommandTest.php diff --git a/CLAUDE.md b/CLAUDE.md index c76fa21..7f03c9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -477,6 +477,10 @@ List routes with `gf cli:list`; debug with `gf cli /path --debug`. - Every `model` needs `public \MongoDB\BSON\ObjectId $_id;`. Every embeddable-in-an-array needs a `@var Type[]`. - Never `exit`/`die` in a controller (breaks the lifecycle + `_after` hooks); return a response. - `aggregation()` does **not** auto-apply the typemap. +- **Mongo must be a replica set.** `save`/`saveMany`/`delete`/`deleteMany`/`deleteManyBy` each open a + transaction when not handed a session, so a standalone `mongod` serves every read and fails every + write with "Transaction numbers are only allowed on a replica set member or mongos". A single + member is enough; the app template's `docker-compose.yml` starts one. - Deeply nested/mutually-referential models can infinite-loop the typemap → use `#[excludeFromTypemapWhenThisClassNotRoot]`. - There's no auth without `services.auth`; it registers a **global guard** over every @@ -564,7 +568,7 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea - **Commands** (canonical names; `gf db run` auto-resolves to `db:run`): `cli`, `cli:list`, `cert:generate-auth`, `chrome:install`, `chrome:update`, `chrome:status`, `db:run`, `env`, `init`, - `migrate`, `completion`, `completion:powershell`. Bare `gf` lists everything. + `migrate`, `user:create`, `completion`, `completion:powershell`. Bare `gf` lists everything. **Removed in v7**: `deploy` (a Release is an immutable image pinned by digest — see ADR 0002), `db:restore` (it required production credentials on every workstation), and `setup` (replaced by the non-interactive `init`, since bootstrap belongs in a scaffolding script or a devcontainer). @@ -574,6 +578,10 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea config.json knows nothing about. `--force` rewrites from config.json alone, discarding both. **`gf init --title="…"`** bootstraps a scaffolded app: title, guid, `.env`, JWT keys, chrome. **`gf migrate`** converts a v6 app — its `plan()` is a pure function of `app.json` + `environment.json`, so it is unit-tested rather than run hopefully. + **`gf user:create --email=… --roles="…"`** creates the account you sign in as, saved through the + resolved user model so the password is hashed by it. An app with `services.auth` enabled has no + other way to get its first user: `blockNewUsers` defaults true and `/user` needs `User.Write`. + `--force` updates an existing email in place, leaving options you did not pass alone. - **chrome-headless-shell**: `chrome:install`/`chrome:update` download the Chrome for Testing Stable build for the current platform into `srv/chrome/{version}/` (manifest: `srv/chrome/installation.json`; needs ext-zip; `gf init` auto-installs, `--skip-chrome` opts @@ -590,7 +598,9 @@ consuming app gets `vendor/bin/gf` (+ `gf.bat` on Windows). Full reference: `rea - **Command tiers**: no context (list/help/completion — must work anywhere, including this repo); root-only (env, db:run, cert:*, init, migrate — config JSON only, no `\app` boot); - app-boot (cli, cli:list — `assertAppLoadable()`; `\app\app::_before()` is deliberately NOT called). + app-boot (cli, cli:list, user:create — `assertAppLoadable()`; `\app\app::_before()` is deliberately + NOT called). `user:create` runs in-process — `config` bootstraps itself lazily, and only `gf cli` + needs a child process (fresh Xdebug INI, `exit()` isolation). - **`gf cli `** always spawns a fresh PHP child process (Xdebug flags need fresh INI; isolates `exit()`; interpreter picked via `--php` > `GF_PHP` > current). The interpreter must be the CLI binary — `php-cgi`/`php-fpm`/`php-win` are swapped for the diff --git a/README.md b/README.md index ba868a9..d9ccece 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ gf env --list # every variable config.json references, and wh gf env --init # write/extend the .env skeleton from config.json gf init --title="My API" # bootstrap a scaffolded app (replaces setup.ps1) gf migrate # convert a v6 application to the v7 layout +gf user:create --email=… --roles=… # create the account you sign in as (the first user) ``` Removed in v7: `deploy` (a Release is an immutable image pinned by digest — see ADR 0002), diff --git a/readme/gf.md b/readme/gf.md index d4097a1..0de73df 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -28,6 +28,7 @@ spelling also works — `gf db run` resolves to `db:run` automatically. | `gf db:run ` | ad-hoc `mongosh "" script.js` | Run a mongosh script using config-managed connections | | `gf env` | manual `Copy-Item` steps | Validate that config.json resolves; `--list` its variables; `--init` a .env skeleton | | `gf init` | `scripts/setup.ps1` | Bootstrap a freshly scaffolded application (non-interactive) | +| `gf user:create` | hand written mongosh inserts | Create the application user you sign in as | | `gf migrate` | — | Convert a v6 application's configuration to v7 | | `gf completion` / `gf completion:powershell` | — | Shell tab completion | @@ -211,6 +212,41 @@ devcontainer `postCreateCommand`, or CI. It replaces v6's `gf setup` wizard, who --- +## The first user: `gf user:create` + +``` +gf user:create --email=dev@example.test --roles="User.Read,User.Write" +gf user:create --email=dev@example.test --password="…" --name="Dev" --username=dev --roles="User.Read" +gf user:create --email=dev@example.test --roles="User.Read,User.Write,Widget.Read" --force +``` + +An application whose `config.json` enables `services.auth` starts with no way in. +`blockNewUsers` defaults to true, so only users already in the database may sign in; every `/user` +route requires a caller who already holds `User.Write`. Nothing can authenticate, so nothing can +create the first user. Writing the document by hand does not break the cycle either — the user +model hashes the password as it serialises, so a `mongosh` insert has no password anyone can sign +in with. + +- The user is saved through the model the application actually resolves — `\app\models\user` when + it defines one, otherwise the framework's Mongo user model — so hashing and every model hook run + exactly as they do when the application writes a user itself. +- **Omit `--password`** and one is generated and printed once. It is stored hashed and is not + recoverable, so pass your own when you would otherwise be copying it out of the terminal. +- `--username` defaults to the email address. `verifyUsernamePassword()` matches on username + first, so a user created without one could not sign in by username. +- An email that already exists is refused unless `--force`, which updates that user in place. On + an update, an option you do not pass is left alone — including the password — so + `--force --roles="…"` is the way to grant a role. +- Roles are not validated against anything: they are strings an application's routes compare + against. Give the first user the roles its own `requiredRoles` name, plus `User.Read` and + `User.Write` if you want it to administer other users through `services.userCrud`. + +This is an **app-boot** command and it writes to the database, so the configuration must resolve +and MongoDB must be reachable — and must be a replica set, because writing a user is a +transactional write like any other (see [mongodb.md](mongodb.md)). + +--- + ## Migrating a v6 application: `gf migrate` Converts the configuration half of a v6 application. Run it on a clean working tree so the result @@ -304,6 +340,7 @@ Useful helpers for custom commands (all in `\gcgov\framework\cli`): | `app\cli\local-debug.bat /cli/x` | `vendor/bin/gf cli /cli/x --debug` | | `scripts\create-jwt-keys.ps1` | `vendor/bin/gf cert:generate-auth` | | `scripts\setup.ps1` | `vendor/bin/gf init --title="…"` | +| hand written user inserts in `db/*.js` | `vendor/bin/gf user:create --email=… --roles="…"` | | `mongosh "mongodb://user:pass@..." db\fix.js` | `vendor/bin/gf db:run db/fix.js --env=prod` | | `update-production.ps1` | removed — deployment is a GitHub Actions workflow (ADR 0002) | | `Copy-Item composer-local.json composer.json` (+ 2 more) | nothing — config is committed and environment-variable driven (v7); `gf env` validates it | diff --git a/readme/mongodb.md b/readme/mongodb.md index f999fd4..79aa161 100644 --- a/readme/mongodb.md +++ b/readme/mongodb.md @@ -7,6 +7,26 @@ You will primarily interact with this service through extended classes that model your data structure. Data classes will extend `\gcgov\framework\services\mongodb\model` or `\gcgov\framework\services\mongodb\embedded`. +## The database must be a replica set + +Every write this service makes runs inside a transaction. `save()`, `saveMany()`, `delete()`, +`deleteMany()` and `deleteManyBy()` each start one when they are not handed a session, because a +single `save()` is already several writes — the document itself, its auto-increment counters, and +the embedded copies the dispatcher pushes into other collections — and a half-applied save is a +corrupt denormalisation rather than a failed request. + +MongoDB only offers transactions on a **replica set** or a sharded cluster. A standalone `mongod` +serves every read perfectly well and then fails every write with: + +``` +Transaction numbers are only allowed on a replica set member or mongos +``` + +which is why a standalone survives a casual smoke test — the list endpoints work, and only writing +fails. A single-member replica set is enough, and is what the application template's +`docker-compose.yml` runs locally; MongoDB Atlas and any production deployment are replica sets +already. + ## Config `{root}/config.json` (`mongoDatabases` section) ```json diff --git a/src/cli/application.php b/src/cli/application.php index 57a432a..b5e2c3e 100644 --- a/src/cli/application.php +++ b/src/cli/application.php @@ -36,6 +36,7 @@ public function __construct( ?string $composerAutoloadPath = null ) { new commands\envCommand(), new commands\initCommand(), new commands\migrateCommand(), + new commands\userCreateCommand(), new commands\completionPowershellCommand(), ] ); diff --git a/src/cli/commands/initCommand.php b/src/cli/commands/initCommand.php index 0b89a90..b44edf7 100644 --- a/src/cli/commands/initCommand.php +++ b/src/cli/commands/initCommand.php @@ -53,16 +53,14 @@ protected function execute( InputInterface $input, OutputInterface $output ): in if( !$input->getOption( 'skip-env' ) ) { $io->section( '.env' ); - if( file_exists( $context->getEnvFilePath() ) ) { - $io->text( 'Kept the existing .env. Run `gf env --list` to check it against config.json.' ); - } - else { - $contents = ( new envCommand() )->renderEnvFile( $context->configReferences() ); - if( file_put_contents( $context->getEnvFilePath(), $contents )===false ) { - throw new cliException( 'Failed writing ' . $context->getEnvFilePath() ); - } - $io->text( 'Wrote ' . $context->getEnvFilePath() . ' — fill in the values.' ); - } + // Delegated to `env --init` rather than written here, because that command is + // additive: it appends only the references the file does not already declare and + // leaves every filled-in value, and every variable config.json knows nothing + // about, alone. This step used to skip an existing .env entirely, which broke the + // documented bootstrap the moment it began with `cp .env.example .env` — the file + // existed, so the application's own variables were never appended and `gf env` + // then failed on the first of them. + $this->runSubCommand( 'env', [ '--init' => true ], $output, $io ); } if( !$input->getOption( 'skip-keys' ) ) { diff --git a/src/cli/commands/userCreateCommand.php b/src/cli/commands/userCreateCommand.php new file mode 100644 index 0000000..e928f34 --- /dev/null +++ b/src/cli/commands/userCreateCommand.php @@ -0,0 +1,263 @@ +addOption( 'email', null, InputOption::VALUE_REQUIRED, 'The user\'s email address. Also the default username.' ); + $this->addOption( 'password', null, InputOption::VALUE_REQUIRED, 'Password. Omit to have one generated and printed once.' ); + $this->addOption( 'name', null, InputOption::VALUE_REQUIRED, 'Display name' ); + $this->addOption( 'username', null, InputOption::VALUE_REQUIRED, 'Username to sign in with. Defaults to the email address.' ); + $this->addOption( 'roles', null, InputOption::VALUE_REQUIRED, 'Comma separated authorization roles, e.g. "User.Read,User.Write"' ); + $this->addOption( 'force', null, InputOption::VALUE_NONE, 'Update the existing user with this email instead of refusing' ); + $this->setHelp( <<<'HELP' + Create the account an application is signed into with. + + An application whose config.json enables services.auth starts with no way in: + blockNewUsers defaults to true, so only users already in the database may sign + in, and every /user route requires a caller already holding User.Write. Nothing + can authenticate, so nothing can create the first user. A direct mongosh insert + cannot break the cycle either — the user model hashes the password as it writes, + so a hand written document has no password anyone can sign in with. + + gf user:create --email=dev@example.test --roles="User.Read,User.Write" + + The user is saved through the model the application actually resolves — + \app\models\user when it defines one, otherwise the framework's Mongo user model + — so the password is hashed and every model hook runs exactly as they do when the + application writes a user itself. Pass the password rather than having one + generated when you would otherwise be copying it out of the terminal anyway. + + Writing a user is a transactional write, so the database must be a replica set + (or mongos). A standalone mongod fails this command, and every other write the + application makes, with "Transaction numbers are only allowed on a replica set + member or mongos". + + An email that already exists is refused unless --force, which updates that user + in place. On an update, an option you do not pass is left as it is — including + the password, so --force is safe to use to add a role. + HELP ); + } + + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $context = appContext::require(); + $context->assertAppLoadable(); + + $io = new SymfonyStyle( $input, $output ); + + $email = trim( (string)( $input->getOption( 'email' ) ?? '' ) ); + if( $email==='' ) { + throw new cliException( '--email is required.' ); + } + + /** @var class-string $userClass */ + $userClass = request::getUserClassFqdn(); + + $existing = self::findByEmail( $userClass, $email ); + if( $existing!==null && !$input->getOption( 'force' ) ) { + throw new cliException( 'A user with the email ' . $email . ' already exists. Pass --force to update it in place.' ); + } + + // Generated only for a NEW user with no --password. On an update, an omitted + // password means "leave the stored one alone" — the model unsets an empty password + // rather than writing it, so a --force run adding a role must not invent one. + $password = (string)( $input->getOption( 'password' ) ?? '' ); + $generated = $password==='' && $existing===null; + if( $generated ) { + $password = self::generatePassword(); + } + + $user = $existing ?? new $userClass(); + self::applyTo( $user, [ + 'email' => $email, + 'username' => (string)( $input->getOption( 'username' ) ?? '' ), + 'name' => (string)( $input->getOption( 'name' ) ?? '' ), + 'password' => $password, + 'roles' => $input->getOption( 'roles' )===null ? null : self::parseRoles( (string)$input->getOption( 'roles' ) ), + ] ); + + try { + $userClass::save( $user ); + } + catch( modelException $e ) { + throw new cliException( 'Saving the user failed: ' . $e->getMessage() . self::saveHint( $e ), 0, $e ); + } + + $io->success( ( $existing===null ? 'Created ' : 'Updated ' ) . $email ); + $io->text( 'id: ' . (string)$user->getId() ); + $io->text( 'username: ' . $user->getUsername() ); + $io->text( 'roles: ' . ( count( $user->getRoles() )>0 ? implode( ', ', $user->getRoles() ) : '(none — every role gated route will answer 403)' ) ); + if( $generated ) { + $io->text( 'password: ' . $password ); + $io->warning( 'This password is shown once and is not recoverable — it is stored hashed.' ); + } + + self::warnWhenNothingConsumesTheUser( $io ); + + return Command::SUCCESS; + } + + + /** + * Copy the requested values onto a user model. + * + * Pure, and deliberately typed against a plain object rather than the user interface: + * it writes the trait's public properties, which the interface only exposes as getters. + * Keeping it here rather than inline in execute() is what lets the test drive the + * mapping — role parsing, the username default, the empty-password rule — without a + * database. + * + * A null entry means "not supplied": the property is left as it is, which on an update + * preserves the stored value. An empty password is likewise left alone, because the + * model's _beforeBsonSerialize() unsets an empty password rather than storing it, and + * hashing happens there — never here, or the hash would be hashed again and no password + * would ever verify. + * + * @param array{email: string, username?: string, name?: string, password?: string, roles?: string[]|null} $options + */ + public static function applyTo( object $user, array $options ): void { + $email = trim( $options[ 'email' ] ); + + $user->email = $email; + + // The username is what verifyUsernamePassword() matches first, so a user created + // without one could never sign in by username. The email is the sensible default + // and is what every caller of this command would otherwise type twice. + $username = trim( (string)( $options[ 'username' ] ?? '' ) ); + if( $username!=='' || !isset( $user->username ) || $user->username==='' ) { + $user->username = $username!=='' ? $username : $email; + } + + $name = trim( (string)( $options[ 'name' ] ?? '' ) ); + if( $name!=='' ) { + $user->name = $name; + } + + $password = (string)( $options[ 'password' ] ?? '' ); + if( $password!=='' ) { + $user->password = $password; + } + + if( isset( $options[ 'roles' ] ) ) { + $user->roles = $options[ 'roles' ]; + } + + $user->active = true; + + // A model whose $_id is a typed, non-nullable ObjectId is read unconditionally by + // factory::save() to build its update filter, so leaving it uninitialized is a fatal + // Error rather than an insert. The framework's own user model assigns one in its + // constructor; an application model that does not still gets one here. + if( !isset( $user->_id ) ) { + $user->_id = new \MongoDB\BSON\ObjectId(); + } + } + + + /** + * Split a --roles value into role names. + * + * Pure. Empty entries are dropped so a trailing comma, or the empty string, means no + * roles rather than one role named "". + * + * @return string[] + */ + public static function parseRoles( string $roles ): array { + $parsed = []; + foreach( explode( ',', $roles ) as $role ) { + $role = trim( $role ); + if( $role!=='' && !in_array( $role, $parsed, true ) ) { + $parsed[] = $role; + } + } + + return $parsed; + } + + + /** + * The existing user with this email, or null when there is none. + * + * getOneByEmail() reports "not found" by throwing, which is the right shape for the + * request lifecycle and the wrong one here: not finding a user is this command's normal + * case. A modelException carrying any other status is a real failure and is re-thrown. + * + * @param class-string $userClass + * + * @throws \gcgov\framework\cli\cliException + */ + private static function findByEmail( string $userClass, string $email ): ?object { + try { + return $userClass::getOneByEmail( $email ); + } + catch( modelException $e ) { + if( $e->getCode()===404 ) { + return null; + } + + throw new cliException( 'Looking up ' . $email . ' failed: ' . $e->getMessage() . self::saveHint( $e ), 0, $e ); + } + } + + + /** + * The failure a developer running this on a fresh local stack is most likely to hit is + * a standalone mongod, whose driver message ("Transaction numbers are only allowed on + * a replica set member or mongos") explains what happened but not what to do about it. + */ + private static function saveHint( \Throwable $e ): string { + $message = $e->getMessage() . ( $e->getPrevious()?->getMessage() ?? '' ); + if( stripos( $message, 'transaction numbers are only allowed' )===false ) { + return ''; + } + + return ' The database is a standalone mongod, and every write this framework makes runs in a transaction. Run MongoDB as a replica set — the application template\'s docker-compose.yml starts one.'; + } + + + private static function warnWhenNothingConsumesTheUser( SymfonyStyle $io ): void { + try { + if( config::getServices()->auth===null ) { + $io->warning( 'config.json does not enable services.auth, so nothing in this application signs a user in. The account is stored, but no route will accept it.' ); + } + } + catch( \Throwable ) { + // Configuration that does not resolve cannot have got this far — the save above + // reads it. Nothing to warn about that the caller has not already seen. + } + } + + + private static function generatePassword(): string { + $password = ''; + $max = strlen( self::PASSWORD_ALPHABET ) - 1; + for( $i = 0; $itempRootDir = sys_get_temp_dir() . '/gcgov-init-test-' . uniqid(); + mkdir( $this->tempRootDir . '/vendor', 0777, true ); + mkdir( $this->tempRootDir . '/app', 0777, true ); + touch( $this->tempRootDir . '/vendor/autoload.php' ); + touch( $this->tempRootDir . '/app/app.php' ); + touch( $this->tempRootDir . '/composer.json' ); + file_put_contents( $this->tempRootDir . '/config.json', '{"app":{"title":"","guid":""},"type":"%env(APP_TYPE)%","mongoDatabases":[{"default":true,"database":"%env(MONGO_DATABASE)%","uri":"%env(secret:MONGO_URI)%"}]}' ); + + \gcgov\framework\cli\appContext::$composerAutoloadPath = $this->tempRootDir . '/vendor/autoload.php'; + } + + + protected function tearDown(): void { + \gcgov\framework\cli\appContext::$composerAutoloadPath = null; + $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $this->tempRootDir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); + foreach( $iterator as $file ) { + $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $this->tempRootDir ); + } + + + /** + * The documented bootstrap starts with `cp .env.example .env`, so by the time init runs + * the file already exists and carries the docker compose variables. init used to see it + * and skip the step entirely, which left the application's own variables — every one of + * them required — undeclared, and `gf env` then failed on the first. + */ + public function testInitAppendsToAnExistingEnvRatherThanSkippingIt(): void { + file_put_contents( $this->tempRootDir . '/.env', "# compose\nHTTP_PORT=8080\n" ); + + $this->runInit(); + + $env = (string)file_get_contents( $this->tempRootDir . '/.env' ); + self::assertStringContainsString( 'HTTP_PORT=8080', $env, 'the compose half must survive' ); + self::assertMatchesRegularExpression( '/^APP_TYPE=/m', $env ); + self::assertMatchesRegularExpression( '/^MONGO_DATABASE=/m', $env ); + self::assertMatchesRegularExpression( '/^MONGO_URI=/m', $env ); + } + + + public function testInitWritesTheEnvFileWhenThereIsNone(): void { + $this->runInit(); + + $env = (string)file_get_contents( $this->tempRootDir . '/.env' ); + self::assertMatchesRegularExpression( '/^APP_TYPE=/m', $env ); + } + + + /** A value already filled in is never rewritten. */ + public function testInitLeavesFilledInValuesAlone(): void { + file_put_contents( $this->tempRootDir . '/.env', "APP_TYPE=local\n" ); + + $this->runInit(); + + $env = (string)file_get_contents( $this->tempRootDir . '/.env' ); + self::assertStringContainsString( 'APP_TYPE=local', $env ); + self::assertSame( 1, preg_match_all( '/^APP_TYPE=/m', $env ), 'the variable must not be declared twice' ); + } + + + /** + * Driven through a real Application because the .env step is `env --init` run as a + * sub-command, which a bare CommandTester cannot resolve. + */ + private function runInit(): void { + $application = new \gcgov\framework\cli\application(); + $application->setAutoExit( false ); + $exitCode = $application->run( + new \Symfony\Component\Console\Input\ArrayInput( [ 'command' => 'init', '--title' => 'Test API', '--skip-keys' => true, '--skip-chrome' => true ] ), + new \Symfony\Component\Console\Output\NullOutput() + ); + + self::assertSame( 0, $exitCode ); + } + + public function testEmptyServiceBlocksSurviveTheRewrite(): void { $original = '{"app":{"title":"","guid":""},"services":{"userCrud":{},"documentation":{}},"appDictionary":{}}'; diff --git a/tests/Unit/Cli/UserCreateCommandTest.php b/tests/Unit/Cli/UserCreateCommandTest.php new file mode 100644 index 0000000..11cd270 --- /dev/null +++ b/tests/Unit/Cli/UserCreateCommandTest.php @@ -0,0 +1,157 @@ +newUser(); + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test' ] ); + + self::assertSame( 'dev@example.test', $user->email ); + self::assertSame( 'dev@example.test', $user->username, 'verifyUsernamePassword() matches on username first, so a user created without one could never sign in' ); + } + + + public function testAnExplicitUsernameWins(): void { + $user = $this->newUser(); + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test', 'username' => 'dev' ] ); + + self::assertSame( 'dev', $user->username ); + } + + + /** + * The model hashes in _beforeBsonSerialize(). Hashing here too would store a hash of a + * hash, and no password would ever verify — the failure would look like a wrong password. + */ + public function testThePasswordIsStoredAsPlaintextForTheModelToHash(): void { + $user = $this->newUser(); + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test', 'password' => 'correct horse' ] ); + + self::assertSame( 'correct horse', $user->password ); + } + + + /** + * An omitted password on a --force update must leave the stored one alone: the model + * unsets an empty password rather than writing it, so `--force --roles=…` is a safe way + * to add a role. + */ + public function testAnOmittedPasswordLeavesTheExistingOneAlone(): void { + $user = $this->newUser(); + $user->password = 'already-hashed'; + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test', 'password' => '' ] ); + + self::assertSame( 'already-hashed', $user->password ); + } + + + public function testOmittedRolesAreLeftAloneButAnEmptyListClearsThem(): void { + $user = $this->newUser(); + $user->roles = [ 'User.Read' ]; + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test', 'roles' => null ] ); + self::assertSame( [ 'User.Read' ], $user->roles, 'a --roles that was never passed must not silently strip a user\'s roles' ); + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test', 'roles' => [] ] ); + self::assertSame( [], $user->roles ); + } + + + public function testNameIsOnlyWrittenWhenSupplied(): void { + $user = $this->newUser(); + $user->name = 'Existing Name'; + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test' ] ); + + self::assertSame( 'Existing Name', $user->name ); + } + + + /** + * factory::save() reads the typed $_id unconditionally to build its update filter, so an + * uninitialized property is a fatal Error rather than an insert — the same fault that + * broke POST /user/new. + */ + public function testAnIdIsAssignedWhenTheModelHasNotSetOne(): void { + $user = $this->newUser(); + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test' ] ); + + self::assertTrue( isset( $user->_id ) ); + } + + + public function testAnExistingIdIsKept(): void { + $user = $this->newUser(); + $user->_id = 'keep-me'; + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test' ] ); + + self::assertSame( 'keep-me', $user->_id ); + } + + + public function testTheUserIsActivated(): void { + $user = $this->newUser(); + $user->active = false; + + userCreateCommand::applyTo( $user, [ 'email' => 'dev@example.test' ] ); + + self::assertTrue( $user->active ); + } + + + public function testRolesAreSplitTrimmedAndDeduplicated(): void { + self::assertSame( [ 'User.Read', 'User.Write' ], userCreateCommand::parseRoles( ' User.Read , User.Write ' ) ); + self::assertSame( [ 'User.Read' ], userCreateCommand::parseRoles( 'User.Read,User.Read' ) ); + } + + + /** A trailing comma is a typo, not a role named "". */ + public function testEmptyRoleEntriesAreDropped(): void { + self::assertSame( [ 'User.Read' ], userCreateCommand::parseRoles( 'User.Read,,' ) ); + self::assertSame( [], userCreateCommand::parseRoles( '' ) ); + } + + + /** + * Stands in for the user model's public properties (the userTrait ones), which is all + * applyTo() touches. Untyped so a test can leave $_id unset the way a typed ObjectId is. + */ + private function newUser(): object { + return new class { + public $_id; + public string $email = ''; + public string $username = ''; + public string $name = ''; + public string $password = ''; + /** @var string[] */ + public array $roles = []; + public bool $active = true; + }; + } + +} From 6c278042804106aedf99a3c2ca48d4672e6f257a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:59:42 +0000 Subject: [PATCH 25/30] Record the replica-set decision, and move the local-dev rules into the framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from a design review of the local development work. Five changes, each one a decision that had been made implicitly and is now written down where it will be found. - ADR 0008 records that every write opens a transaction, so MongoDB must be a replica set. This was a real property of factory::save() that nothing stated, and it is now load-bearing for every developer and every CI run. The ADR keeps the rejected alternative — open a transaction only when the save spans more than one write — because that is exactly what the next reader will propose on finding a session around a single-document write. It trades an unconditional invariant for one conditioned on attributes that change over time, and on which OTHER models embed a copy of this one, which the model being saved cannot see. - readme/local-development.md holds what any application needs in order to run locally: the replica set, fail-closed configuration, the signing keys, and the Bootstrap User. It deliberately names no compose services. The application template keeps the commands, because those are its own — and because a scaffolded application's copy of any file is frozen at Scaffold time, while this page reaches it through Composer. - CONTEXT.md gains Bootstrap and Bootstrap User. Bootstrap was listed under Scaffold's _Avoid_, which no longer holds: Scaffold is the one-time copy from the template, Bootstrap is the idempotent act of making a scaffolded application runnable, and the two are genuinely different. Bootstrap User names the account that breaks the circle a fail-closed auth posture creates — a domain concept, which is why it is here and the host/container variable split is not. - gf init's help still said "run once after scaffolding" while the command documented re-running for the guid and its .env step was just made additive. Reworded around idempotence, and it now says plainly that it cannot create the first user: nothing can be written to a database that .env does not yet describe. - gf user:create warns when settings.forceMfaForPasswordUsers is on. Such an account cannot sign in with its password alone — the first authorize returns an enrolment challenge and a token carrying no roles — and the command that created it said nothing, which is the same looks-like-success failure that readiness checks the signing keys to avoid. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014PQDyvhd1iz7g1AF6RsCdT --- CLAUDE.md | 1 + CONTEXT.md | 14 +- README.md | 4 +- ...ansactional-so-mongodb-is-a-replica-set.md | 44 +++++ readme/gf.md | 3 +- readme/local-development.md | 159 ++++++++++++++++++ readme/mongodb.md | 4 + src/cli/commands/initCommand.php | 29 ++-- src/cli/commands/userCreateCommand.php | 17 +- 9 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 docs/adr/0008-writes-are-transactional-so-mongodb-is-a-replica-set.md create mode 100644 readme/local-development.md diff --git a/CLAUDE.md b/CLAUDE.md index 7f03c9f..390ae66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -555,6 +555,7 @@ needing routes of its own puts them in `\app\router`, which already runs first i ## 15. Where to look - Full narrative + app file system: `README.md`. - Core file examples: `readme/{index.php,cli-index.php,app.php,router.php,renderer.php}.md`. +- **Running an app locally (the rules; the template holds the commands)**: `readme/local-development.md`. - **Mongo (authoritative, deep)**: `readme/mongodb.md`. - **gf CLI (authoritative)**: `readme/gf.md`. - A real, minimal consuming controller: `src/services/userCrud/controllers/user.php`. diff --git a/CONTEXT.md b/CONTEXT.md index 09660e9..16663b7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,7 +27,13 @@ _Avoid_: driver, strategy, backend, adapter **Scaffold**: The one-time act of creating a new Application from the application template. -_Avoid_: setup, bootstrap, generate +_Avoid_: setup, generate + +**Bootstrap**: +Bringing a scaffolded Application to a runnable state — naming it, giving it the values its Config +References need, and generating the keys it signs with. Unlike a Scaffold it is idempotent: it is +re-run as an Application's configuration grows, and adds only what is missing. +_Avoid_: setup, init, provisioning, first-run ### Request handling @@ -50,6 +56,12 @@ The authenticated identity for the current request, carrying its roles. Populate and absent on unauthenticated Routes. _Avoid_: current user, principal, session user +**Bootstrap User**: +The first user of an Application, created out of band because nothing else can create it. An +Application that admits only users already stored, and that gates user administration on a role, +can produce no first identity from the outside — the Bootstrap User is what breaks that circle. +_Avoid_: admin user, seed user, root account, initial user + **Controller Response**: The value a controller method returns, describing what to send and how to serialize it. Returning one is the only way a controller may end a request. diff --git a/README.md b/README.md index d9ccece..9bebf97 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,9 @@ Tab completion is available for bash/zsh/fish (`gf completion --help`) and Power (`gf completion:powershell`), including dynamic completion of the app's CLI route names. Apps and plugins can add their own gf commands via a `cli\commandProvider` class. -**See [readme/gf.md](readme/gf.md) for the full reference and the migration guide.** +**See [readme/gf.md](readme/gf.md) for the full reference and the migration guide**, and +[readme/local-development.md](readme/local-development.md) for what an application needs in order to +run on a development computer. The legacy per-app entry (`> app/cli/{env}.bat {url-path}`, `local-debug.bat` for XDebug) keeps working, but new apps should use gf. The `scripts/*.ps1` files shipped with the framework are diff --git a/docs/adr/0008-writes-are-transactional-so-mongodb-is-a-replica-set.md b/docs/adr/0008-writes-are-transactional-so-mongodb-is-a-replica-set.md new file mode 100644 index 0000000..fa5a361 --- /dev/null +++ b/docs/adr/0008-writes-are-transactional-so-mongodb-is-a-replica-set.md @@ -0,0 +1,44 @@ +# Writes are transactional, so MongoDB is a replica set everywhere + +`factory::save()`, `saveMany()`, `delete()`, `deleteMany()` and `deleteManyBy()` each open a +transaction when they are not handed a session. MongoDB offers transactions only on a replica set +or a sharded cluster, so every Environment an Application runs in — production, CI, and a +developer's machine — must provide one. A standalone `mongod` is not a supported configuration. + +The reason is that a save is not one write. A single `save()` writes the document, advances any +`#[autoIncrement]` counters, and dispatches the Model's Embedded Copies into every other collection +that holds one. A partial application of that set does not fail a request; it leaves Embedded +Copies disagreeing with the Model they copy, which is a corruption nothing detects and no later +save repairs. + +## Considered Options + +**Open a transaction only when the save spans more than one write.** A Model with no `#[foreignKey]` +Embedded Copies and no `#[autoIncrement]` field really does perform exactly one write, with nothing +to be atomic about, and skipping the session there would let a standalone `mongod` serve an +Application completely. This is the change a future reader will propose on finding a session opened +around a single-document write, so it is worth saying why it was rejected. + +It trades an unconditional invariant for a conditional one. "A save is atomic" becomes "a save is +atomic when the framework judged it needed to be" — and that judgement is made from attributes on a +class that changes over time. Adding a `#[foreignKey]` to an existing Model would silently move it +from one regime to the other, and the failure that follows is invisible in the failing request and +surfaces later as data that disagrees with itself. The condition is also not local: whether a save +is single-write depends on which *other* Models embed a copy of this one, which the Model being +saved cannot see. + +**Require a replica set only in production, and let development run standalone.** Rejected because +it makes the Environments differ in a way that hides exactly the class of bug transactions exist to +prevent: development would pass on writes production would roll back, and vice versa. + +## Consequences + +The cost falls entirely on development and CI. Production was never affected — Applications connect +to a managed cluster over `mongodb+srv://`, which is a replica set already — which is why the +constraint went unwritten until someone tried to run a scaffolded Application locally and found that +reads worked and writes did not. + +A single-member replica set satisfies it, and is what the application template's compose stack now +runs. The failure mode when it is missing is worth recognising on sight: reads succeed, so an +Application starts, answers `/health`, and lists documents; only writing fails, with *"Transaction +numbers are only allowed on a replica set member or mongos"*. diff --git a/readme/gf.md b/readme/gf.md index 0de73df..8c5870c 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -243,7 +243,8 @@ in with. This is an **app-boot** command and it writes to the database, so the configuration must resolve and MongoDB must be reachable — and must be a replica set, because writing a user is a -transactional write like any other (see [mongodb.md](mongodb.md)). +transactional write like any other (see [mongodb.md](mongodb.md)). Where this command sits in +bringing an Application up: [local-development.md](local-development.md). --- diff --git a/readme/local-development.md b/readme/local-development.md new file mode 100644 index 0000000..e3ac8b4 --- /dev/null +++ b/readme/local-development.md @@ -0,0 +1,159 @@ +# Running an Application on a development computer + +What every Application built on this framework needs in order to run locally, and why. The +**commands** belong to the Application: `gcgov/framework-app-template` ships a container stack and a +[LOCAL-DEVELOPMENT.md](https://github.com/gcgov/framework-app-template/blob/main/LOCAL-DEVELOPMENT.md) +that names its own service names, ports and routes. This page is the half that is true whatever +stack an Application runs, and it is the half that changes — it reaches you through Composer, while +a scaffolded Application's own copy of anything is frozen at Scaffold time. + +Four things stand between a fresh checkout and a request that returns data. Three of them fail in +ways that look like something else. + +--- + +## 1. The database must be a replica set + +Every write the framework makes runs inside a transaction. `save()`, `saveMany()`, `delete()`, +`deleteMany()` and `deleteManyBy()` each open one when they are not handed a session, because a +single `save()` is already several writes — the document, its `#[autoIncrement]` counters, and the +Embedded Copies the dispatcher pushes into every other collection that holds one. + +MongoDB offers transactions only on a replica set or a sharded cluster. A standalone `mongod` serves +every **read** perfectly and fails every **write**: + +``` +Transaction numbers are only allowed on a replica set member or mongos +``` + +That asymmetry is why this is the first thing to check and the last thing anyone suspects: the +Application starts, `/health` is green, list endpoints return data, and only saving fails. A +single-member set is enough: + +```bash +mongod --replSet rs0 --dbpath /path/to/data +mongosh --eval 'rs.initiate()' +``` + +Production is unaffected — a managed cluster reached over `mongodb+srv://` is a replica set already. +The reasoning, and the alternative that was rejected, are in +[ADR 0008](../docs/adr/0008-writes-are-transactional-so-mongodb-is-a-replica-set.md). + +## 2. Configuration resolves, or the Application refuses to start + +Every Config Reference in the Unified Config is required. There are no defaults, and a variable set +to the empty string counts as unset — a half-configured Application should refuse to start rather +than run in a posture nobody chose ([ADR 0001](../docs/adr/0001-fail-closed-configuration.md)). + +```bash +vendor/bin/gf env # resolve it, or name the first thing missing +vendor/bin/gf env --list # every variable, which are Secrets, which are set +``` + +Two consequences catch people out: + +- **A path is `/`, not blank.** `basePath` at the domain root is `/`. Blank is unset, and unset is a + startup failure like any other. +- **`.env` is loaded, but the real environment always wins.** Precedence is + real env > `.env.local` > `.env`. A variable exported in your shell silently outranks the file you + are editing. + +Full reference: [environment variables](environment-variables.md). + +### Variables whose correct value depends on who is reading + +When an Application runs in a container while its `gf` CLI runs on the host, some references cannot +have one correct value. A connection string names `localhost` from the host and a service name from +inside the network; a key directory is relative on one filesystem and absolute on the other. + +The rule: **`.env` carries the host's value, and the container's value is pinned where `.env` cannot +reach it** — for Docker Compose that is the service's `environment:` block, which beats `env_file:`. +One file then serves both, and neither side has to know about the other. An Application's own +documentation should say which of its variables are in this category; getting one wrong is silent, +because the host's value resolves perfectly well inside the container and simply points nowhere. + +## 3. JWT signing keys exist and the Application is pointed at them + +Signing keys are Secrets, so they are gitignored and never enter a build context or an image. They +are generated per Environment: + +```bash +vendor/bin/gf cert:generate-auth +``` + +They go to `jwtAuth.keyPath`, defaulting to `{root}/srv/jwtCertificates`. Mounting keys without +pointing the Application at them is the failure that looks like success — which is why, when +`services.auth` is enabled, readiness checks that the directory holds usable keys rather than +letting the first sign-in discover it. Regenerating keys invalidates every issued token. + +## 4. The Bootstrap User + +An Application with `services.auth` enabled starts with no way in, and the circle is closed on every +side: + +- `blockNewUsers` defaults true, so only users already stored may sign in. +- Every `/user` route from `services.userCrud` requires a caller already holding `User.Write`. +- Writing the document by hand does not help: the user model hashes the password as it serialises, + so a `mongosh` insert has no password anyone can sign in with. + +`gf user:create` is the way out. It saves through the model the Application actually resolves — +`\app\models\user` when it defines one, otherwise the framework's — so hashing and every model hook +run exactly as they do when the Application writes a user itself. + +```bash +vendor/bin/gf user:create --email=dev@example.test --roles="User.Read,User.Write" +``` + +Roles are strings a Route's `requiredRoles` compares against; nothing validates them. Give the +Bootstrap User the roles its own Routes name, plus `User.Read` and `User.Write` to administer others +through `services.userCrud`. `--force` updates an existing email in place and leaves every option +you did not pass alone, including the password — so it is also how you grant a role later. + +Creating a user is a transactional write like any other, so §1 applies: this is usually the first +command that discovers a standalone `mongod`. + +Full reference: [the gf CLI](gf.md). + +--- + +## Bootstrap, in order + +```bash +vendor/bin/gf init --title="…" # title, guid, .env, signing keys, chrome +# fill in .env +vendor/bin/gf env # does it resolve? +# start the Application +vendor/bin/gf user:create --email=… --roles="…" +``` + +`gf init` is idempotent and meant to be re-run as configuration grows — every step adds only what is +missing, and an existing guid is kept rather than reminted. It cannot create the Bootstrap User, +because nothing can be written to a database that `.env` does not yet describe. + +## Is it working? + +Every Application gets two endpoints from the framework itself, so a deploy pipeline can gate on +something no Application can forget to provide. + +| | `{basePath}/health` | `{basePath}/health/ready` | +|---|---|---| +| Answers | is this process able to serve? | should traffic be sent here right now? | +| Does I/O | no | pings every configured database; checks signing keys when `services.auth` is on | +| Backs | the container healthcheck | the deploy gate | + +They are separate deliberately. A readiness failure that also failed liveness would restart every +replica at once, turning a brief database outage into a crash loop. + +`/health/ready` answers `503` with the failing check named, which makes it the right first stop for +all three failures above: + +```json +{ "status": "ok", "version": "unknown", "checks": { "mongo:myapp": "ok", "jwtKeys": "ok" } } +``` + +`mongo:*` failing means §1 or §2. `jwtKeys` failing means §3. Neither appears in `/health`, which +stays I/O-free on purpose. + +When a request is answered by something you did not expect, set `logging.lifecycle: true` in the +Unified Config to trace routing and every Auth Guard end to end. Logs go to stderr by default, not +to `logs/*.log`. diff --git a/readme/mongodb.md b/readme/mongodb.md index 79aa161..2597abb 100644 --- a/readme/mongodb.md +++ b/readme/mongodb.md @@ -27,6 +27,10 @@ fails. A single-member replica set is enough, and is what the application templa `docker-compose.yml` runs locally; MongoDB Atlas and any production deployment are replica sets already. +The reasoning, and the conditional-transaction alternative that was considered and rejected, are in +[ADR 0008](../docs/adr/0008-writes-are-transactional-so-mongodb-is-a-replica-set.md). Running an +Application locally: [local-development.md](local-development.md). + ## Config `{root}/config.json` (`mongoDatabases` section) ```json diff --git a/src/cli/commands/initCommand.php b/src/cli/commands/initCommand.php index b44edf7..04646b2 100644 --- a/src/cli/commands/initCommand.php +++ b/src/cli/commands/initCommand.php @@ -22,19 +22,28 @@ protected function configure(): void { $this->addOption( 'skip-keys', null, InputOption::VALUE_NONE, 'Do not generate JWT signing keys' ); $this->addOption( 'skip-chrome', null, InputOption::VALUE_NONE, 'Do not download chrome-headless-shell' ); $this->setHelp( <<<'HELP' - Run once after scaffolding a project from gcgov/framework-app-template. - - Deliberately non-interactive, so it can run from a scaffolding script, a devcontainer - postCreateCommand, or CI — which is where project bootstrap belongs. It replaces the - v6 `gf setup` wizard, whose prompts filled {placeholder} tokens in php.ini and - web.config files that no longer exist. + Bring a scaffolded application to a runnable state. gf init --title="Timesheet API" - It writes the title and guid into config.json, writes a .env skeleton from the - variables config.json references, generates JWT signing keypairs, and installs - chrome-headless-shell. Everything else about an application's configuration is either - a committed literal or an environment variable you supply. + It writes the title and guid into config.json, adds the variables config.json + references to .env, generates JWT signing keypairs, and installs chrome-headless-shell. + Everything else about an application's configuration is either a committed literal or + an environment variable you supply. + + **Idempotent**, and meant to be re-run as an application's configuration grows: every + step adds only what is missing. .env keeps every value already filled in and every + variable config.json does not reference, and an existing guid is kept rather than + reminted — it is the OAuth client_id, so a new one would invalidate every registered + client. + + Deliberately non-interactive, so it can run from a scaffolding script, a devcontainer + postCreateCommand, or CI. It replaces the v6 `gf setup` wizard, whose prompts filled + {placeholder} tokens in php.ini and web.config files that no longer exist. + + It does not create the application's first user: nothing can be written to the + database until .env carries a connection string, which is a step later. See + `gf user:create`. HELP ); } diff --git a/src/cli/commands/userCreateCommand.php b/src/cli/commands/userCreateCommand.php index e928f34..79d45cf 100644 --- a/src/cli/commands/userCreateCommand.php +++ b/src/cli/commands/userCreateCommand.php @@ -114,7 +114,7 @@ protected function execute( InputInterface $input, OutputInterface $output ): in $io->warning( 'This password is shown once and is not recoverable — it is stored hashed.' ); } - self::warnWhenNothingConsumesTheUser( $io ); + self::warnAboutSignIn( $io ); return Command::SUCCESS; } @@ -237,10 +237,23 @@ private static function saveHint( \Throwable $e ): string { } - private static function warnWhenNothingConsumesTheUser( SymfonyStyle $io ): void { + /** + * What stands between this account and a working sign-in, when it is not simply "nothing". + * + * Both cases are ones where the command succeeds and the account still cannot be used, which + * is the failure worth saying out loud — the same reason readiness checks the signing keys + * rather than letting an unusable deployment report itself healthy. + */ + private static function warnAboutSignIn( SymfonyStyle $io ): void { try { if( config::getServices()->auth===null ) { $io->warning( 'config.json does not enable services.auth, so nothing in this application signs a user in. The account is stored, but no route will accept it.' ); + + return; + } + + if( config::getSettings()->forceMfaForPasswordUsers ) { + $io->warning( 'settings.forceMfaForPasswordUsers is on, so this account cannot sign in with its password alone. The first POST /auth/authorize returns an MFA enrolment challenge and a token carrying NO roles; the account can do nothing until it completes POST /auth/verifyMfaSecret and then POST /auth/verifyMfaCode.' ); } } catch( \Throwable ) { From 71b54c1fcc3dbcaef9b268fd40d22967eebbda61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:21:04 +0000 Subject: [PATCH 26/30] Fix four unit tests that only fail off Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four are pre-existing and environment-dependent — nothing in the local development work touched config paths, phpProcess, or the route runner. Two distinct causes. Three of them compared a temp root built from sys_get_temp_dir(), which on Windows carries backslashes, against accessors that normalise separators (configLoader::configFilePath, unifiedConfig::getJwtKeyPath). The fixture was wrong, and wrong in a way worth naming: config::$rootDir is only ever reached through setAppDir(), which forward-slashes it, or through appContext::normalize(), which does the same — so a backslash root cannot occur at runtime and ConfigTest was manufacturing one with reflection. Both fixtures now normalise, matching the pattern AppContextTest already uses. Normalising the fixture would leave configFilePath's own test vacuous on a system whose temp path has no backslashes, so it now asserts the contract outright against a literal C:\app. The fourth proved that $argv survived by checking the missing autoload path appeared in the child's output — which happened only because PHP displayed the fatal from require. Whether it does is entirely the host php.ini's business: with display_errors Off and error_log naming a file, both ordinary on a server, the child exits 255 having printed nothing anywhere the caller looks. That is a real gap rather than an unlucky assertion. `gf cli` is what Task Scheduler and cron run, and a wrong vendor path currently fails silently. run-route.php now checks the autoloader itself and reports through the $gfWriteError closure it already uses for a missing $argv and an undefined STDERR — the same class of problem, previously unhandled — exiting 2, the code it already gives every other bad invocation. The test asserts that exit code, so its evidence no longer depends on how the host reports errors. Verified on Linux by reproducing both failures: run-route.php under `-ddisplay_errors=0 -derror_log=` produced empty output and exit 255, and now names the path and exits 2; injecting a backslash root into config reproduced the two reported expected/actual strings exactly, and the normalised fixture matches. The Windows workstation is still the only place the originals can be confirmed fixed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014PQDyvhd1iz7g1AF6RsCdT --- src/cli/internal/run-route.php | 11 +++++++++++ tests/Unit/Cli/RunRouteScriptTest.php | 10 ++++++++-- tests/Unit/ConfigTest.php | 8 +++++++- tests/Unit/Services/Environment/ConfigLoaderTest.php | 9 ++++++++- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/cli/internal/run-route.php b/src/cli/internal/run-route.php index 4a08593..6c25108 100644 --- a/src/cli/internal/run-route.php +++ b/src/cli/internal/run-route.php @@ -49,6 +49,17 @@ exit( 2 ); } +// Checked rather than left to require's fatal, which this process cannot rely on being +// seen. Whether that fatal reaches the caller depends entirely on the host php.ini: +// display_errors is Off in php.ini-production, and error_log usually names a file, so the +// message goes to that file and the child exits 255 having printed nothing at all. `gf cli` +// is what Task Scheduler and cron run, so "failed, no diagnostic" is the one outcome this +// script exists to prevent — it already guards $argv and STDERR for the same reason. +if( !is_file( $gfArguments[ 1 ] ) ) { + $gfWriteError( 'the composer autoloader was not found at "' . $gfArguments[ 1 ] . '". Run `composer install` in the application root, or point gf at the right application.' ); + exit( 2 ); +} + require $gfArguments[ 1 ]; $_SERVER[ 'REQUEST_METHOD' ] = 'CLI'; diff --git a/tests/Unit/Cli/RunRouteScriptTest.php b/tests/Unit/Cli/RunRouteScriptTest.php index 50ae5be..1e3bd0e 100644 --- a/tests/Unit/Cli/RunRouteScriptTest.php +++ b/tests/Unit/Cli/RunRouteScriptTest.php @@ -59,8 +59,14 @@ public function testRequiredIniFlagsRestoreArgumentsWhenPhpIniDisablesThem(): vo $output = $process->getErrorOutput() . $process->getOutput(); - // Arguments were readable: the script got past its argument checks and tried to - // require the (deliberately missing) autoloader. + // Arguments were readable: the script got past its argument checks and reached the + // (deliberately missing) autoloader. + // + // The script reports that itself rather than letting require's fatal stand in for it. + // Whether that fatal reaches us depends on the host php.ini — with display_errors Off + // and error_log naming a file, which is an ordinary server configuration, the child + // prints nothing at all and this assertion had nothing to match. + $this->assertSame( 2, $process->getExitCode() ); $this->assertStringNotContainsString( 'register_argc_argv', $output ); $this->assertStringNotContainsString( 'usage: php run-route.php', $output ); $this->assertStringContainsString( 'nonexistent-autoload.php', $output ); diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index c1267dd..6030121 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -15,7 +15,13 @@ final class ConfigTest extends TestCase { private string $tempRootDir = ''; protected function setUp(): void { - $this->tempRootDir = sys_get_temp_dir() . '/gcgov-config-test-' . uniqid(); + // Forward slashes, because that is the only shape config ever holds: setAppDir() + // normalises the separators it reflects out of \app\app, and the gf CLI reaches the + // same field through appContext::normalize(). Injecting sys_get_temp_dir() raw would + // put a backslash root into a private static that cannot hold one at runtime, and the + // accessors that normalise — getConfigFilePath(), getJwtKeyPath() — would then + // disagree with the fixture on Windows and nowhere else. + $this->tempRootDir = str_replace( '\\', '/', sys_get_temp_dir() ) . '/gcgov-config-test-' . uniqid(); mkdir( $this->tempRootDir . '/app/config', 0777, true ); $rootProp = new \ReflectionProperty( config::class, 'rootDir' ); diff --git a/tests/Unit/Services/Environment/ConfigLoaderTest.php b/tests/Unit/Services/Environment/ConfigLoaderTest.php index ece3886..1369d17 100644 --- a/tests/Unit/Services/Environment/ConfigLoaderTest.php +++ b/tests/Unit/Services/Environment/ConfigLoaderTest.php @@ -26,7 +26,9 @@ final class ConfigLoaderTest extends TestCase { protected function setUp(): void { $this->envSnapshot = $_ENV; $this->serverSnapshot = $_SERVER; - $this->tempDir = sys_get_temp_dir() . '/gcgov-configloader-test-' . uniqid(); + // Forward slashes: configFilePath() normalises separators, so a raw sys_get_temp_dir() + // makes the fixture disagree with it on Windows and nowhere else. + $this->tempDir = str_replace( '\\', '/', sys_get_temp_dir() ) . '/gcgov-configloader-test-' . uniqid(); mkdir( $this->tempDir, 0777, true ); dotEnvLoader::resetForTesting(); } @@ -53,6 +55,11 @@ private function writeConfig( array $config ): void { public function testConfigFilePathIsRootConfigJson(): void { $this->assertSame( $this->tempDir . '/config.json', configLoader::configFilePath( $this->tempDir ) ); $this->assertSame( 'config.json', configLoader::FILE_NAME ); + + // Stated rather than left to the platform: the assertion above cannot exercise the + // normalisation on a system whose temp path has no backslashes to normalise. + $this->assertSame( 'C:/app/config.json', configLoader::configFilePath( 'C:\\app' ) ); + $this->assertSame( 'C:/app/config.json', configLoader::configFilePath( 'C:\\app\\' ) ); } From 4334e8cd24fe16e18ce8d6d5fc4ffc98f3f9d653 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:28:13 +0000 Subject: [PATCH 27/30] Capture the log records tests provoke, instead of printing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing was failing. A green run printed four JSON log records — a router warning, two readiness warnings with a driver stack trace, and two records from LogTest — because log's default destination is stderr and these tests exercise paths that log. Output that reads like failure on a passing run is worth removing on its own; it also buries the failures that are real. A new capturesFrameworkLog trait swaps a channel's logger for a Monolog TestHandler, so the records land somewhere the test can read. Restoration hangs off #[After] rather than tearDown(), which lets it compose with seedsFrameworkConfig — that trait already defines one. Swallowing the records would have been the easy fix and the wrong one, so each is now asserted: - Readiness deliberately withholds the database host and port from an unauthenticated 503. That detail has to reach the operator somewhere, so the test now pins that the log names the failing database — and, for the key check, that it names the command that fixes it. - A role-gated route with nobody authenticated refuses with 401 AND explains why. Those are one behaviour: a 401 whose cause is unlogged sends an application developer hunting through the guard chain for a route that is simply unguarded. The test moves from expectException to try/catch so it can assert both halves. LogTest's two destination tests now assert the handlers rather than logging. That is not just about noise — testStderrIsTheDefaultDestinationAndEmitsJsonLines never asserted anything about JSON lines. It logged a record and checked only that no file appeared, which is equally true of implementations that are broken in several interesting ways. It now checks that the stderr destination builds exactly one StreamHandler on php://stderr with a JsonFormatter, and that "both" adds the file handler alongside it. Monolog opens streams lazily, so building the handlers writes nothing and creates no file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014PQDyvhd1iz7g1AF6RsCdT --- tests/Support/capturesFrameworkLog.php | 76 +++++++++++++++++++ tests/Unit/RequiredRolesTest.php | 22 +++++- .../Services/Health/HealthControllerTest.php | 9 +++ tests/Unit/Services/LogTest.php | 41 ++++++++-- tests/bootstrap.php | 1 + 5 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 tests/Support/capturesFrameworkLog.php diff --git a/tests/Support/capturesFrameworkLog.php b/tests/Support/capturesFrameworkLog.php new file mode 100644 index 0000000..a661678 --- /dev/null +++ b/tests/Support/capturesFrameworkLog.php @@ -0,0 +1,76 @@ + channel => the logger that was cached before */ + private array $capturedFrameworkLogChannels = []; + + + /** + * Route one channel's records into a TestHandler for the rest of this test. + * + * The channel is the first argument to log::warning() and friends at the call site + * under test — 'health', 'Framework Lifecycle', and so on. + */ + protected function captureLog( string $channel ): TestHandler { + $loggers = new \ReflectionProperty( log::class, 'loggers' ); + /** @var array $current */ + $current = $loggers->getValue(); + + if( !array_key_exists( $channel, $this->capturedFrameworkLogChannels ) ) { + $this->capturedFrameworkLogChannels[ $channel ] = $current[ $channel ] ?? null; + } + + $handler = new TestHandler(); + $current[ $channel ] = new Logger( $channel, [ $handler ] ); + $loggers->setValue( null, $current ); + + return $handler; + } + + + #[After] + protected function restoreCapturedFrameworkLog(): void { + if( count( $this->capturedFrameworkLogChannels )===0 ) { + return; + } + + $loggers = new \ReflectionProperty( log::class, 'loggers' ); + /** @var array $current */ + $current = $loggers->getValue(); + + foreach( $this->capturedFrameworkLogChannels as $channel => $previous ) { + if( $previous===null ) { + unset( $current[ $channel ] ); + } + else { + $current[ $channel ] = $previous; + } + } + $loggers->setValue( null, $current ); + + $this->capturedFrameworkLogChannels = []; + } + +} diff --git a/tests/Unit/RequiredRolesTest.php b/tests/Unit/RequiredRolesTest.php index 6f53221..f5096a6 100644 --- a/tests/Unit/RequiredRolesTest.php +++ b/tests/Unit/RequiredRolesTest.php @@ -8,6 +8,7 @@ use gcgov\framework\models\authUser; use gcgov\framework\models\routeHandler; use gcgov\framework\router; +use gcgov\framework\tests\Support\capturesFrameworkLog; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -25,6 +26,8 @@ #[CoversClass(router::class)] final class RequiredRolesTest extends TestCase { + use capturesFrameworkLog; + protected function tearDown(): void { // the authenticated user is a singleton; leave it as the next test expects to find it authUser::getInstance()->setFromJwtToken( [], [] ); @@ -44,10 +47,21 @@ public function testRouteWithoutRolesPassesEvenWithNoUser(): void { * previously sailed through — the only role check in the codebase was never reached. */ public function testRolesDeclaredWithNoUserEstablishedIs401(): void { - $this->expectException( routeException::class ); - $this->expectExceptionCode( 401 ); - - $this->assertRoles( $this->handler( [ 'User.Read' ] ) ); + $log = $this->captureLog( 'Framework Lifecycle' ); + + // try/catch rather than expectException: the refusal and the diagnostic that goes + // with it are one behaviour. A 401 whose cause is unlogged sends an application + // developer hunting through the guard chain for a route that is simply unguarded. + try { + $this->assertRoles( $this->handler( [ 'User.Read' ] ) ); + $this->fail( 'a role-gated route with no authenticated user must be refused' ); + } + catch( routeException $e ) { + $this->assertSame( 401, $e->getCode() ); + } + + $this->assertTrue( $log->hasWarningThatContains( 'User.Read' ), 'the log must name the role that was required' ); + $this->assertTrue( $log->hasWarningThatContains( 'services.auth' ), 'and what to do about it' ); } diff --git a/tests/Unit/Services/Health/HealthControllerTest.php b/tests/Unit/Services/Health/HealthControllerTest.php index 323533a..5b27d9f 100644 --- a/tests/Unit/Services/Health/HealthControllerTest.php +++ b/tests/Unit/Services/Health/HealthControllerTest.php @@ -7,6 +7,7 @@ use gcgov\framework\models\config\environment\mongoDatabase; use gcgov\framework\models\unifiedConfig; use gcgov\framework\services\health\controllers\health; +use gcgov\framework\tests\Support\capturesFrameworkLog; use gcgov\framework\tests\Support\seedsFrameworkConfig; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -20,6 +21,7 @@ final class HealthControllerTest extends TestCase { use seedsFrameworkConfig; + use capturesFrameworkLog; protected function setUp(): void { putenv( 'APP_VERSION' ); @@ -70,6 +72,7 @@ public function testReadinessIsOkWithNoConfiguredDatabases(): void { * unauthenticated endpoint. */ public function testUnreachableDatabaseIs503AndDisclosesNothing(): void { + $log = $this->captureLog( 'health' ); $this->seedConfig( static function( unifiedConfig $c ): void { $database = new mongoDatabase(); $database->default = true; @@ -91,6 +94,10 @@ public function testUnreachableDatabaseIs503AndDisclosesNothing(): void { $this->assertIsString( $serialized ); $this->assertStringNotContainsString( '192.0.2.1', $serialized, 'the probe must not publish the database host' ); $this->assertStringNotContainsString( '27017', $serialized, 'the probe must not publish the database port' ); + + // The detail the response withholds has to go somewhere, or the operator has a 503 + // and nothing to act on. It belongs in the log, which is not public. + $this->assertTrue( $log->hasWarningThatContains( 'appdb' ), 'the failing database must be named in the log' ); } @@ -100,6 +107,7 @@ public function testUnreachableDatabaseIs503AndDisclosesNothing(): void { * green — and surface only as a configException on the first production sign-in. */ public function testMissingJwtKeysFailReadinessWhenAuthIsEnabled(): void { + $log = $this->captureLog( 'health' ); $this->seedConfig( static function( unifiedConfig $c ): void { $c->services->auth = new \gcgov\framework\models\config\services\auth(); $c->jwtAuth->keyPath = sys_get_temp_dir() . '/gcgov-health-missing-keys-' . uniqid(); @@ -109,6 +117,7 @@ public function testMissingJwtKeysFailReadinessWhenAuthIsEnabled(): void { $this->assertSame( 503, $response->getHttpStatus() ); $this->assertSame( 'failed', $response->getData()[ 'checks' ][ 'jwtKeys' ] ); + $this->assertTrue( $log->hasWarningThatContains( 'cert:generate-auth' ), 'the log must name the command that fixes it' ); } diff --git a/tests/Unit/Services/LogTest.php b/tests/Unit/Services/LogTest.php index 279cbf5..ab39fa8 100644 --- a/tests/Unit/Services/LogTest.php +++ b/tests/Unit/Services/LogTest.php @@ -8,6 +8,8 @@ use PHPUnit\Framework\TestCase; use gcgov\framework\models\config\environment\logging; use gcgov\framework\services\log; +use Monolog\Formatter\JsonFormatter; +use Monolog\Handler\StreamHandler; #[CoversClass(log::class)] final class LogTest extends TestCase { @@ -98,26 +100,53 @@ public function testRepeatedCallsReuseSameLoggerInstance(): void { /** * The v7 default. A container's filesystem does not survive a deploy, so file logs * would be per-replica and destroyed on every release. + * + * Asserted against the handlers rather than by logging: this test used to call + * log::error() and then check only that no FILE appeared, which is true of a great many + * broken implementations and said nothing at all about JSON lines. What it did reliably + * do was print a record to the console on every green run. */ public function testStderrIsTheDefaultDestinationAndEmitsJsonLines(): void { $this->setDestination( logging::DESTINATION_STDERR ); - $before = $this->logsDir . '/stderr-channel.log'; - log::error( 'stderr-channel', 'to stderr' ); + $handlers = self::buildHandlers( 'stderr-channel' ); + + $this->assertCount( 1, $handlers, 'the stderr destination adds no file handler' ); + $this->assertInstanceOf( StreamHandler::class, $handlers[ 0 ] ); + $this->assertSame( 'php://stderr', $handlers[ 0 ]->getUrl() ); + $this->assertInstanceOf( JsonFormatter::class, $handlers[ 0 ]->getFormatter(), 'a collector has to be able to query the records' ); - $this->assertFileDoesNotExist( $before, 'stderr destination must not write a log file' ); $this->assertSame( logging::DESTINATION_STDERR, ( new logging() )->destination ); $this->assertTrue( ( new logging() )->writesToStderr() ); $this->assertFalse( ( new logging() )->writesToFile() ); } - public function testBothDestinationWritesTheFileAsWell(): void { + /** "both" is the stderr handler plus the file handler, not one or the other. */ + public function testBothDestinationAddsTheFileHandlerAlongsideStderr(): void { $this->setDestination( logging::DESTINATION_BOTH ); - log::error( 'both-channel', 'to both' ); + $handlers = self::buildHandlers( 'both-channel' ); + $urls = array_map( static fn( StreamHandler $h ): ?string => $h->getUrl(), $handlers ); + + $this->assertCount( 2, $handlers ); + $this->assertContains( 'php://stderr', $urls ); + $this->assertContains( $this->logsDir . '/both-channel.log', $urls ); + } + + + /** + * Monolog opens a stream lazily, so building the handlers writes nothing and creates + * no file — which is what lets these two tests assert the wiring without emitting a + * record. + * + * @return \Monolog\Handler\StreamHandler[] + */ + private static function buildHandlers( string $channel ): array { + /** @var \Monolog\Handler\StreamHandler[] $handlers */ + $handlers = ( new \ReflectionMethod( log::class, 'buildHandlers' ) )->invoke( null, $channel ); - $this->assertFileExists( $this->logsDir . '/both-channel.log' ); + return $handlers; } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index afc1343..5f533b6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,6 +13,7 @@ // Shared test helpers. tests/ is not PSR-4 autoloaded, so they are required here. require __DIR__ . '/Support/seedsFrameworkConfig.php'; +require __DIR__ . '/Support/capturesFrameworkLog.php'; // Several framework call sites reflect on \app\app to derive directories. // Stub the class so tests that touch config::getAppDir() can boot. From 7633428c8a378ae4d5e37d3bc3d5c9b54f7ebca7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 17:26:44 +0000 Subject: [PATCH 28/30] Say how real data reaches a development computer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gf db:restore` was removed in v7 and readme/gf.md pointed at "the separate backup-restore workflow" for what replaced it. Nothing described that workflow, so this writes it down where the other local-development rules live. Three things hold for every application, whatever stack it runs: a workstation reads no other Environment's database, so a dump file travels instead of the credentials; a restored account carries a hash and no password anyone knows, so `gf user:create --force` is still the way in; and a dump of an encrypted collection is ciphertext to a computer without the keys. gcgov/framework-app-template now ships one implementation of it — a mongo-restore container reading db/backup/{DatabaseName} — and the section links to it, since the commands belong to the application. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Rw19sb82Jy2GQTLdd7N8f7 --- readme/gf.md | 5 +++-- readme/local-development.md | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/readme/gf.md b/readme/gf.md index 8c5870c..24e59b0 100644 --- a/readme/gf.md +++ b/readme/gf.md @@ -160,8 +160,9 @@ Requires `mongosh` on PATH. Connection details come from `config.json`'s `mongoD is redacted in all output. > **`gf db:restore` was removed in v7.** It pulled another Environment's databases down to a -> workstation, which meant every developer's `.env` held production credentials. Developers get -> realistic data from the separate backup-restore workflow instead. +> workstation, which meant every developer's `.env` held production credentials. A dump file +> travels instead of the credentials — see +> [Data to work with](local-development.md#data-to-work-with). --- diff --git a/readme/local-development.md b/readme/local-development.md index e3ac8b4..7f02e44 100644 --- a/readme/local-development.md +++ b/readme/local-development.md @@ -157,3 +157,41 @@ stays I/O-free on purpose. When a request is answered by something you did not expect, set `logging.lifecycle: true` in the Unified Config to trace routing and every Auth Guard end to end. Logs go to stderr by default, not to `logs/*.log`. + +--- + +## Data to work with + +An empty database is a poor way to work on an Application that holds years of documents. A copy of +real data is the usual answer, and the framework takes no part in producing one — but three things +about that copy are true of every Application built on this framework. + +**A development computer never reads another Environment's database.** v6 shipped `gf db:restore`, +which pulled one down over the network. Every developer's configuration then held production +credentials, so v7 removed the command along with the committed connection details it needed. A +file replaced it: someone who already has access dumps the database, and the dump travels instead +of the credentials. Nothing on the workstation points anywhere but at its own database. + +**A restored account is not a way in by itself.** The user model hashes a password as it +serializes, so a restored account carries a hash and nothing else — you can sign in as a user whose +password you already know, and as nobody else. That is the closed circle §4 describes, reached by a +different route. `gf user:create --force` sets a password on an account that already exists and +leaves every option you did not pass alone. + +```bash +vendor/bin/gf user:create --force --email=dev@example.test --roles="User.Read,User.Write" +``` + +**A dump of an encrypted collection is ciphertext.** Documents in a collection with queryable +encryption decrypt through the KMS keys that Environment's configuration names. A development +computer without those keys restores the documents and reads nothing in the encrypted fields. Seed +those collections rather than restore them. + +The backup holds whatever the source database holds, so the rules that govern the source database +govern it too: keep it out of git, keep it off shared disks, and keep it on the workstation no +longer than the work needs it. + +`gcgov/framework-app-template` ships one implementation of all this — a one-shot `mongo-restore` +container that restores `db/backup/{DatabaseName}` into the development database over the compose +network, with no MongoDB tools on the host. See its +[LOCAL-DEVELOPMENT.md](https://github.com/gcgov/framework-app-template/blob/main/LOCAL-DEVELOPMENT.md). From 4bb7284762496351b022b1a90e09390f6ea20da6 Mon Sep 17 00:00:00 2001 From: gcgov deploy Date: Tue, 8 Sep 2026 11:57:30 +0000 Subject: [PATCH 29/30] docs(adr): move operational ADRs to gcgov/deploy and renumber 0001-0004 Four ADRs carrying the county's operational threat model (secrets, runners, DNS-01, Key Vault) move out of this public repository into the gcgov/deploy Ops Repo. The ADRs that stay are renumbered into a clean 0001-0004 sequence: - 0001 fail-closed configuration (unchanged) - 0002 immutable Release, pinned by digest (unchanged) - 0003 Framework Services are built in (was 0005) - 0004 writes are transactional; Mongo replica set (was 0008) Add docs/adr/README.md with the mapping and citation rule, refresh the ADR index and citations in CLAUDE.md and the readme/ files. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NzByhoDp7hsD39aoThJ9rv --- CLAUDE.md | 11 +-- ...ices-are-built-in-and-config-activated.md} | 0 ...secrets-never-decrypt-in-ci-or-on-hosts.md | 38 ---------- docs/adr/0004-self-hosted-runners-per-zone.md | 29 -------- ...nsactional-so-mongodb-is-a-replica-set.md} | 0 ...pt-dns-01-on-a-shared-registered-domain.md | 68 ------------------ ...y-vault-per-zone-for-deployment-secrets.md | 72 ------------------- docs/adr/README.md | 36 ++++++++++ readme/app.php.md | 2 +- readme/local-development.md | 2 +- readme/mongodb.md | 2 +- 11 files changed, 45 insertions(+), 215 deletions(-) rename docs/adr/{0005-framework-services-are-built-in-and-config-activated.md => 0003-framework-services-are-built-in-and-config-activated.md} (100%) delete mode 100644 docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md delete mode 100644 docs/adr/0004-self-hosted-runners-per-zone.md rename docs/adr/{0008-writes-are-transactional-so-mongodb-is-a-replica-set.md => 0004-writes-are-transactional-so-mongodb-is-a-replica-set.md} (100%) delete mode 100644 docs/adr/0006-lets-encrypt-dns-01-on-a-shared-registered-domain.md delete mode 100644 docs/adr/0007-azure-key-vault-per-zone-for-deployment-secrets.md create mode 100644 docs/adr/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 390ae66..426bd15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -502,7 +502,7 @@ List routes with `gf cli:list`; debug with `gf cli /path --debug`. Framework Services ship **inside** the framework (`src/services/`). Enable one by adding its block to the `services` section of `config.json` — presence enables, and the block's contents are its settings, so -activation and configuration are one statement. See ADR 0005. +activation and configuration are one statement. See ADR 0003. | Config key | Namespace | Adds | |------------|-----------|------| @@ -523,7 +523,7 @@ framework declares a `conflict` against all five, so a v7 application cannot ins --- ## 13. Adding a new Framework Service -Services live in this repository; there is no out-of-tree extension point (ADR 0005). An application +Services live in this repository; there is no out-of-tree extension point (ADR 0003). An application needing routes of its own puts them in `\app\router`, which already runs first in the guard chain. - Code in `src/services//`, namespace `\gcgov\framework\services\`. @@ -638,6 +638,7 @@ Single-context: one root `CONTEXT.md` plus `docs/adr/`. See `docs/agents/domain. isolation boundary) are different things, and that v6's "environment variant" no longer exists. ADRs recorded so far: 0001 fail-closed configuration · 0002 immutable Release pinned by digest · -0003 secrets never decrypt in CI or on hosts · 0004 one self-hosted runner per Zone · -0005 Framework Services are built in and config-activated · 0006 Let's Encrypt DNS-01 on one -registered domain every Zone shares · 0007 Azure Key Vault per Zone for deployment secrets. +0003 Framework Services are built in and config-activated · 0004 writes are transactional so +MongoDB is a replica set. The four operational ADRs (secrets never decrypt, one runner per Zone, +Let's Encrypt DNS-01, Azure Key Vault) moved to `gcgov/deploy` in the v7 review — see +`docs/adr/README.md` for the old-to-new mapping. diff --git a/docs/adr/0005-framework-services-are-built-in-and-config-activated.md b/docs/adr/0003-framework-services-are-built-in-and-config-activated.md similarity index 100% rename from docs/adr/0005-framework-services-are-built-in-and-config-activated.md rename to docs/adr/0003-framework-services-are-built-in-and-config-activated.md diff --git a/docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md b/docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md deleted file mode 100644 index 8e8fb88..0000000 --- a/docs/adr/0003-secrets-never-decrypt-in-ci-or-on-hosts.md +++ /dev/null @@ -1,38 +0,0 @@ -# Production secrets never decrypt in CI, and hosts hold no decryption key - -> **Amended twice since acceptance.** The decision below — operator-workstation decryption, -> no key on a host or in CI — stands unchanged. Two details in its summary do not: -> -> - **The wrapping key is Azure Key Vault, one vault and one key per Zone**, not GCP KMS. -> Superseded by ADR 0007. (MongoDB queryable encryption keeps its own Cloud KMS key in -> GCP; that is a different key and is untouched.) -> - **The plaintext lands in `/etc/gcgov/secrets//` on the host**, not -> `/run/secrets`. `/run` is a tmpfs, so anything written there is gone after a reboot and -> every container fails to start on the way back up. `/run/secrets/` is what -> the *container* sees — the bind-mount target, not the host path. - -Secrets live SOPS-encrypted in the `gcgov/deploy` Ops Repo, encrypted to a **GCP KMS key per Zone** -plus one offline age key held as break-glass. An operator decrypts on their own workstation and -writes the plaintext to the host as files under `/run/secrets` — a **Provisioning** step deliberately -separate from deploying. GitHub Actions never decrypts anything, and no host holds a key that could. - -## Considered Options - -A root-owned `.env` per host was simpler but keeps every secret in the process environment, visible -through `docker inspect` and `/proc//environ`. Holding the SOPS key as an Actions secret would -have let CI decrypt, putting every production credential into GitHub's blast radius and into runner -memory — which would have made SOPS strictly worse than the `.env` it replaced, since the ceremony -would be there without the isolation. Pure age keyfiles were rejected because offboarding becomes a -re-encryption exercise with no record of what the departing operator ever decrypted; KMS makes it an -IAM revocation against an audit log. KMS *alone* was rejected because it puts a network round trip to -Google on the critical path for restarting a container. - -## Consequences - -- The Ops Repo's read access is equivalent to access to every credential it has ever held, because - `git log -p` exposes historical values. Encryption protects the repository's contents, not its - history from its own readers. -- Rotating a secret is two deliberate steps (provision, then deploy) rather than one automatic one. -- CI cannot run tests that need real credentials. Integration tests use throwaway ones. -- Offboarding is revoke **and** rotate. Credentials are therefore scoped per Application per Zone, - with a `-g{n}` generation suffix so old and new can coexist during a rotation. diff --git a/docs/adr/0004-self-hosted-runners-per-zone.md b/docs/adr/0004-self-hosted-runners-per-zone.md deleted file mode 100644 index 0a841c6..0000000 --- a/docs/adr/0004-self-hosted-runners-per-zone.md +++ /dev/null @@ -1,29 +0,0 @@ -# One self-hosted runner per Zone, on a dedicated host, without Docker access - -Each Zone has its own ephemeral self-hosted GitHub Actions runner, registered to the `gcgov/deploy` -Ops Repo only, living on a small host of its own with **no Docker socket**. It deploys to the -Application hosts over SSH using a forced-command key that can run only `deploy `. - -## Considered Options - -An internal-only Zone cannot accept inbound SSH from GitHub, so push-based deployment does not reach -it. A pull agent on each host watching the registry would work but loses the health gate and the -"did my deploy land" answer, and would give internal Applications a second deployment mechanism to -debug. Runners let Actions drive every Zone identically over outbound connections only. - -The isolation choices exist because a self-hosted runner executes workflow code inside the Zone: - -- **Ops Repo only.** Registering to app repositories would give the contributors of thirty - repositories code execution inside the network. Applications fire a `repository_dispatch` at the - Ops Repo with an image digest; the Ops Repo runs its own trusted workflow. This cannot use - `workflow_call`, which executes in the caller's context and would erase the boundary. -- **Ephemeral.** A persistent runner lets one poisoned job leave something behind for the next. -- **No Docker socket.** Access to the socket is root on that host, with no partial version. Keeping - the runner off the Application hosts means runner compromise is bounded by what the forced command - permits, rather than being equivalent to owning the Zone. - -## Consequences - -- Three additional small hosts to build and patch. -- The image digest arriving by dispatch is untrusted input and must be validated, not interpolated. -- Deploys are gated on a protected GitHub Environment, which doubles as the deploy approval. diff --git a/docs/adr/0008-writes-are-transactional-so-mongodb-is-a-replica-set.md b/docs/adr/0004-writes-are-transactional-so-mongodb-is-a-replica-set.md similarity index 100% rename from docs/adr/0008-writes-are-transactional-so-mongodb-is-a-replica-set.md rename to docs/adr/0004-writes-are-transactional-so-mongodb-is-a-replica-set.md diff --git a/docs/adr/0006-lets-encrypt-dns-01-on-a-shared-registered-domain.md b/docs/adr/0006-lets-encrypt-dns-01-on-a-shared-registered-domain.md deleted file mode 100644 index 7bde10f..0000000 --- a/docs/adr/0006-lets-encrypt-dns-01-on-a-shared-registered-domain.md +++ /dev/null @@ -1,68 +0,0 @@ -# Certificates come from Let's Encrypt over DNS-01, on one registered domain every Zone shares - -Every Zone's Traefik obtains certificates from Let's Encrypt using the DNS-01 challenge against -Cloudflare. All three Zones serve names under `garrettcountymd.gov`, so all three hold a Cloudflare -token with `Zone:DNS:Edit` on that one domain. Per-Zone token scoping — which the Ops Repo's own -follow-up list asked for — is not achievable in this shape. It is accepted for the pilot rather than -pretended, on a condition that is enforced rather than remembered. - -## Considered Options - -DNS-01 is not itself a choice. The internal-only Zone has no inbound path from the internet and so -cannot complete an HTTP-01 challenge; using DNS-01 everywhere means one ACME mechanism to understand -rather than two that drift apart. - -What was genuinely open is how to stop a credential in one Zone from being a credential over another -Zone's names. A Cloudflare API token scopes to a *registered domain* — there is no per-subdomain -record filtering short of Enterprise subdomain zones. Three ways out were weighed: - -- **An internal CA for the internal Zone.** Certificates for `internal-apps`, `swagger` and - `netops-tools` would come from AD CS and be provisioned like any other Secret, so the internal host - would hold no Cloudflare token at all and its hostnames would never be published. Rejected because - it reintroduces exactly the second certificate mechanism that DNS-01-everywhere exists to avoid, - and there is no internal PKI stood up to carry it. Worth reopening if one is built, since it is the - only option that removes the internal Zone's token entirely. -- **A wildcard certificate per Zone.** One `*.garrettcountymd.gov` certificate would keep individual - hostnames out of Certificate Transparency. Rejected outright — and it is the option that looks most - attractive while being the worst available. A wildcard on the internal host is a certificate valid - for `www.garrettcountymd.gov`, so a compromise of the least-exposed host yields a credential for - the most-exposed name. -- **`_acme-challenge` delegation.** Each Zone's challenge records are CNAMEd into a DNS zone of its - own, so its token can be scoped to that zone and to nothing that serves traffic. This is the - correct end state. It is deferred, not dismissed. - -Accepting the shared scope is defensible only because the pilot is a single Zone. While `bridge` is -the only Zone provisioned there is exactly one token and the cross-Zone capability does not exist. It -comes into being the moment a second Zone is provisioned — which is why the condition is a mechanism -and not a sentence. `internal` and `isolated` carry an unresolved placeholder in -`ZONE_ACME_DELEGATION`, and `bin/provision` already refuses to send any file containing a placeholder -to a host. Whoever provisions Zone 2, months from now and with none of this context, has to resolve -it deliberately to get past it. - -## Consequences - -- **A DNS-edit credential exists in every Zone, and they are equal in power.** Concretely: a - compromise of `c-web-isolated`, the most exposed of the three, yields a token that can repoint - `payments-api` and `dmr` over in `bridge` and issue valid certificates for them. Three separate - tokens are still issued, so one can be revoked without disturbing the others and Cloudflare's audit - log tells them apart — but that limits what a revocation costs, it does not prevent the capability. -- **Internal hostnames become public and permanent.** `internal-apps`, `swagger` and `netops-tools` - do not resolve in public DNS at all today. DNS-01 publishes every name it issues for to Certificate - Transparency, where it stays searchable indefinitely. Accepted: hostnames are not secrets, and - obscurity that is depended on but not maintained is worse than obscurity that has been written off. - It is why `paloalto-tools` was renamed to `netops-tools` first — a hostname that names a vendor - tells a reader which CVE feed to watch, and the rename is free before first issuance and impossible - after it. -- **All three Zones share one Let's Encrypt rate limit,** which is counted per registered domain. - Delegation would not change this, because the limit follows the certificate's names rather than - where the challenge was answered. Losing one Zone's `acme` volume therefore competes with every - other Zone's renewals, which is what turns backing that volume up from a nicety into a requirement. -- **`garrettcounty.org` is retired to a Cloudflare redirect** rather than served from an origin, so no - second registered domain enters any token's scope. Every domain that reaches an origin is a domain - whose certificate and token scope somebody maintains indefinitely. -- **Adding an Application now has a DNS step,** and gains a second one once delegation lands. The - `_acme-challenge` CNAME is easy to forget and its absence surfaces only as a certificate that never - issues, so it belongs in the runbook rather than in anyone's memory. -- **`ZONE_ACME_EMAIL` is a registration contact, not a monitoring backstop.** Let's Encrypt stopped - sending expiry notification emails in June 2025, so a renewal that silently stops working surfaces - as an outage unless something else watches for it. diff --git a/docs/adr/0007-azure-key-vault-per-zone-for-deployment-secrets.md b/docs/adr/0007-azure-key-vault-per-zone-for-deployment-secrets.md deleted file mode 100644 index 944aa34..0000000 --- a/docs/adr/0007-azure-key-vault-per-zone-for-deployment-secrets.md +++ /dev/null @@ -1,72 +0,0 @@ -# Deployment Secrets are encrypted with Azure Key Vault, one vault per Zone - -The Ops Repo's Secrets are encrypted by SOPS to a key held in Azure Key Vault — one vault and one -key per Zone, with decrypt granted to an Entra group per Zone. MongoDB queryable encryption keeps its -own Cloud KMS key in GCP and is untouched. - -## Considered Options - -This began as a dedicated GCP project, for a reason that still holds. The queryable-encryption key is -used by an Application at runtime, so its service-account credential file sits **on an Application -host**, while the `sops` keys are used by operators at a workstation and, per ADR 0003, must never be -reachable from a host or from CI. One project for both means a compromised Application host holds a -credential inside the same project as the keys that decrypt every Zone's Secrets — contained today by -per-key bindings, and not contained at all by a project-level binding, or by a `roles/cloudkms.admin` -granted in eighteen months to solve something unrelated. - -Building it is what changed the answer. There is no GCP organization and no group layer: operators -sign in with individual Google accounts. Access would therefore be individual IAM bindings, and -offboarding one edit per key per person — three chances to half-finish a revocation, which is the -precise failure this decision was written to prevent. A group layer could be built, since Cloud -Identity's free tier supplies Google Groups without Workspace, but it means verifying a domain that -Microsoft 365 already holds in order to replicate a directory that is already running. - -Azure Key Vault has the group layer, because the county already operates Entra. More importantly it -has the *process*: a joiner/mover/leaver routine already exists, so revoking decrypt stops being a -separate checklist item somebody has to remember and becomes a consequence of offboarding that -happens anyway. SOPS supports Key Vault natively and `bin/provision` only shells out to `sops`, so -nothing in the provisioning path changes. - -The separation argument survives the move and gets stronger. With Mongo staying on GCP, the deploy -keys and the host-resident credential are no longer merely in different projects but in different -clouds — the strongest available form of what the original decision was reaching for, arrived at -sideways rather than by design. - -Three vaults rather than one vault holding three keys. Key-scoped RBAC is possible, but vault-scoped -is easier to read in a role listing and to reason about mid-incident, and it gives each Zone its own -firewall and network rules if those are ever wanted. Premium SKU rather than Managed HSM: both give -HSM-backed keys, but Managed HSM is a dedicated pool billed hourly and would cost more per month than -every host in this design combined. `Key Vault Crypto User` at vault scope rather than `Crypto -Officer`, which can also create and destroy keys — no operator needs that to do their job, and the -gap between the two roles is the gap between losing one Secret and losing every Secret. - -Doing this before the pilot is most of why it is cheap. Nothing has been encrypted yet, so there is -no re-encryption and no rotation of Secrets exposed under superseded keys. That window closes at the -first `sops --encrypt`. - -## Consequences - -- **The Break-glass Key becomes more load-bearing, not less.** The vaults are Entra, so a - tenant-wide compromise takes the primary decryption path outright. The offline age key is the only - part of this design that does not depend on Entra, which is why its escrow must not sit anywhere - Entra can sign you in, and why the break-glass runbook now says so outright rather than leaving it - to be inferred. -- **Nothing automated holds a crypto role** — no service principal, no CI identity, no host. ADR - 0003 already required it; vault-scoped RBAC makes it checkable by listing role assignments on three - resources instead of reasoning about which bindings in a shared project are for what. That listing - has to include *inherited* assignments: a subscription-level `Owner` reaches all three vaults at - once and silently defeats the per-Zone split this ADR exists to create. -- **Key URLs in `.sops.yaml` are version-pinned.** Unlike a GCP resource id, an Azure key URL names - one specific version, so rotating a key means editing `.sops.yaml` and running `sops updatekeys` - across every file rather than a transparent switch behind a stable identifier. Rotating a *Secret* - is unaffected and stays cheap, which is the operation that actually happens often. -- **Two clouds, but not one more than before.** GCP was already there for Mongo. What changes is - which cloud holds what, not how many consoles exist — though the less-used one still drifts, and - its billing and audit retention go unwatched between incidents. -- **Key Vault audit logging is off by default,** exactly as GCP's Data Access logging was. Per-vault - diagnostic settings into a Log Analytics workspace are what make the offboarding runbook's claim - about reading what someone decrypted true rather than aspirational. -- **Access is standing, not just-in-time.** Entra ID P2 would allow PIM to make the crypto role - eligible rather than active, so decrypt would be time-boxed and approval-gated. The county holds - P1, so that is an upgrade path rather than a property of the design today — worth revisiting at the - next licensing review, because it is the one control neither cloud's plain RBAC offers. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..61971ea --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,36 @@ +# Architecture decision records + +These ADRs record decisions about the framework itself. During the `v7` review, four +operational ADRs moved out to `gcgov/deploy`, and the ADRs left here were renumbered into a +clean `0001`-`0004` sequence. + +The four that moved carry the county's operational threat model — where secrets decrypt, how +the deploy runners are isolated, how certificates issue, and where the SOPS keys live. This +repository is public; that material belongs in the Ops Repo beside the mechanism it +describes. See `gcgov/deploy` `docs/adr/README.md`. + +## What stayed, and its number + +| Now | Was | Decision | +|---|---|---| +| `0001` | `0001` | Fail-closed configuration | +| `0002` | `0002` | Immutable Release, pinned by digest | +| `0003` | `0005` | Framework Services are built in and config-activated | +| `0004` | `0008` | Writes are transactional, so MongoDB is a replica set | + +## What moved to `gcgov/deploy` + +| Was here | Now (`gcgov/deploy`) | Decision | +|---|---|---| +| `0003` | `0001` | Secrets never decrypt in CI or on hosts | +| `0004` | `0002` | One self-hosted runner per Zone | +| `0006` | `0003` | Let's Encrypt DNS-01 on a shared registered domain | +| `0007` | `0004` | Azure Key Vault per Zone for deployment secrets | + +## How to cite an ADR + +Both repositories now number `0001`-`0004`, so a bare "ADR 0002" is ambiguous. Every +citation names its repository: + +- A local ADR: `docs/adr/0002-immutable-release-digest-pinning.md` +- A foreign ADR: `gcgov/deploy docs/adr/0002-self-hosted-runners-per-zone.md` diff --git a/readme/app.php.md b/readme/app.php.md index 9e7fecc..061718e 100644 --- a/readme/app.php.md +++ b/readme/app.php.md @@ -10,7 +10,7 @@ location, so an application without it cannot resolve its own root. Framework Services used to be registered here, by returning their namespaces from `registerFrameworkServiceNamespaces()`. They are now enabled in the `services` section of `config.json`, so that switching a service on and configuring it are one statement rather than two. -See ADR 0005. +See ADR 0003. ```php namespace app; diff --git a/readme/local-development.md b/readme/local-development.md index 7f02e44..211a0df 100644 --- a/readme/local-development.md +++ b/readme/local-development.md @@ -37,7 +37,7 @@ mongosh --eval 'rs.initiate()' Production is unaffected — a managed cluster reached over `mongodb+srv://` is a replica set already. The reasoning, and the alternative that was rejected, are in -[ADR 0008](../docs/adr/0008-writes-are-transactional-so-mongodb-is-a-replica-set.md). +[ADR 0004](../docs/adr/0004-writes-are-transactional-so-mongodb-is-a-replica-set.md). ## 2. Configuration resolves, or the Application refuses to start diff --git a/readme/mongodb.md b/readme/mongodb.md index 2597abb..d6be221 100644 --- a/readme/mongodb.md +++ b/readme/mongodb.md @@ -28,7 +28,7 @@ fails. A single-member replica set is enough, and is what the application templa already. The reasoning, and the conditional-transaction alternative that was considered and rejected, are in -[ADR 0008](../docs/adr/0008-writes-are-transactional-so-mongodb-is-a-replica-set.md). Running an +[ADR 0004](../docs/adr/0004-writes-are-transactional-so-mongodb-is-a-replica-set.md). Running an Application locally: [local-development.md](local-development.md). ## Config From 2dc4708f84ce174b31e3bbf14cdb443c5dcca52b Mon Sep 17 00:00:00 2001 From: gcgov deploy Date: Tue, 8 Sep 2026 11:58:45 +0000 Subject: [PATCH 30/30] docs(context): widen the glossary for frontends and one operator gcgov/deploy has deployed browser bundles as well as APIs for months, and the escrow has one operator rather than two. Widen the glossary to match: - Application now covers both Application Kinds, not only a REST API. - Add Application Kind (api or frontend). - Provisioning covers the compose file and Zone values, not only Secrets. - A Release is a set of named content digests, one per image. - Escrow Custodian describes one custodian plus a second safe-opener. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NzByhoDp7hsD39aoThJ9rv --- CONTEXT.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 16663b7..2445d0f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,10 +9,16 @@ per concept. It is not a specification — see `README.md`, `readme/`, and `docs ### Applications and extensions **Application**: -A deployable REST API (optionally server-rendered) built on the framework, living in its own -repository and depending on the framework as a library. +A deployable unit that runs in exactly one Zone and is built from its own repository. It takes one +of two Application Kinds — an `api` built on the framework as a library, or a `frontend` that talks +to one. _Avoid_: project, site, instance, consumer +**Application Kind**: +Which of the two shapes an Application takes: `api` or `frontend`. The Kind decides which images a +Release is made of, and whether the Application holds Secrets at all. +_Avoid_: type, flavour, variant, shape + **Framework Service**: An optional part of the framework that contributes routes, controllers and an auth guard to an Application when the Application enables it in the `services` section of its Unified Config. A @@ -129,14 +135,15 @@ holds production topology or Secrets. _Avoid_: infra repo, config repo **Provisioning**: -Writing decrypted Secrets onto a host. Performed by an operator as a step deliberately separate from -deploying, so that no host holds a decryption key and no deploy needs one. +Writing an Application's configuration onto a host — its decrypted Secrets, its compose file, and its +Zone's values. Performed by an operator as a step deliberately separate from deploying, so that no +host holds a decryption key and no deploy needs one. _Avoid_: secret sync, secret deploy, key distribution **Release**: -A tagged, immutable build of an Application, identified in production by content digest rather than -by tag or branch. Deploying and rolling back are both the act of pointing a host at a different -Release. +A tagged, immutable build of an Application, identified in production by a set of named content +digests — one per image the Application's compose file declares — rather than by tag or branch. +Deploying and rolling back are both the act of pointing a host at a different Release. _Avoid_: version, build, deployment **Zone Key Vault**: @@ -156,9 +163,9 @@ total loss of cloud access is still recoverable. Retrieving it obliges replacing _Avoid_: recovery key, backup key, master key **Escrow Custodian**: -One of the two named people who may retrieve the Break-glass Key from physical escrow, drawn from -different reporting lines so that retrieval crosses a departmental boundary and is witnessed by -someone with no stake in it. +The person who may retrieve the Break-glass Key from physical escrow. A second person can open the +same safe, so the key survives the custodian's absence; that second person is a control on +availability, not a witness to retrieval. _Avoid_: key holder, key owner, keeper ### Retired language