Skip to content

Commit 251453a

Browse files
committed
docs: switch ADR-0009 to claim-based isolation via Akrites-namespaced scopes (CM-1333)
Signed-off-by: Joana Maia <jmaia@contractor.linuxfoundation.org>
1 parent 2bb785c commit 251453a

1 file changed

Lines changed: 124 additions & 99 deletions

File tree

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

Lines changed: 124 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
## Context
88

99
Akrites is a new external consumer that needs read-only access to CDP's public
10-
API through a new route, `/akrites-external`. The route runs on the existing
11-
CDP public API listener (`v1Router`, `backend/src/api/public/v1/index.ts`) —
12-
same service, same process as `/members`, `/organizations`, `/akrites`, etc.
10+
API through a new route. The route runs on the existing CDP public API listener
11+
(`v1Router`, `backend/src/api/public/v1/index.ts`) — same service, same
12+
process as `/members`, `/organizations`, `/akrites`, etc.
1313

1414
Auth is M2M with an RSA keypair: Akrites signs a JWT `client_assertion`,
1515
exchanges it at LFX Auth0 for a short-lived Bearer token, then calls CDP.
@@ -47,37 +47,48 @@ variant (see Alternatives Considered) — everything else in this ADR is
4747
unaffected.
4848

4949
Both `/akrites` (existing, called by LFX Self Serve) and the new
50-
`/akrites-external` share the CDP public API's single audience
50+
`/akrites-external` route share the CDP public API's single audience
5151
(`https://cm.lfx.dev/api/` in prod, `https://lf-staging.crowd.dev/api/` in
5252
dev + staging) via the shared `AUTH0_CONFIG` used by every public route —
5353
`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.
59-
60-
Consumer isolation on `/akrites-external` is enforced by an explicit `azp`
61-
allowlist middleware — that is the sole gate distinguishing Akrites from
62-
other CDP consumers. Data-domain access is gated separately by three
63-
scopes (`read:packages`, `read:advisories`, `read:maintainers`), which
64-
also constrain which handlers the token may reach. Auth0 client IDs are
65-
referenced as `{{AKRITES_AUTH0_CLIENT_ID}}` /
66-
`{{AKRITES_AUTH0_CLIENT_ID_STAGING}}` pending client provisioning.
54+
two routes is the route-level middleware chain and the set of scopes
55+
required: `/akrites-external` requires Akrites-namespaced scopes
56+
(`read:akrites-packages`, `read:akrites-advisories`,
57+
`read:akrites-maintainers`) via per-endpoint `requireScopes`. Consumers of
58+
`/akrites` continue to use it with LFX-internal scopes (`read:packages`,
59+
`read:stewardships`, etc.) — no change to that route.
60+
61+
Consumer isolation is claim-based via Auth0 grants. Three Akrites-namespaced
62+
scopes (`read:akrites-packages`, `read:akrites-advisories`,
63+
`read:akrites-maintainers`) are defined on `cdp_public_api` and granted only
64+
to the `Akrites Enclave` client. Auth0 will not issue these scopes to any
65+
other client, so tokens from other CDP consumers (e.g. `lfx_one` for LFX
66+
Self Serve) cannot carry them. CDP's per-endpoint `requireScopes` middleware
67+
is the sole enforcement point at the API layer — no `azp` allowlist or other
68+
client-identity inspection in CDP source.
69+
70+
CDP does not validate client_id or client_secret. It only sees the
71+
Auth0-signed bearer token. Both `lfx_one` (`client_secret_post`) and
72+
`Akrites Enclave` (`private_key_jwt`) obtain tokens against the same
73+
`cdp_public_api` audience; from CDP's perspective the tokens are
74+
shape-identical. The `private_key_jwt` vs `client_secret_post` distinction
75+
lives entirely between client and Auth0.
6776

6877
## Decision
6978

70-
Authenticate Akrites against the **existing `cdp_public_api` resource
71-
server**, gated by an **`azp` allowlist middleware** in CDP (sole consumer
72-
identity gate) and domain scopes **`read:packages`**, **`read:advisories`**,
73-
**`read:maintainers`** on the granted token. Distribute the RSA private
74-
key via an **LF-owned AWS Secrets Manager entry** with a **resource policy
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).
79+
Authenticate the `Akrites Enclave` client against the **existing
80+
`cdp_public_api` resource server**, gated by three Akrites-namespaced scopes
81+
(`read:akrites-packages`, `read:akrites-advisories`,
82+
`read:akrites-maintainers`) granted only to this client on `cdp_public_api`.
83+
CDP enforces via per-endpoint `requireScopes`; no consumer-identity check
84+
outside the scope claim. Distribute the RSA private key via an **LF-owned
85+
AWS Secrets Manager entry** with a **resource policy on the secret** granting
86+
`GetSecretValue` to Akrites' workload IAM role. If the secret is encrypted
87+
with a customer-managed KMS key (likely required for cross-account decrypt —
88+
LF DevOps to confirm), the KMS key policy must also grant `kms:Decrypt` to
89+
that role. Akrites reads cross-account, same pattern as cross-account S3.
90+
Assumes Akrites has an AWS account and a workload role — pending confirmation
91+
(see Context).
8192

8293
## Auth Flow
8394

@@ -95,14 +106,13 @@ sequenceDiagram
95106
Note over Akrites,Auth0: Token exchange, repeated on expiry
96107
Akrites->>Akrites: sign client_assertion JWT with RS256
97108
Akrites->>Auth0: POST /oauth/token client_credentials + jwt-bearer
98-
Auth0-->>Akrites: Bearer access_token aud=cdp_public_api scope=read:packages+read:advisories+read:maintainers
109+
Auth0-->>Akrites: Bearer access_token aud=cdp_public_api scope=read:akrites-packages+read:akrites-advisories+read:akrites-maintainers
99110
100111
Note over Akrites,CDP: API call
101112
Akrites->>CDP: GET /api/v1/akrites-external/* + Bearer token
102113
CDP->>Auth0: fetch JWKS, cached
103114
CDP->>CDP: oauth2Middleware verifies sig + iss + aud
104-
CDP->>CDP: azpAllowlistMiddleware asserts azp == AKRITES_EXTERNAL_CLIENT_ID
105-
CDP->>CDP: requireScopes asserts read:packages + read:advisories + read:maintainers
115+
CDP->>CDP: requireScopes asserts required Akrites scope for the endpoint
106116
CDP-->>Akrites: 200 OK
107117
108118
Note over Akrites,Auth0: On invalid_client, key rotated by LF
@@ -117,23 +127,26 @@ sequenceDiagram
117127
Three edits, all against the existing `cdp_public_api` resource server. No new
118128
resource server.
119129

120-
**`resource_servers.tf`** — append two new scopes inside
121-
`auth0_resource_server_scopes.cdp_public_api` (`read:packages` already
122-
exists on the resource server and is reused):
130+
**`resource_servers.tf`** — replace the two scopes added in this branch with
131+
three Akrites-namespaced ones inside `auth0_resource_server_scopes.cdp_public_api`:
123132
```hcl
124133
scopes {
125-
name = "read:advisories"
126-
description = "Read security advisories"
134+
name = "read:akrites-packages"
135+
description = "Read package data via the Akrites Enclave surface"
127136
}
128137
scopes {
129-
name = "read:maintainers"
130-
description = "Read package maintainer data"
138+
name = "read:akrites-advisories"
139+
description = "Read security advisories via the Akrites Enclave surface"
140+
}
141+
scopes {
142+
name = "read:akrites-maintainers"
143+
description = "Read package maintainer data via the Akrites Enclave surface"
131144
}
132145
```
133146

134147
**`clients_m2m.tf`** — add one entry to `local.m2m_clients`:
135148
```hcl
136-
"Akrites External" = {
149+
"Akrites Enclave" = { # Client for Akrites to consume the CDP public API
137150
oidc_conformant = true
138151
}
139152
```
@@ -145,34 +158,43 @@ client with `grant_types = ["client_credentials"]`. Auth method starts as
145158
**`grants_cdp.tf`** — add the grant next to `lfxone_cdp` and
146159
`persona_service_cdp`:
147160
```hcl
148-
resource "auth0_client_grant" "akrites_external_cdp" {
149-
client_id = auth0_client.m2m_clients["Akrites External"].id
161+
# Akrites Enclave CDP grant. Consumer isolation is claim-based: the three
162+
# `read:akrites-*` scopes below are granted only to this client on
163+
# cdp_public_api. Auth0 refuses to issue these scopes to any other client,
164+
# so tokens from other CDP consumers (e.g. lfx_one) cannot carry them, and
165+
# CDP's per-endpoint requireScopes middleware blocks any request without
166+
# them. To add another external consumer of the Akrites-shaped surface,
167+
# add a new grant here with its own scopes; to open a scope to another
168+
# consumer, add it to that consumer's grant here — the governance surface
169+
# is this file.
170+
#
171+
# Client credential is rotated from client_secret_post to private_key_jwt
172+
# by lfx-secrets-management (same path used by other CDP M2M clients).
173+
resource "auth0_client_grant" "akrites_enclave_cdp" {
174+
client_id = auth0_client.m2m_clients["Akrites Enclave"].id
150175
audience = auth0_resource_server.cdp_public_api.identifier
151176
scopes = [
152-
"read:packages",
153-
"read:advisories",
154-
"read:maintainers",
177+
"read:akrites-packages",
178+
"read:akrites-advisories",
179+
"read:akrites-maintainers",
155180
]
156181
157182
depends_on = [auth0_resource_server_scopes.cdp_public_api]
158183
}
159184
```
160185

161-
Note: `read:packages` is already granted to `lfxone_cdp`. Scope alone does
162-
not identify the Akrites consumer — `azp` allowlist on the CDP side does.
163-
164186
---
165187

166188
### `lfx-secrets-management`
167189

168-
Add a new entry in `secrets/lfx/auth0_clients.yml` for the Akrites External
190+
Add a new entry in `secrets/lfx/auth0_clients.yml` for the Akrites Enclave
169191
client. Pattern mirrors every other rotating `auth0_jwt` M2M client:
170192

171-
- **Source**: `auth0_jwt` with `client_name: Akrites External`
193+
- **Source**: `auth0_jwt` with `client_name: Akrites Enclave`
172194
- **Destinations**:
173195
- 1Password (all envs) — safe default, gives operators a browsable copy
174196
- AWS Secrets Manager in the **LF account** — same SM account as every
175-
other CDP M2M credential; path `auth0/Akrites_External`. Write is
197+
other CDP M2M credential; path `auth0/Akrites_Enclave`. Write is
176198
same-account for LF.
177199
- **Orchestration**: `secretsmanagement/sync.py` — no code change; the
178200
existing `auth0_jwt` → destinations pipeline handles it.
@@ -200,66 +222,48 @@ CDP holds no private key. Token verification is JWKS-only.
200222

201223
### `crowd.dev` (CDP — this repo)
202224

203-
The audience for `/akrites-external` is the existing CDP audience — same
204-
`AUTH0_CONFIG` already used by every other public route. No new
225+
The audience for the Akrites Enclave route is the existing CDP audience —
226+
same `AUTH0_CONFIG` already used by every other public route. No new
205227
`Auth0Configuration` block is needed.
206228

207-
**`backend/config/custom-environment-variables.json`**
229+
**`backend/src/security/scopes.ts`**
208230

209-
Add one env var under the existing block:
210-
```json
211-
"akritesExternal": {
212-
"clientId": "CROWD_AKRITES_EXTERNAL_CLIENT_ID"
213-
}
231+
Add three new consts (only these three are new — existing scopes are
232+
unchanged):
233+
```ts
234+
READ_AKRITES_PACKAGES: 'read:akrites-packages',
235+
READ_AKRITES_ADVISORIES: 'read:akrites-advisories',
236+
READ_AKRITES_MAINTAINERS: 'read:akrites-maintainers',
214237
```
215238

216-
**`backend/src/conf/index.ts`**
217-
218-
Add:
239+
**`backend/src/api/public/v1/index.ts`** — mount at the existing position:
219240
```ts
220-
export const AKRITES_EXTERNAL_CLIENT_ID: string = config.get<string>('akritesExternal.clientId')
241+
router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter())
221242
```
222243

223-
**`backend/src/security/scopes.ts`**
244+
No mount-level `requireScopes` — scope checks are per-subrouter inside
245+
`akritesExternalRouter()`, same pattern as `akritesRouter()`.
246+
247+
**`backend/src/api/public/v1/akrites-external/index.ts`** — replace the
248+
placeholder scope constants (current TODO comments call this out explicitly)
249+
with the newly-provisioned Akrites-namespaced scopes:
224250

225-
Add to the `SCOPES` const (only the two new ones — `READ_PACKAGES` already
226-
exists):
227251
```ts
228-
READ_ADVISORIES: 'read:advisories',
229-
READ_MAINTAINERS: 'read:maintainers',
230-
```
252+
// packages subrouter
253+
packagesSubRouter.use(requireScopes([SCOPES.READ_AKRITES_PACKAGES]))
231254

232-
**`backend/src/api/public/middlewares/azpAllowlistMiddleware.ts`** _(new file)_
255+
// advisories subrouter
256+
advisoriesSubRouter.use(requireScopes([SCOPES.READ_AKRITES_ADVISORIES]))
233257

234-
Reads `req.auth.payload.azp`. Throws `UnauthorizedError` if the value is
235-
missing or not in the allowlist passed at wire-up. Fails closed.
258+
// contacts subrouter
259+
contactsSubRouter.use(requireScopes([SCOPES.READ_AKRITES_MAINTAINERS]))
236260

237-
**`backend/src/api/public/v1/index.ts`** — update the existing
238-
`/akrites-external` mount at line 46 to add the `azp` allowlist and the
239-
mount-level scope check on top of the existing `oauth2Middleware`:
240-
```ts
241-
router.use(
242-
'/akrites-external',
243-
oauth2Middleware(AUTH0_CONFIG),
244-
azpAllowlistMiddleware([AKRITES_EXTERNAL_CLIENT_ID]),
245-
requireScopes(
246-
[SCOPES.READ_PACKAGES, SCOPES.READ_ADVISORIES, SCOPES.READ_MAINTAINERS],
247-
'all',
248-
),
249-
akritesExternalRouter(),
250-
)
261+
// blast-radius subrouter (same surface as advisories per the contract)
262+
blastRadiusSubRouter.use(requireScopes([SCOPES.READ_AKRITES_ADVISORIES]))
251263
```
252264

253-
`akritesExternalRouter()` at
254-
`backend/src/api/public/v1/akrites-external/index.ts` already exists (a
255-
recent main merge brought it in) and currently carries per-subrouter
256-
`requireScopes` guards inherited from the pre-decision scaffold —
257-
`[READ_PACKAGES, READ_STEWARDSHIPS]` on packages, `[READ_PACKAGES]` on
258-
advisories/blast-radius, `[READ_MAINTAINER_ROLES]` on contacts. Refactor
259-
the module to strip all inner read-scope guards, so the mount-level
260-
`requireScopes` above is the authoritative check for this route. Leave
261-
any write-scope guards on write routes (there are none today, but the
262-
convention holds).
265+
Remove the TODO comments once the scopes are provisioned — the swap is
266+
complete.
263267

264268
`/akrites` (Self Serve) is untouched.
265269

@@ -297,7 +301,7 @@ Implement the token exchange described in the Auth Flow diagram:
297301
3. POST to Auth0 `/oauth/token` as `application/x-www-form-urlencoded`
298302
with:
299303
- `grant_type=client_credentials`
300-
- `client_id=<AKRITES_EXTERNAL_CLIENT_ID>`
304+
- `client_id=<AKRITES_ENCLAVE_CLIENT_ID>`
301305
- `audience=<cdp_public_api identifier>``https://cm.lfx.dev/api/`
302306
in prod, `https://lf-staging.crowd.dev/api/` in dev + staging
303307
- `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`
@@ -347,7 +351,7 @@ the RSA-keypair-signed `client_assertion` flow.
347351
Delta to the ADR body if this fallback is selected:
348352

349353
- **`auth0-terraform`** — no change to the client, scope, or grant. The
350-
`Akrites External` client stays on the default `client_secret_post`
354+
`Akrites Enclave` client stays on the default `client_secret_post`
351355
auth method (which is what a fresh client uses before
352356
`lfx-secrets-management` rotates it to `private_key_jwt`). Drop the
353357
rotation-to-JWT step for this client.
@@ -357,9 +361,30 @@ Delta to the ADR body if this fallback is selected:
357361
this client becomes manual, coordinated with the Akrites team, and
358362
performed by re-issuing the secret in Auth0 and re-delivering it.
359363
- **`crowd.dev`** — no change. CDP receives an identical Bearer JWT
360-
regardless of how Akrites authed to Auth0. The oauth2 / azp / scope
361-
middleware chain, config, and env vars stay the same.
364+
regardless of how Akrites authed to Auth0. The oauth2 + requireScopes
365+
middleware chain stays the same.
362366
- **Akrites side** — drop the RSA signing, JWKS setup, and cross-account
363367
IAM entirely. Store `client_id` + `client_secret` in their own
364368
environment secret store. On `invalid_client`, pause and coordinate with
365369
LF rather than auto-retry; do not tight-loop.
370+
371+
### `azp` allowlist middleware for consumer identity (superseded)
372+
373+
Earlier revisions of this ADR gated the Akrites Enclave route with an
374+
`azpAllowlistMiddleware` reading `req.auth.payload.azp` against the Akrites
375+
client ID from env. Consumer identity lived in CDP source code (a client-ID
376+
allowlist), independent of Auth0's grant model.
377+
378+
- **Pros**: consumer gate does not depend on how scopes are named or
379+
granted; a single generic scope set could be shared across consumers.
380+
- **Cons**: resource server becomes coupled to specific client IDs; every
381+
new consumer or client-ID rotation is a CDP code + redeploy change;
382+
identity is invisible to Auth0's governance surface (grants, resource-
383+
server scope model). Diverges from the rest of the CDP public API, which
384+
already gates on scopes via `requireScopes` (e.g.
385+
`/packages:batch-stewardship`).
386+
- **Why superseded**: reviewer feedback on the `auth0-terraform` PR
387+
(@detjensrobert, 2026-07-21) — trust decisions on resource servers
388+
should be claim-based, not caller-metadata based. Namespaced
389+
Akrites-only scopes put the identity gate inside Auth0's grant model and
390+
let CDP stay pure-claims via the existing `requireScopes` middleware.

0 commit comments

Comments
 (0)