Skip to content

feat!: secure configJson credentials, slimmer client contract (#5, #8, #9) - #10

Merged
schmunk42 merged 7 commits into
masterfrom
feature/8-credential-security
Oct 5, 2026
Merged

schmunk42 merged 7 commits into
masterfrom
feature/8-credential-security

Conversation

@schmunk42

@schmunk42 schmunk42 commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Closes #5, closes #8, closes #9.

Summary

#8 — credentials in configJson

  • Every operation of ApiConfiguration, reads and /health included, requires ROLE_ADMIN.
  • Type schemas mark secret keys with "writeOnly": true (nested objects, $ref, oneOf, list items, maps).
  • Marked keys are encrypted at rest (CredentialEncryption, Doctrine onFlush listener), masked as ******** in responses and validator logs, and decrypted only in ApiClientFactory.
  • PUT/PATCH: an omitted key or the mask keeps the stored secret, null removes it, nothing carries over when type changes.
  • No usable key → SecretEncryptionException, never clear text. Configurations without secrets need no key.
  • app:api-configuration:encrypt-secrets [--dry-run] encrypts existing rows, idempotent.

#9 — client contract

  • ApiClientInterface keeps what every source supports; getCustomers(), getTodoLists(), hasChanges() move to CustomerAwareApiClientInterface, TodoListAwareApiClientInterface, ChangeProbeInterface.
  • authenticate(): void throws AuthenticationFailedException with the transport exception as previous.
  • ApiClientFactory::create() validates the decrypted config against the extension's schema.json (InvalidConfigurationException names the field; _-prefixed keys are skipped).
  • HealthProbeInterface for types without a full client (schema-only types, MCP); ApiConfigurationHealthChecker is shared by the health route and command.
  • Health route keeps metadata/error keys in JSON-LD (api_sub_level).

Fixes

Breaking changes

Listed in CHANGELOG.md; release as 0.5.0.

  1. Reads require ROLE_ADMIN (no option to restore ROLE_USER).
  2. Secrets are masked in responses, also for admins.
  3. getConfigJson() returns enc:v1:… for marked keys; code reading secrets from the entity directly must use ConfigSecrets::decrypt().
  4. A usable CREDENTIALS_ENCRYPTION_KEY is required once a secret is stored.
  5. ApiClientInterface slimmer; authenticate() returns void and throws.
  6. ApiClientFactory rejects configs that violate the extension schema.
  7. Constructor changes in ApiClientFactory, ApiConfigurationValidator, CreateApiConfigurationCommand, ApiConfigurationHealthProvider, ApiConfigurationHealthCommand (autowired consumers unaffected).

Required downstream changes

  • Key: dmstr_api_platform_utils.credential_encryption.key: '%env(base64:CREDENTIALS_ENCRYPTION_KEY)%' — CredentialEncryption expects raw bytes, dmstr:generate-encryption-key prints base64. Then run app:api-configuration:encrypt-secrets once.
  • Schemas: add "writeOnly": true to password, token, client_secret and equivalents in every type schema (adapters in the applications, flowable bundle, MCP type). Unmarked keys stay clear text.
  • Clients (Basecamp 2/4, GitHub, GitLab, Jira XML):
    • authenticate(): void, rethrow via AuthenticationFailedException::fromPrevious($apiName, $e);
    • drop stubs, implement only the capability interfaces the source supports;
    • hasChanges() must fail open — the Jira XML client currently returns false on error;
    • required-field isset() checks in createClient() can go.
  • Callers (scanners, sync): instanceof checks before getCustomers(), getTodoLists(), hasChanges(); a client without ChangeProbeInterface counts as changed. Catch AuthenticationFailedException instead of checking a bool.
  • Extensions reading secrets from the entity (e.g. an OAuth client secret in buildAuthorizeUrl()): decrypt with ConfigSecrets::decrypt().
  • Non-admin consumers of /api/admin/api_configurations (including /health) need ROLE_ADMIN.

Tests

44 tests, 119 assertions (GitHub Actions, PHP 8.4): encryption round trip and idempotence, masking, keep-on-update rules, missing/wrong key, re-encryption command and onFlush listener against SQLite, ROLE_ADMIN on every operation, factory schema validation, health probe vs. client fallback, health operation metadata. Known limitation, not changed here: the validator checks against the unified anyOf schema of SchemaRegistry, which cannot resolve a type schema's local $ref: #/definitions/… (opis Unresolved reference). No type schema in the known applications uses local refs; ApiClientFactory validates against the extension's own schema and is not affected.

Not covered end-to-end (needs a kernel): the 403 over HTTP and the JSON-LD output of /health.

🤖 Generated with Claude Code

schmunk42 and others added 7 commits October 5, 2026 11:44
Closes #8.

- All operations of ApiConfiguration, reads included, require ROLE_ADMIN.
- Type schemas mark secret keys with "writeOnly": true.
- Marked keys are encrypted at rest (CredentialEncryption, onFlush
  listener), masked as "********" in responses and validator logs, and
  decrypted only in ApiClientFactory.
- On PUT/PATCH an omitted key or the mask keeps the stored secret, null
  removes it.
- New command app:api-configuration:encrypt-secrets (idempotent) for
  existing rows.
- Fix app:api:test-connection calling non-existent getCredentials().

BREAKING CHANGE: reads require ROLE_ADMIN; secrets are masked in API
responses; getConfigJson() returns enc:v1: ciphertext for marked keys;
a usable CREDENTIALS_ENCRYPTION_KEY is required once a secret is stored;
ApiClientFactory, ApiConfigurationValidator and
CreateApiConfigurationCommand take an additional ConfigSecrets argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…M [*]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ma-validated config [*]

Closes #9.

- ApiClientInterface keeps what every source supports; getCustomers(),
  getTodoLists() and hasChanges() move to CustomerAware-,
  TodoListAwareApiClientInterface and ChangeProbeInterface.
- authenticate(): void throws AuthenticationFailedException with the
  transport exception as previous.
- ApiClientFactory::create() validates the decrypted config against the
  extension's schema.json and throws InvalidConfigurationException naming
  the field.
- HealthProbeInterface for types without a full client; the shared
  ApiConfigurationHealthChecker serves route and command.
- Health operation normalizes nested arrays raw (api_sub_level), so
  metadata and error keep their keys in JSON-LD.

BREAKING CHANGE: ApiClientInterface loses getCustomers(), getTodoLists()
and hasChanges(); authenticate() returns void and throws; invalid configs
are rejected by ApiClientFactory before createClient().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The command read ApiConfiguration::getFileConfig(), which no longer
exists since file sources are configured by directory (Jira XML), and
failed with a PHP error. Build the client instead and use
FileApiClientInterface::validateFile()/parseFile(), so the format checks
live with the type, not in the command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 500 [*]

Closes #5.

With no schema provider registered, SchemaRegistry returns `anyOf: []`,
which opis rejects while parsing the schema; the uncaught exception
became HTTP 500 on every write. Add a violation instead (HTTP 422).
Also declare symfony/validator, used directly but only installed
transitively.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unified anyOf schema of SchemaRegistry cannot resolve a type
schema's local #/definitions references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@schmunk42 schmunk42 changed the title feat!: secure configJson credentials, slimmer client contract (#8, #9) feat!: secure configJson credentials, slimmer client contract (#5, #8, #9) Oct 5, 2026
@schmunk42
schmunk42 merged commit 84f350a into master Oct 5, 2026
2 checks passed
@schmunk42

Copy link
Copy Markdown
Member Author

@eluhr Breaking Changes in 0.5.0-beta1 upcoming - FYI

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant