+25
−0
Loading
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.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.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:
tables.py and the migrations produce no autogenerate difffm_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.