Skip to content
Merged
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!-- file generated with AI assistance: Claude Code - 2026-10-05 10:25:39 UTC -->

# Changelog

## Unreleased

Secure storage and transport of the credentials in `configJson` ([Issue #8](https://github.com/dmstr/api-configuration-bundle/issues/8)) and a slimmer client contract ([Issue #9](https://github.com/dmstr/api-configuration-bundle/issues/9)). Release as a new minor version: the changes below break consumers in the ways listed under "Breaking changes".

### Breaking changes

- **Reads require `ROLE_ADMIN`.** `GET /api/admin/api_configurations`, `GET /api/admin/api_configurations/{id}` and `GET /api/admin/api_configurations/{id}/health` answer `403` for non-admin users (before: `ROLE_USER`). There is no option to restore the old behaviour.
- **Secrets are masked in responses.** Keys marked `writeOnly` in the type schema come back as `********`, also for administrators. Clients that read credentials over the API no longer get them.
- **Secrets are encrypted at rest.** `ApiConfiguration::getConfigJson()` returns `enc:v1:…` for marked keys after a flush. Code that reads secrets from the entity directly (instead of through `ApiClientFactory`) has to decrypt with `ConfigSecrets::decrypt()`, for example an `AuthorizableExtensionInterface` that reads a client secret in `buildAuthorizeUrl()`.
- **A usable encryption key is required** as soon as a configuration holds a marked secret: `dmstr_api_platform_utils.credential_encryption.key: '%env(base64:CREDENTIALS_ENCRYPTION_KEY)%'`. Without it, persisting or using the secret throws `SecretEncryptionException`.
- **`ApiClientInterface` is slimmer** (Issue #9). `getCustomers()`, `getTodoLists()` and `hasChanges()` moved to the capability interfaces `CustomerAwareApiClientInterface`, `TodoListAwareApiClientInterface` and `ChangeProbeInterface`. Clients implement only what their source supports and drop their stubs (`return []`, `return true`); callers check `instanceof` and treat a client without `ChangeProbeInterface` as "always changed".
- **`authenticate(): void` throws** (Issue #9) `AuthenticationFailedException` with the transport exception as `previous`, instead of returning `false`. Implementations rethrow via `AuthenticationFailedException::fromPrevious($apiName, $e)`; callers catch instead of checking the return value.
- **`ApiClientFactory::create()` validates the configuration** against the extension's `schema.json` before `createClient()` (Issue #9) and throws `InvalidConfigurationException` (an `\InvalidArgumentException`) naming the failing field. Configurations that an extension accepted despite its own schema are now rejected. Keys starting with `_` (`_apiConfigurationId`) are not validated.
- **Constructor signatures**: `ApiClientFactory`, `ApiConfigurationValidator` and `CreateApiConfigurationCommand` take an additional `ConfigSecrets` argument; `ApiConfigurationHealthProvider` and `ApiConfigurationHealthCommand` take `ApiConfigurationHealthChecker` instead of `ApiClientFactory` and `HealthNormalizer` (only relevant when they are instantiated manually instead of autowired).

### Added

- Secret keys are declared in the type schema with `"writeOnly": true`; the existing type schemas in the consuming bundles and applications need the marker on their `password`, `token`, `client_secret` and equivalent keys.
- Encryption at rest of the marked keys with `CredentialEncryption` (Doctrine `onFlush` listener).
- Masking of the marked keys in API responses and in the validator's logs.
- Update rule: an omitted secret or the mask keeps the stored value, `null` removes it.
- Console command `app:api-configuration:encrypt-secrets` (`--dry-run`) to encrypt the secrets of existing rows, idempotent.
- `HealthProbeInterface` (Issue #9): a health check per type without the project-management client methods, autoconfigured. Schema-only types (no `ApiExtensionInterface`) answer the health route and command instead of "Unsupported API name"; a probe takes precedence over the client-based check.
- `ApiConfigurationHealthChecker`: the health check shared by the health route, the health command and application dashboards.

### Fixed

- `GET /api/admin/api_configurations/{id}/health` in JSON-LD returned `metadata` and `error` as `hydra:Collection` without their keys (Issue #9); the operation now normalizes nested arrays raw (`api_sub_level`).
- Write operations returned HTTP 500 (`anyOf must have at least one element`) when no configuration schema was registered ([Issue #5](https://github.com/dmstr/api-configuration-bundle/issues/5)); the validator now reports a violation (HTTP 422). The root cause, the empty `anyOf` from `SchemaRegistry`, is tracked in dmstr/openapi-json-schema-bundle#3.
- `symfony/validator` is declared as a dependency; it was used directly but only installed transitively.
- `app:api:validate-file` called the non-existent `ApiConfiguration::getFileConfig()` (a leftover from file configurations with a `format` key) and failed with a PHP error; it now builds the client and delegates to `FileApiClientInterface::validateFile()` and `parseFile()`, so every file type validates its own format.
- `app:api:test-connection` called the non-existent `ApiConfiguration::getCredentials()` and failed with a PHP error; it now builds the client from the entity.

## 0.4.0 and earlier

See git history.
83 changes: 82 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,95 @@ Manage external API connections as Doctrine entities.

## Features (planned)

- `ApiConfiguration` entity — type, credentials (encrypted), endpoint config
- `ApiConfiguration` entity — type, credentials (encrypted at rest, masked on read), endpoint config
- Custom operations: `health`, `authorize`, `test-connection`
- `ApiExtensionRegistry` — discoverable adapter pattern via tag
- `ApiExtensionInterface` / `AuthorizableExtensionInterface` — adapters
implement these to plug in
- OAuth callback controller for `authorize` flow
- CLI mirrors: `api-configuration:create`, `:health`, `:test-connection`

## API clients

An extension implements `ApiExtensionInterface` (tagged automatically) and builds a client implementing `ApiClientInterface`, which holds only what every source supports. Optional capabilities are separate interfaces; implement those the source really supports, callers check `instanceof`:

| Interface | Methods |
|---|---|
| `CustomerAwareApiClientInterface` | `getCustomers()` |
| `TodoListAwareApiClientInterface` | `getTodoLists()` |
| `ChangeProbeInterface` | `hasChanges()` — cheap, fail open; without it a source counts as always changed |
| `UserAwareApiClientInterface` | `getUsers()` |

`authenticate()` returns nothing and throws `AuthenticationFailedException` with the original exception as `previous`.

`ApiClientFactory::create()` validates the configuration against the extension's `schema.json` before calling `createClient()`, so extensions need no `isset()` checks of their own; an invalid configuration throws `InvalidConfigurationException` naming the field.

### Health checks

`ApiConfigurationHealthChecker` serves the health route, `app:api-configuration:health` and application dashboards. For a type with a `HealthProbeInterface` (tagged automatically) it calls the probe with the decrypted configuration; otherwise it builds the client and calls `getHealthInfo()`. Types without a full client, such as schema-only connection types, only need a probe.

## Security

### Access

Every operation of the `ApiConfiguration` resource under `/api/admin/api_configurations` — reads and the `health` and `authorize` sub-resources included — requires `ROLE_ADMIN`, because `configJson` holds the credentials for the upstream systems.

### Secrets in `configJson`

A type marks its secret keys in its own `schema.json` with the standard JSON Schema annotation `writeOnly`:

```json
{
"properties": {
"username": { "type": "string" },
"password": { "type": "string", "writeOnly": true },
"oauth": {
"type": "object",
"properties": {
"client_secret": { "type": "string", "writeOnly": true }
}
}
}
}
```

Marked keys are found in nested objects, local `$ref`s, `allOf`/`anyOf`/`oneOf`, `if`/`then`/`else`, array `items` and `additionalProperties`. The schema of a type is looked up by schema provider name, then by extension name, then by the `type` const of the registered schemas; for an unknown type the marked keys of all schemas apply, so a missing mapping over-masks instead of leaking.

For marked keys the bundle guarantees:

- **Encrypted at rest.** Values are stored as `enc:v1:<ciphertext>` (libsodium secretbox via `Dmstr\ApiPlatformUtils\Service\CredentialEncryption`), whoever writes the entity — API, console or application code (Doctrine `onFlush` listener).
- **Masked on read.** API responses contain `********` instead of the value, also for administrators. Logs of the configuration validator are masked the same way.
- **Kept on update.** In `PUT` and `PATCH`, an omitted key or the mask `********` keeps the stored value, `null` removes the key, any other value replaces it. Nothing is carried over when `type` changes.
- **Decrypted only for the client.** `ApiClientFactory::create()` and `createFromEntity()` decrypt before calling `ApiExtensionInterface::createClient()`. Code that reads secrets from `ApiConfiguration::getConfigJson()` itself (for example an `AuthorizableExtensionInterface` reading a client secret) must decrypt with `Dmstr\ApiConfiguration\Security\ConfigSecrets::decrypt()`.

### Encryption key

The key comes from `dmstr/api-platform-utils-bundle`. `CredentialEncryption` expects the 32 raw key bytes, the generated key is base64, so decode it in the configuration:

```yaml
# config/packages/dmstr_api_platform_utils.yaml
dmstr_api_platform_utils:
credential_encryption:
key: '%env(base64:CREDENTIALS_ENCRYPTION_KEY)%'
```

```bash
bin/console dmstr:generate-encryption-key # prints CREDENTIALS_ENCRYPTION_KEY=...
```

Without a usable key (missing, wrong length, not base64), storing or using a secret fails with `Dmstr\ApiConfiguration\Security\SecretEncryptionException`; nothing is stored in clear. Configurations without secrets keep working. Changing the key makes existing secrets unreadable.

### Existing rows

Rows stored before encryption at rest hold their secrets in clear. Encrypt them once after the upgrade; the command is idempotent and leaves encrypted values alone, so it can also run on every deployment:

```bash
bin/console app:api-configuration:encrypt-secrets --dry-run
bin/console app:api-configuration:encrypt-secrets
```

Rows that are updated through Doctrine are encrypted on the next flush anyway.

## License

MIT © diemeisterei GmbH
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
"symfony/framework-bundle": "^7.0",
"api-platform/symfony": "^4.0",
"doctrine/orm": "^3.0",
"doctrine/doctrine-bundle": "^2.0",
"doctrine/doctrine-bundle": "^2.8",
"dmstr/api-platform-utils-bundle": "*",
"dmstr/openapi-json-schema-bundle": "*",
"opis/json-schema": "^2.3"
"opis/json-schema": "^2.3",
"symfony/validator": "^7.0|^8.0"
},
"require-dev": {
"phpunit/phpunit": "^12.0",
Expand Down
46 changes: 42 additions & 4 deletions src/ApiClient/ApiClientFactory.php
Original file line number Diff line number Diff line change
@@ -1,29 +1,43 @@
<?php
// file generated with AI assistance: Claude Code - 2025-11-01 00:00:00
// file generated with AI assistance: Claude Code - 2026-10-05 12:10:00 UTC

declare(strict_types=1);

namespace Dmstr\ApiConfiguration\ApiClient;

use Dmstr\ApiConfiguration\Extension\ApiExtensionInterface;
use Dmstr\ApiConfiguration\Schema\JsonSchemaFile;
use Dmstr\ApiConfiguration\Security\ConfigSecrets;
use Dmstr\ApiConfiguration\Service\ApiExtensionRegistry;
use Opis\JsonSchema\Errors\ErrorFormatter;
use Opis\JsonSchema\Validator;

/**
* Factory for creating API clients using extension registry
*/
class ApiClientFactory
{
public function __construct(
private readonly ApiExtensionRegistry $registry
private readonly ApiExtensionRegistry $registry,
private readonly ConfigSecrets $secrets,
) {
}

/**
* Create an API client based on configuration
*
* The configuration is validated against the extension's schema
* ({@see ApiExtensionInterface::getSchemaPath()}) before the client is
* built, so extensions need no required-field checks of their own. Keys
* starting with `_` (e.g. `_apiConfigurationId`) are internal and not
* validated.
*
* @param string $apiName API name (basecamp2, github, gitlab, jira)
* @param array $config Configuration array
* @param array $config Configuration array; encrypted secrets are decrypted here
* @return ApiClientInterface
* @throws \InvalidArgumentException If API name is not supported or config is invalid
* @throws \InvalidArgumentException If API name is not supported
* @throws InvalidConfigurationException If config does not match the extension's schema
* @throws \Dmstr\ApiConfiguration\Security\SecretEncryptionException If a secret cannot be decrypted
*/
public function create(string $apiName, array $config): ApiClientInterface
{
Expand All @@ -39,6 +53,9 @@ public function create(string $apiName, array $config): ApiClientInterface
);
}

$config = $this->secrets->decrypt($config);
$this->validate($extension, $config);

return $extension->createClient($config);
}

Expand All @@ -58,4 +75,25 @@ public function createFromEntity(\Dmstr\ApiConfiguration\Entity\ApiConfiguration
$config
);
}

/**
* @throws InvalidConfigurationException
*/
private function validate(ApiExtensionInterface $extension, array $config): void
{
$public = array_filter(
$config,
static fn (int|string $key): bool => !str_starts_with((string) $key, '_'),
ARRAY_FILTER_USE_KEY,
);
$schema = json_decode(json_encode(JsonSchemaFile::load($extension->getSchemaPath())));

$result = (new Validator())->validate(json_decode(json_encode((object) $public)), $schema);
if (!$result->isValid()) {
throw new InvalidConfigurationException(
$extension->getName(),
(new ErrorFormatter())->format($result->error(), true),
);
}
}
}
50 changes: 11 additions & 39 deletions src/ApiClient/ApiClientInterface.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php
// file generated with AI assistance: Claude Code - 2026-10-05 12:10:00 UTC

declare(strict_types=1);

Expand All @@ -7,18 +8,25 @@
/**
* Base interface for all API clients (REST + File).
*
* Only what every source supports belongs here. Optional capabilities are
* separate interfaces that callers check with `instanceof`, so "unsupported"
* is no longer indistinguishable from "empty":
* {@see CustomerAwareApiClientInterface}, {@see TodoListAwareApiClientInterface},
* {@see ChangeProbeInterface}, {@see UserAwareApiClientInterface}.
*
* Domain methods (getProjects, getTodos, ...) belong here because the data
* shape is the same regardless of acquisition mechanism — HTTP, XML file,
* CSV dump, etc. are implementation details of the concrete client.
*/
interface ApiClientInterface
{
/**
* Authenticate with the API
* Authenticate with the API.
*
* @return bool True if authentication successful
* @throws AuthenticationFailedException if authentication fails; the
* transport exception (401, DNS, TLS, ...) is passed as `previous`
*/
public function authenticate(): bool;
public function authenticate(): void;

/**
* Get the client type (rest or file)
Expand Down Expand Up @@ -58,24 +66,6 @@ public function getEndpoint(): string;
*/
public function getProjects(): array;

/**
* Get all customers (groups/orgs/companies) from the source.
* "Customer" maps to: BC2 Groups, BC4 Companies, GitHub Orgs, GitLab Groups,
* Jira Project Categories (or empty for file-based sources without grouping).
*
* @return array Array of customer data (raw, not normalized)
*/
public function getCustomers(): array;

/**
* Get todo lists for a specific project (Basecamp 2 specific concept).
* Other sources may return an empty array.
*
* @param string $projectId The project identifier
* @return array Array of todo list data
*/
public function getTodoLists(string $projectId): array;

/**
* Get todos/issues for a specific project.
*
Expand All @@ -92,22 +82,4 @@ public function getTodos(string $projectId): array;
* @return array|null Todo/issue data or null if not found
*/
public function getTodo(string $projectId, string $todoId): ?array;

/**
* Check if the source has been modified since a given timestamp.
* Used for intelligent sync scheduling (skip when nothing changed).
*
* Contract:
* - Must be *cheap*: one request at most. It is called to avoid a more
* expensive call, so it must not page or fan out.
* - Must **fail open**: on any error, or whenever the implementation cannot
* tell, return `true`. A `false` suppresses the scan entirely, so a broken
* or incomplete probe would silently stop data from being refreshed.
* - Returning a constant `true` is a valid implementation for sources
* without a reliable account-wide change feed.
*
* @param \DateTimeInterface $since Timestamp to check against
* @return bool True if source has (or may have) changes since the timestamp
*/
public function hasChanges(\DateTimeInterface $since): bool;
}
23 changes: 23 additions & 0 deletions src/ApiClient/AuthenticationFailedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
// file generated with AI assistance: Claude Code - 2026-10-05 12:10:00 UTC

declare(strict_types=1);

namespace Dmstr\ApiConfiguration\ApiClient;

/**
* Thrown by {@see ApiClientInterface::authenticate()}. Carries the original
* transport exception as `previous`, so the cause (401, DNS, TLS, ...)
* reaches the caller instead of a bare "failed".
*/
class AuthenticationFailedException extends \RuntimeException
{
public static function fromPrevious(string $apiName, \Throwable $previous): self
{
return new self(
sprintf('Authentication against "%s" failed: %s', $apiName, $previous->getMessage()),
0,
$previous,
);
}
}
31 changes: 31 additions & 0 deletions src/ApiClient/ChangeProbeInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php
// file generated with AI assistance: Claude Code - 2026-10-05 12:10:00 UTC

declare(strict_types=1);

namespace Dmstr\ApiConfiguration\ApiClient;

/**
* Clients that can tell cheaply whether their source changed.
*
* Sources without a reliable account-wide change feed do not implement this
* interface; callers treat such clients as "always changed".
*/
interface ChangeProbeInterface extends ApiClientInterface
{
/**
* Check if the source has been modified since a given timestamp.
* Used for intelligent sync scheduling (skip when nothing changed).
*
* Contract:
* - Must be *cheap*: one request at most. It is called to avoid a more
* expensive call, so it must not page or fan out.
* - Must **fail open**: on any error, or whenever the implementation cannot
* tell, return `true`. A `false` suppresses the scan entirely, so a broken
* or incomplete probe would silently stop data from being refreshed.
*
* @param \DateTimeInterface $since Timestamp to check against
* @return bool True if source has (or may have) changes since the timestamp
*/
public function hasChanges(\DateTimeInterface $since): bool;
}
Loading
Loading