feat(fm): accept the partner client secret by value, stored encrypted

Closes #28 (closed).

The portal can now register a partner with the secret itself instead of a path to a file mounted in FM's container. FM encrypts it, keeps it in fm_db, and never hands it back.

sequenceDiagram
    participant P as Portal
    participant OEG as OEG
    participant FM as FM-A
    participant DB as fm_db
    participant KC as Partner Keycloak
    participant B as FM-B
    P->>OEG: PATCH /platform/v1/federation/partners/{id} {our_client_secret}
    OEG->>FM: PATCH /internal/partners/{id} (relayed)
    FM->>DB: partner_op_secrets = AES-256-GCM(secret)
    FM-->>P: {our_client_secret_set: true, our_client_secret_ref: null}
    Note over FM: token cache miss
    FM->>DB: read ciphertext
    FM->>KC: client_credentials, our_client_id + decrypted secret
    KC-->>FM: access token
    FM->>B: EWBI GET zone (Bearer)

What changes:

  • POST/PATCH /internal/partners accept our_client_secret (write-only, 1–4096 chars). Responses gain our_client_secret_set; the value never comes back, not even in a 400.
  • One source per partner. Sending both our_client_secret and our_client_secret_ref is a 400; setting one on an existing partner drops the other. A PATCH without the field keeps the stored secret, null removes it. The "all three outbound fields or none" 422 counts either source as the secret.
  • New partner_op_secrets table (migration 0007), written in the same transaction as the partner row and read only by the token provider.
  • FM_PARTNER_SECRET_KEY: base64url, 32 bytes, checked at startup without echoing it on failure. Unset, FM starts with a warning and answers a request carrying a secret with 501 partner-secret-storage-unavailable. Partners on our_client_secret_ref don't need it.
  • FileClientSecretTokenProvider is now ClientSecretTokenProvider: file when there's a ref, otherwise the decrypted stored secret. A replaced secret is used from the next token refresh, same as a rewritten file.
  • cryptography is a direct dependency now (it came in through pyjwt[crypto] before); uv.lock updated.

Not in this MR: the helm value for the key, and the portal form change (password field instead of the path). OEG lets the 501 through in open-exposure-gateway!39 (merged).

This goes against #21's "no secret value is accepted, stored, returned or logged". ADR-0067 (oop/architecture!12) records the decision and updates the FM docs; it follows what OEG already does for callback tokens (ADR-0063, encrypted in oeg_db under one app-wide key).

Tests:

  • unit: 333 pass, including the new registry, provider, cipher and key-validation tests
  • integration against Postgres: 15 pass; tables.py and the migrations produce no autogenerate diff
  • e2e on the two-operator docker harness, 27 checks through OEG: with a wrong typed secret Keycloak refuses the token and the zone call gets 503; with the right one FM-B answers the zone, so the decrypted secret is what FM-A used. The secret shows up in no response, error body or container log, fm_db holds 12 + len + 16 bytes of ciphertext, FM-B without a key returns 501 through OEG-B and stores nothing, and switching back to the mounted file works. OEG ran with auth in log-only mode, so the operator role was not exercised.
Edited by Sergio Gimenez

Merge request reports

Loading
Loading