@@ -19,12 +19,22 @@ exchanges it at LFX Auth0 for a short-lived Bearer token, then calls CDP.
1919week of 2026-07-27. Working assumption pending that confirmation: Akrites
2020has an AWS account and a workload IAM role (ECS task role or EKS pod role
2121via IRSA). The RSA private key sits in an ** LF-owned** AWS Secrets Manager
22- entry; a resource policy on that secret grants
23- ` secretsmanager:GetSecretValue ` (+ ` kms:Decrypt ` if CMK-encrypted) to
24- Akrites' workload role ARN. Akrites' app makes a direct cross-account read
25- (same pattern as cross-account S3), validated by the item policy on LF's
26- secret. LF rotates the keypair inside LF's AWS boundary; Akrites re-reads
27- on ` invalid_client ` without redeploy.
22+ entry. A resource policy on the secret grants ` secretsmanager:GetSecretValue `
23+ to Akrites' workload role ARN. Akrites' app makes a direct cross-account
24+ call (same pattern as cross-account S3), validated by the item policy on
25+ LF's secret. LF rotates the keypair inside LF's AWS boundary; how Akrites
26+ picks up a rotated value depends on which delivery pattern is chosen (see
27+ the Akrites section below).
28+
29+ > ** KMS note (to validate with LF DevOps):** cross-account Secrets Manager
30+ > reads are commonly documented as requiring a customer-managed KMS key —
31+ > the AWS-managed ` aws/secretsmanager ` key policy can't be modified to
32+ > grant ` kms:Decrypt ` to an external principal. If that is accurate for
33+ > LF's current SM setup, the LF secret must be created against a CMK and
34+ > the KMS key policy must also grant ` kms:Decrypt ` to Akrites' workload
35+ > role. LF DevOps should confirm the encryption mode used by existing
36+ > cross-account CDP secrets and whether a CMK+key-policy step is needed
37+ > here.
2838
2939Role trust on the Akrites side (who can assume the workload role, whether
3040Akrites users can also assume it to fetch the secret manually) is Akrites'
@@ -36,16 +46,16 @@ for LF to grant access to), we can fall back to the shared `client_secret`
3646variant (see Alternatives Considered) — everything else in this ADR is
3747unaffected.
3848
39- Two consumer-shape distinctions matter:
40-
41- - ` /akrites ` (existing, unchanged) is called by LFX Self Serve. Self Serve
42- users already hold tokens in the LFX platform realm, so that route accepts
43- the LFX platform audience ( ` lfx_v2_api ` ). This avoids a second token
44- exchange for user-context calls.
45- - ` /akrites-external ` (new) is pure M2M with no pre-existing realm on the
46- Akrites side. It accepts the existing ` cdp_public_api ` audience
47- ( ` https://cm.lfx.dev/api/ ` in prod, ` https://lf-staging.crowd.dev/api/ ` in
48- dev + staging) — the natural audience for calls into CDP's own API .
49+ Both ` /akrites ` (existing, called by LFX Self Serve) and the new
50+ ` /akrites-external ` share the CDP public API's single audience
51+ ( ` https://cm.lfx.dev/api/ ` in prod, ` https://lf-staging.crowd.dev/api/ ` in
52+ dev + staging) via the shared ` AUTH0_CONFIG ` used by every public route —
53+ ` oauth2Middleware ` verifies exactly one audience. What differentiates the
54+ two routes is the route-level middleware chain applied on top:
55+ ` /akrites-external ` adds an ` azp ` allowlist and a stricter scope set
56+ ( ` read:packages ` , ` read:advisories ` , ` read:maintainers ` ). Consumers of
57+ ` /akrites ` continue to hit it with whatever LFX-issued token satisfies the
58+ same CDP audience — no change to that route .
4959
5060Consumer isolation on ` /akrites-external ` is enforced by an explicit ` azp `
5161allowlist middleware — that is the sole gate distinguishing Akrites from
@@ -55,20 +65,19 @@ also constrain which handlers the token may reach. Auth0 client IDs are
5565referenced as ` {{AKRITES_AUTH0_CLIENT_ID}} ` /
5666` {{AKRITES_AUTH0_CLIENT_ID_STAGING}} ` pending client provisioning.
5767
58- High-level overview: ` docs/akrites-cdp-auth-highlevel.md `
59- (mirror of the internal DSP page).
60-
6168## Decision
6269
6370Authenticate Akrites against the ** existing ` cdp_public_api ` resource
6471server** , gated by an ** ` azp ` allowlist middleware** in CDP (sole consumer
6572identity gate) and domain scopes ** ` read:packages ` ** , ** ` read:advisories ` ** ,
6673** ` read:maintainers ` ** on the granted token. Distribute the RSA private
6774key via an ** LF-owned AWS Secrets Manager entry** with a ** resource policy
68- granting Akrites' workload IAM role** ` GetSecretValue ` (+ ` kms:Decrypt ` if
69- CMK); Akrites reads cross-account, same pattern as cross-account S3.
70- Assumes Akrites has an AWS account and a workload role — pending
71- confirmation (see Context).
75+ on the secret** granting ` GetSecretValue ` to Akrites' workload IAM role.
76+ If the secret is encrypted with a customer-managed KMS key (likely
77+ required for cross-account decrypt — LF DevOps to confirm), the KMS key
78+ policy must also grant ` kms:Decrypt ` to that role. Akrites reads
79+ cross-account, same pattern as cross-account S3. Assumes Akrites has an
80+ AWS account and a workload role — pending confirmation (see Context).
7281
7382## Auth Flow
7483
@@ -89,7 +98,7 @@ sequenceDiagram
8998 Auth0-->>Akrites: Bearer access_token aud=cdp_public_api scope=read:packages+read:advisories+read:maintainers
9099
91100 Note over Akrites,CDP: API call
92- Akrites->>CDP: GET /public /v1/akrites-external/* + Bearer token
101+ Akrites->>CDP: GET /api /v1/akrites-external/* + Bearer token
93102 CDP->>Auth0: fetch JWKS, cached
94103 CDP->>CDP: oauth2Middleware verifies sig + iss + aud
95104 CDP->>CDP: azpAllowlistMiddleware asserts azp == AKRITES_EXTERNAL_CLIENT_ID
@@ -170,10 +179,17 @@ client. Pattern mirrors every other rotating `auth0_jwt` M2M client:
170179
171180** Resource policy on the LF secret** — grants Akrites' workload IAM role
172181` secretsmanager:GetSecretValue ` + ` secretsmanager:DescribeSecret ` . Deny
173- wildcards. If the secret is CMK-encrypted, the KMS key policy must also
174- allow ` kms:Decrypt ` for that role. Akrites' AWS account ID + workload role
175- ARN required from the Akrites team before the resource policy can be
176- written.
182+ wildcards. Akrites' AWS account ID + workload role ARN required from the
183+ Akrites team before the resource policy can be written.
184+
185+ ** Encryption (to validate with LF DevOps)** — AWS documentation indicates
186+ that cross-account Secrets Manager reads require the secret to be
187+ encrypted with a customer-managed KMS key; the default
188+ ` aws/secretsmanager ` key policy is AWS-owned and cannot grant ` kms:Decrypt `
189+ to external principals. Before writing the resource policy, LF DevOps
190+ should confirm which encryption mode existing cross-account CDP secrets
191+ use. If a CMK is needed, the KMS key policy must also grant ` kms:Decrypt `
192+ to Akrites' workload role.
177193
178194Role trust on Akrites' side (who can assume the workload role) is Akrites'
179195concern; LF configures only the item policy on the LF secret.
@@ -218,13 +234,9 @@ READ_MAINTAINERS: 'read:maintainers',
218234Reads ` req.auth.payload.azp ` . Throws ` UnauthorizedError ` if the value is
219235missing or not in the allowlist passed at wire-up. Fails closed.
220236
221- ** ` backend/src/api/public/v1/index.ts ` ** (line 46)
222-
223- Replace:
224- ``` ts
225- router .use (' /akrites-external' , oauth2Middleware (AUTH0_CONFIG ), akritesExternalRouter ())
226- ```
227- With:
237+ ** ` backend/src/api/public/v1/index.ts ` ** — insert a ** new** route
238+ registration after the existing ` /akrites ` mount (line 44) and before the
239+ 404 catch-all at line 46:
228240``` ts
229241router .use (
230242 ' /akrites-external' ,
@@ -238,6 +250,11 @@ router.use(
238250)
239251```
240252
253+ ` akritesExternalRouter() ` is a ** new module** at
254+ ` backend/src/api/public/v1/akrites-external/index.ts ` — separate from the
255+ existing ` akritesRouter ` and free of its inner scope guards, so the
256+ mount-level ` requireScopes ` above is authoritative for this route.
257+
241258` /akrites ` (Self Serve) is untouched.
242259
243260---
@@ -246,20 +263,50 @@ router.use(
246263
247264Implement the token exchange described in the Auth Flow diagram:
248265
249- 1 . Fetch RSA private key from LF's AWS Secrets Manager entry
266+ 1 . Fetch the RSA private key from LF's AWS Secrets Manager entry
250267 (cross-account read). Akrites' workload IAM role's identity policy must
251- allow ` secretsmanager:GetSecretValue ` (+ ` kms:Decrypt ` if CMK) on the
252- full LF secret ARN. Prod options: ECS ` ValueFrom ` on the task definition
253- with the LF ARN, or EKS External Secrets Operator + IRSA. Dev:
254- 1Password via the LF-provided vault item.
255- 2 . Build and sign ` client_assertion ` JWT (RS256).
256- 3 . POST to Auth0 ` /oauth/token ` with ` grant_type=client_credentials ` +
257- ` client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer ` .
258- Cache the returned Bearer token until close to expiry.
259- 4 . Attach Bearer token to every ` /public/v1/akrites-external/* ` request as
268+ allow ` secretsmanager:GetSecretValue ` on the full LF secret ARN. If
269+ LF confirms the secret is encrypted with a customer-managed KMS key
270+ (see the Context KMS note), also allow ` kms:Decrypt ` on that KMS key
271+ ARN. Choose one of the delivery patterns Eric outlined; each has a
272+ different rotation behavior:
273+ - ** ECS ` ValueFrom ` ** on the task definition, referencing the LF
274+ secret ARN. The secret is injected as an environment variable at
275+ task start only — ECS does not refresh ` ValueFrom ` values while the
276+ task is running. Rotation therefore requires task replacement; the
277+ step-5 retry-once flow means restarting the task, not an in-process
278+ re-read.
279+ - ** EKS External Secrets Operator + IRSA** , with the operator
280+ configured to re-sync the secret on a short refresh interval so
281+ pods pick up the rotated value shortly after LF rotates. In-process
282+ retry is still possible if the operator has already refreshed.
283+ - ** Direct SDK read** using the workload role — process reads the
284+ secret via ` GetSecretValue ` at boot and again on demand for the
285+ step-5 retry. Simplest match for the retry-once flow without
286+ touching the task/pod lifecycle.
287+
288+ Dev: 1Password via the LF-provided vault item.
289+ 2 . Build and sign a ` client_assertion ` JWT with ` alg: RS256 ` and header
290+ ` kid ` set to the current key's ID. Claims: ` iss ` = ` sub ` =
291+ Akrites client ID, ` aud ` = LFX Auth0 tenant token endpoint URL, short
292+ ` exp ` (≤ 5 min), unique ` jti ` .
293+ 3 . POST to Auth0 ` /oauth/token ` as ` application/x-www-form-urlencoded `
294+ with:
295+ - ` grant_type=client_credentials `
296+ - ` client_id=<AKRITES_EXTERNAL_CLIENT_ID> `
297+ - ` audience=<cdp_public_api identifier> ` — ` https://cm.lfx.dev/api/ `
298+ in prod, ` https://lf-staging.crowd.dev/api/ ` in dev + staging
299+ - ` client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer `
300+ - ` client_assertion=<signed JWT from step 2> `
301+
302+ Cache the returned Bearer token until close to expiry (leave a
303+ clock-skew margin, e.g. refresh at ` exp - 60s ` ).
304+ 4 . Attach Bearer token to every ` /api/v1/akrites-external/* ` request as
260305 ` Authorization: Bearer <token> ` .
261- 5 . On ` invalid_client ` : discard cached key → ` GetSecretValue ` for the latest
262- version → retry token exchange once. LF rotates the keypair without notice.
306+ 5 . On ` invalid_client ` : discard the cached key → re-fetch from the secret
307+ store (SDK read, or task/pod refresh depending on the pattern chosen
308+ in step 1) → retry the token exchange once. LF rotates the keypair
309+ without notice.
263310
264311## Alternatives Considered
265312
0 commit comments