Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .composer-require-checker.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 39 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 29 additions & 9 deletions src/ConfigFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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());
Expand Down
170 changes: 170 additions & 0 deletions src/SecretManagerAccessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

/**
* Google Secret Manager configuration accessor
*
* @package OpenCoreEMR
* @link https://opencoreemr.com
* @author Your Name <your.email@opencoreemr.com>
* @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<string, string>
*/
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<string, string> */
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;
}
}