Loading
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/partnersacceptour_client_secret(write-only, 1–4096 chars). Responses gainour_client_secret_set; the value never comes back, not even in a 400.- One source per partner. Sending both
our_client_secretandour_client_secret_refis a 400; setting one on an existing partner drops the other. APATCHwithout the field keeps the stored secret,nullremoves it. The "all three outbound fields or none" 422 counts either source as the secret. - New
partner_op_secretstable (migration0007), 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 501partner-secret-storage-unavailable. Partners onour_client_secret_refdon't need it.FileClientSecretTokenProvideris nowClientSecretTokenProvider: 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.cryptographyis a direct dependency now (it came in throughpyjwt[crypto]before);uv.lockupdated.
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.pyand 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_dbholds12 + len + 16bytes 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