diff --git a/.composer-require-checker.json b/.composer-require-checker.json index 463966b..da50d78 100644 --- a/.composer-require-checker.json +++ b/.composer-require-checker.json @@ -48,7 +48,9 @@ "text", "date", "ModuleManagerListener", - "{VendorName}\\Modules\\{ModuleName}\\GlobalConfig" + "{VendorName}\\Modules\\{ModuleName}\\GlobalConfig", + "Google\\Cloud\\SecretManager\\V1\\Client\\SecretManagerServiceClient", + "Google\\Cloud\\SecretManager\\V1\\AccessSecretVersionRequest" ], "php-core-extensions": [ "Core", diff --git a/CLAUDE.md b/CLAUDE.md index b7cc8e7..a92ee5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,7 @@ OpenEMR modules follow a **Symfony-inspired MVC architecture** with: │ ├── EnvironmentConfigAccessor.php # Env var config (for containers) │ ├── FileConfigAccessor.php # YAML file config (for K8s) │ ├── GlobalsAccessor.php # Database-backed config (OpenEMR globals) +│ ├── SecretManagerAccessor.php # GCP Secret Manager (for GKE) │ ├── GlobalConfig.php # Centralized configuration wrapper │ ├── YamlConfigLoader.php # YAML file parsing and merging │ ├── ModuleAccessGuard.php # Entry point access guard @@ -191,7 +192,7 @@ Release Please uses the [generic updater](https://github.com/googleapis/release- ## Configuration Abstraction Layer -The template includes a flexible configuration system that supports database-backed globals, environment variables, and YAML file-based configuration: +The template includes a flexible configuration system that supports database-backed globals, environment variables, YAML file-based configuration, and Google Secret Manager: ### Key Components @@ -201,10 +202,19 @@ The template includes a flexible configuration system that supports database-bac | `GlobalsAccessor` | Reads config from OpenEMR database globals | | `EnvironmentConfigAccessor` | Reads config from environment variables | | `FileConfigAccessor` | Reads config from YAML files with env var overrides | +| `SecretManagerAccessor` | Reads secrets from GCP Secret Manager (GKE) | | `YamlConfigLoader` | Parses YAML config files, processes imports, merges | | `ConfigFactory` | Selects the appropriate accessor based on environment | | `GlobalConfig` | Centralized wrapper providing typed access to all module config | +### Config Mode Precedence + +ConfigFactory selects the accessor in this order: +1. **GSM** - `OCE_TENANT_GCP_PROJECT_ID` is set (GKE with Workload Identity) +2. **File** - YAML config files exist at conventional paths +3. **Env** - `{VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG=1` +4. **Database** - OpenEMR globals (default) + ### Usage Pattern ```php @@ -217,6 +227,34 @@ $isEnabled = $config->isEnabled(); // bool $apiKey = $config->getApiKey(); // string (decrypted in DB mode) ``` +### Google Secret Manager Mode (GKE) + +When `OCE_TENANT_GCP_PROJECT_ID` is set, secrets are fetched from GCP Secret Manager using Workload Identity. Non-secret config is still read from OpenEMR globals (database). + +**Required env vars:** +- `OCE_TENANT_GCP_PROJECT_ID` - GCP project containing the secrets +- `OCE_TENANT_ID` - Tenant slug for secret name construction + +**Secret naming convention:** `{tenant_id}_{modulename}_{SECRET_NAME}` + +**Setup in `SecretManagerAccessor::SECRET_MAP`:** +```php +private const SECRET_MAP = [ + GlobalConfig::CONFIG_OPTION_API_KEY => 'API_KEY', + GlobalConfig::CONFIG_OPTION_API_SECRET => 'API_SECRET', +]; +``` + +**Terraform (tfm-oce-tenant):** +```hcl +module_secrets = { + {modulename} = { + enabled = true + secrets = ["API_KEY", "API_SECRET"] + } +} +``` + ### Environment Variable Mode Set `{VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG=1` to use environment variables instead of database: diff --git a/composer.json b/composer.json index 9b435de..339c44f 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "require": { "php": ">=8.2", "ext-filter": "*", + "google/cloud-secret-manager": "^1.14", "openemr/oe-module-installer-plugin": "^0.1.5", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/console": "^6.4 || ^7.0", diff --git a/src/ConfigFactory.php b/src/ConfigFactory.php index 8b40aa9..b94ab77 100644 --- a/src/ConfigFactory.php +++ b/src/ConfigFactory.php @@ -15,16 +15,24 @@ /** * Factory for creating the appropriate configuration accessor. * - * Supports three modes (checked in order of precedence): - * 1. File config: YAML files at conventional or overridden paths - * 2. Env config: {VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG=1 - * 3. Database globals (default) + * Supports four modes (checked in order of precedence): + * 1. GSM config: OCE_TENANT_GCP_PROJECT_ID is set (GKE with Secret Manager) + * 2. File config: YAML files at conventional or overridden paths + * 3. Env config: {VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG=1 + * 4. Database globals (default) * - * This pattern allows modules to be configured via YAML files (Kubernetes), - * environment variables (containers), or database (traditional OpenEMR). + * This pattern allows modules to be configured via Google Secret Manager (GKE), + * YAML files (Kubernetes), environment variables (containers), or database + * (traditional OpenEMR). */ class ConfigFactory { + /** + * Environment variable indicating GKE deployment with GSM secrets. + * When set, secrets are fetched from Google Secret Manager. + */ + public const GSM_PROJECT_VAR = 'OCE_TENANT_GCP_PROJECT_ID'; + /** * Environment variable that toggles environment-based configuration. * Set to "1" or "true" to enable environment variable configuration mode. @@ -77,20 +85,32 @@ public static function isFileConfigMode(): bool } /** - * Check if any external config mode is active (file or env) + * Check if Google Secret Manager mode is enabled (GKE deployment) + */ + public static function isSecretManagerMode(): bool + { + return getenv(self::GSM_PROJECT_VAR) !== false; + } + + /** + * Check if any external config mode is active (GSM, file, or env) */ public static function isExternalConfigMode(): bool { - return self::isFileConfigMode() || self::isEnvConfigMode(); + return self::isSecretManagerMode() || self::isFileConfigMode() || self::isEnvConfigMode(); } /** * Create the appropriate config accessor based on environment * - * Precedence: file config > env config > database globals + * Precedence: GSM > file config > env config > database globals */ public static function createConfigAccessor(): ConfigAccessorInterface { + if (self::isSecretManagerMode()) { + return new SecretManagerAccessor(); + } + if (self::isFileConfigMode()) { $loader = new YamlConfigLoader(); $paths = $loader->resolveFilePaths(self::getConfigFileCandidates()); diff --git a/src/SecretManagerAccessor.php b/src/SecretManagerAccessor.php new file mode 100644 index 0000000..f9a67ac --- /dev/null +++ b/src/SecretManagerAccessor.php @@ -0,0 +1,170 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}; + +use Google\Cloud\SecretManager\V1\Client\SecretManagerServiceClient; +use Google\Cloud\SecretManager\V1\AccessSecretVersionRequest; +use OpenEMR\Core\Kernel; + +/** + * Reads module secrets from Google Cloud Secret Manager. + * + * This accessor is used in GKE deployments where secrets are stored in GSM + * and accessed via Workload Identity. Non-secret configuration is delegated + * to GlobalsAccessor (database-backed OpenEMR globals). + * + * Required environment variables: + * - OCE_TENANT_GCP_PROJECT_ID: The GCP project containing the secrets + * - OCE_TENANT_ID: The tenant slug used in secret naming + * + * Secret naming convention: {tenant_id}_{modulename}_{SECRET_NAME} + * Example: cardinal-clinic_cardinal_ui_API_KEY + */ +class SecretManagerAccessor implements ConfigAccessorInterface +{ + /** + * Maps internal config keys to GSM secret name suffixes. + * + * Add your module's secret keys here. Only keys listed here will be + * fetched from GSM; all other config is delegated to GlobalsAccessor. + * + * Example: + * GlobalConfig::CONFIG_OPTION_API_KEY => 'API_KEY', + * GlobalConfig::CONFIG_OPTION_API_SECRET => 'API_SECRET', + * + * @var array + */ + private const SECRET_MAP = [ + // Add your secret mappings here: + // GlobalConfig::CONFIG_OPTION_API_KEY => 'API_KEY', + ]; + + /** + * The module name used in GSM secret naming. + * This should match the module identifier in tfm-oce-tenant module_secrets. + */ + private const MODULE_NAME = '{modulename}'; + + /** @var array */ + private array $secretCache = []; + + private ?SecretManagerServiceClient $client = null; + + public function __construct( + private readonly GlobalsAccessor $globalsAccessor = new GlobalsAccessor() + ) { + } + + public function get(string $key, mixed $default = null): mixed + { + if ($default === null) { + return $this->getString($key, ''); + } + return $this->getString($key, is_string($default) ? $default : ''); + } + + public function getString(string $key, string $default = ''): string + { + if (isset(self::SECRET_MAP[$key])) { + return $this->getSecret($key) ?? $default; + } + + return $this->globalsAccessor->getString($key, $default); + } + + public function getBoolean(string $key, bool $default = false): bool + { + return $this->globalsAccessor->getBoolean($key, $default); + } + + public function getInt(string $key, int $default = 0): int + { + return $this->globalsAccessor->getInt($key, $default); + } + + public function has(string $key): bool + { + if (isset(self::SECRET_MAP[$key])) { + return $this->getSecret($key) !== null; + } + + return $this->globalsAccessor->has($key); + } + + public function getKernel(): ?Kernel + { + return $this->globalsAccessor->getKernel(); + } + + /** + * Fetch a secret from Google Secret Manager + */ + private function getSecret(string $configKey): ?string + { + if (isset($this->secretCache[$configKey])) { + return $this->secretCache[$configKey]; + } + + $projectId = getenv('OCE_TENANT_GCP_PROJECT_ID'); + $tenantSlug = getenv('OCE_TENANT_ID'); + + if ($projectId === false || $tenantSlug === false) { + return null; + } + + $secretSuffix = self::SECRET_MAP[$configKey] ?? null; + if ($secretSuffix === null) { + return null; + } + + $secretName = sprintf( + 'projects/%s/secrets/%s_%s_%s/versions/latest', + $projectId, + $tenantSlug, + self::MODULE_NAME, + $secretSuffix + ); + + try { + $client = $this->getClient(); + $request = new AccessSecretVersionRequest(); + $request->setName($secretName); + + $response = $client->accessSecretVersion($request); + $payload = $response->getPayload(); + if ($payload === null) { + return null; + } + $secretValue = $payload->getData(); + + // Skip placeholder values from terraform initialization + if ($secretValue === 'INITIALIZED') { + return null; + } + + $this->secretCache[$configKey] = $secretValue; + return $secretValue; + } catch (\Exception $e) { + error_log("SecretManagerAccessor: Failed to fetch secret {$secretName}: " . $e->getMessage()); + return null; + } + } + + private function getClient(): SecretManagerServiceClient + { + if (!$this->client instanceof SecretManagerServiceClient) { + $this->client = new SecretManagerServiceClient(); + } + return $this->client; + } +}