77## Context
88
99Akrites 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
1414Auth is M2M with an RSA keypair: Akrites signs a JWT ` client_assertion ` ,
1515exchanges 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
4747unaffected.
4848
4949Both ` /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
5252dev + 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
117127Three edits, all against the existing ` cdp_public_api ` resource server. No new
118128resource 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
124133scopes {
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}
128137scopes {
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
169191client. 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:
2973013 . 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.
347351Delta 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