Skip to content

Commit 70f1c0b

Browse files
committed
docs: address ADR-0009 review feedback (CM-1333)
Fix audience contradiction (shared AUTH0_CONFIG across public routes), correct public URL path to /api/v1/akrites-external, reword the new route mount as an insertion before the 404 handler, soften KMS wording with a DevOps validation note, expand Akrites-side delivery patterns to reflect ValueFrom rotation constraints, spell out the /oauth/token request body, drop the stale highlevel doc reference, and bump the ADR index date. Signed-off-by: Joana Maia <jmaia@contractor.linuxfoundation.org>
1 parent 2e01746 commit 70f1c0b

2 files changed

Lines changed: 95 additions & 48 deletions

File tree

docs/adr/0009-akrites-cdp-public-api-authentication.md

Lines changed: 94 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,22 @@ exchanges it at LFX Auth0 for a short-lived Bearer token, then calls CDP.
1919
week of 2026-07-27. Working assumption pending that confirmation: Akrites
2020
has an AWS account and a workload IAM role (ECS task role or EKS pod role
2121
via 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
2939
Role trust on the Akrites side (who can assume the workload role, whether
3040
Akrites 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`
3646
variant (see Alternatives Considered) — everything else in this ADR is
3747
unaffected.
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

5060
Consumer isolation on `/akrites-external` is enforced by an explicit `azp`
5161
allowlist 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
5565
referenced 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

6370
Authenticate Akrites against the **existing `cdp_public_api` resource
6471
server**, gated by an **`azp` allowlist middleware** in CDP (sole consumer
6572
identity gate) and domain scopes **`read:packages`**, **`read:advisories`**,
6673
**`read:maintainers`** on the granted token. Distribute the RSA private
6774
key 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

178194
Role trust on Akrites' side (who can assume the workload role) is Akrites'
179195
concern; LF configures only the item policy on the LF secret.
@@ -218,13 +234,9 @@ READ_MAINTAINERS: 'read:maintainers',
218234
Reads `req.auth.payload.azp`. Throws `UnauthorizedError` if the value is
219235
missing 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
229241
router.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

247264
Implement 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

docs/adr/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions.
1515
| [ADR-0006](./0006-database-schema-types-as-source-of-truth.md) | Database schema types as the source of truth | accepted | 2026-07-09 |
1616
| [ADR-0007](./0007-test-factory-primitives-and-defaults.md) | Test factory primitives and defaults | accepted | 2026-07-10 |
1717
| [ADR-0008](./0008-how-we-write-unit-tests.md) | How we write unit tests | accepted | 2026-07-13 |
18-
| [ADR-0009](./0009-akrites-cdp-public-api-authentication.md) | Akrites → CDP public API authentication | proposed | 2026-07-14 |
18+
| [ADR-0009](./0009-akrites-cdp-public-api-authentication.md) | Akrites → CDP public API authentication | proposed | 2026-07-21 |
1919

2020
## Why ADRs?
2121

0 commit comments

Comments
 (0)