-
Notifications
You must be signed in to change notification settings - Fork 730
docs: add ADR-0009 for Akrites CDP public API authentication (CM-1333) #4345
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
joanagmaia
wants to merge
6
commits into
main
Choose a base branch
from
docs/CM-1333-akrites-cdp-auth-adr
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+366
−0
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
863bdb8
docs: add ADR-0009 for Akrites CDP public API authentication (CM-1333)
joanagmaia 31719fc
Merge branch 'main' into docs/CM-1333-akrites-cdp-auth-adr
joanagmaia a8c185e
docs: refine ADR-0009 for Akrites CDP auth (CM-1333)
joanagmaia 2e01746
Merge branch 'main' into docs/CM-1333-akrites-cdp-auth-adr
joanagmaia 70f1c0b
docs: address ADR-0009 review feedback (CM-1333)
joanagmaia 2bb785c
docs: address ADR-0009 review comments on route/router refactor and A…
joanagmaia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,365 @@ | ||
| # ADR-0009: Akrites → CDP public API authentication | ||
|
|
||
| **Date**: 2026-07-21 | ||
| **Status**: proposed | ||
| **Deciders**: CDP team, LF Auth, Akrites team | ||
|
|
||
| ## Context | ||
|
|
||
| Akrites is a new external consumer that needs read-only access to CDP's public | ||
| API through a new route, `/akrites-external`. The route runs on the existing | ||
| CDP public API listener (`v1Router`, `backend/src/api/public/v1/index.ts`) — | ||
| same service, same process as `/members`, `/organizations`, `/akrites`, etc. | ||
|
|
||
| Auth is M2M with an RSA keypair: Akrites signs a JWT `client_assertion`, | ||
| exchanges it at LFX Auth0 for a short-lived Bearer token, then calls CDP. | ||
| `lfx-secrets-management` owns rotation and distributes the credential. | ||
|
|
||
| **Where Akrites runs is not yet known** — confirmation deferred to the | ||
| week of 2026-07-27. Working assumption pending that confirmation: Akrites | ||
| has an AWS account and a workload IAM role (ECS task role or EKS pod role | ||
| via IRSA). The RSA private key sits in an **LF-owned** AWS Secrets Manager | ||
| entry. A resource policy on the secret grants `secretsmanager:GetSecretValue` | ||
| to Akrites' workload role ARN. Akrites' app makes a direct cross-account | ||
| call (same pattern as cross-account S3), validated by the item policy on | ||
| LF's secret. LF rotates the keypair inside LF's AWS boundary; how Akrites | ||
| picks up a rotated value depends on which delivery pattern is chosen (see | ||
| the Akrites section below). | ||
|
|
||
| > **KMS note (to validate with LF DevOps):** cross-account Secrets Manager | ||
| > reads are commonly documented as requiring a customer-managed KMS key — | ||
| > the AWS-managed `aws/secretsmanager` key policy can't be modified to | ||
| > grant `kms:Decrypt` to an external principal. If that is accurate for | ||
| > LF's current SM setup, the LF secret must be created against a CMK and | ||
| > the KMS key policy must also grant `kms:Decrypt` to Akrites' workload | ||
| > role. LF DevOps should confirm the encryption mode used by existing | ||
| > cross-account CDP secrets and whether a CMK+key-policy step is needed | ||
| > here. | ||
|
|
||
| Role trust on the Akrites side (who can assume the workload role, whether | ||
| Akrites users can also assume it to fetch the secret manually) is Akrites' | ||
| concern — LF only cares about the identity of the caller reaching the item | ||
| policy. | ||
|
|
||
| If confirmation reveals Akrites has no AWS account (or no workload role | ||
| for LF to grant access to), we can fall back to the shared `client_secret` | ||
| variant (see Alternatives Considered) — everything else in this ADR is | ||
| unaffected. | ||
|
|
||
| Both `/akrites` (existing, called by LFX Self Serve) and the new | ||
| `/akrites-external` share the CDP public API's single audience | ||
| (`https://cm.lfx.dev/api/` in prod, `https://lf-staging.crowd.dev/api/` in | ||
| dev + staging) via the shared `AUTH0_CONFIG` used by every public route — | ||
| `oauth2Middleware` verifies exactly one audience. What differentiates the | ||
| two routes is the route-level middleware chain applied on top: | ||
| `/akrites-external` adds an `azp` allowlist and a stricter scope set | ||
| (`read:packages`, `read:advisories`, `read:maintainers`). Consumers of | ||
| `/akrites` continue to hit it with whatever LFX-issued token satisfies the | ||
| same CDP audience — no change to that route. | ||
|
|
||
| Consumer isolation on `/akrites-external` is enforced by an explicit `azp` | ||
| allowlist middleware — that is the sole gate distinguishing Akrites from | ||
| other CDP consumers. Data-domain access is gated separately by three | ||
| scopes (`read:packages`, `read:advisories`, `read:maintainers`), which | ||
| also constrain which handlers the token may reach. Auth0 client IDs are | ||
| referenced as `{{AKRITES_AUTH0_CLIENT_ID}}` / | ||
| `{{AKRITES_AUTH0_CLIENT_ID_STAGING}}` pending client provisioning. | ||
|
|
||
| ## Decision | ||
|
joanagmaia marked this conversation as resolved.
|
||
|
|
||
| Authenticate Akrites against the **existing `cdp_public_api` resource | ||
| server**, gated by an **`azp` allowlist middleware** in CDP (sole consumer | ||
| identity gate) and domain scopes **`read:packages`**, **`read:advisories`**, | ||
| **`read:maintainers`** on the granted token. Distribute the RSA private | ||
| key via an **LF-owned AWS Secrets Manager entry** with a **resource policy | ||
| on the secret** granting `GetSecretValue` to Akrites' workload IAM role. | ||
| If the secret is encrypted with a customer-managed KMS key (likely | ||
| required for cross-account decrypt — LF DevOps to confirm), the KMS key | ||
| policy must also grant `kms:Decrypt` to that role. Akrites reads | ||
| cross-account, same pattern as cross-account S3. Assumes Akrites has an | ||
| AWS account and a workload role — pending confirmation (see Context). | ||
|
|
||
| ## Auth Flow | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| participant Akrites | ||
| participant SM as LF AWS Secrets Manager | ||
| participant Auth0 as LFX Auth0 | ||
| participant CDP | ||
|
|
||
| Note over Akrites,SM: On startup or after rotation | ||
| Akrites->>SM: GetSecretValue cross-account via IRSA or ECS task role, item policy on LF secret validates caller | ||
| SM-->>Akrites: RSA private key, latest version | ||
|
|
||
| Note over Akrites,Auth0: Token exchange, repeated on expiry | ||
| Akrites->>Akrites: sign client_assertion JWT with RS256 | ||
| Akrites->>Auth0: POST /oauth/token client_credentials + jwt-bearer | ||
| Auth0-->>Akrites: Bearer access_token aud=cdp_public_api scope=read:packages+read:advisories+read:maintainers | ||
|
|
||
| Note over Akrites,CDP: API call | ||
| Akrites->>CDP: GET /api/v1/akrites-external/* + Bearer token | ||
| CDP->>Auth0: fetch JWKS, cached | ||
| CDP->>CDP: oauth2Middleware verifies sig + iss + aud | ||
| CDP->>CDP: azpAllowlistMiddleware asserts azp == AKRITES_EXTERNAL_CLIENT_ID | ||
| CDP->>CDP: requireScopes asserts read:packages + read:advisories + read:maintainers | ||
| CDP-->>Akrites: 200 OK | ||
|
|
||
| Note over Akrites,Auth0: On invalid_client, key rotated by LF | ||
| Akrites->>SM: GetSecretValue cross-account, latest version | ||
| Akrites->>Auth0: retry token exchange once | ||
| ``` | ||
|
|
||
| ## Affected Repositories | ||
|
joanagmaia marked this conversation as resolved.
|
||
|
|
||
| ### `auth0-terraform` | ||
|
|
||
| Three edits, all against the existing `cdp_public_api` resource server. No new | ||
| resource server. | ||
|
|
||
| **`resource_servers.tf`** — append two new scopes inside | ||
| `auth0_resource_server_scopes.cdp_public_api` (`read:packages` already | ||
| exists on the resource server and is reused): | ||
| ```hcl | ||
| scopes { | ||
| name = "read:advisories" | ||
| description = "Read security advisories" | ||
| } | ||
| scopes { | ||
| name = "read:maintainers" | ||
| description = "Read package maintainer data" | ||
| } | ||
| ``` | ||
|
|
||
| **`clients_m2m.tf`** — add one entry to `local.m2m_clients`: | ||
| ```hcl | ||
| "Akrites External" = { | ||
| oidc_conformant = true | ||
| } | ||
| ``` | ||
| The existing `auth0_client.m2m_clients` `for_each` resource instantiates the | ||
| client with `grant_types = ["client_credentials"]`. Auth method starts as | ||
| `client_secret_post`; `lfx-secrets-management` rotation converts it to | ||
| `private_key_jwt` — same path used for every other CDP M2M client. | ||
|
|
||
| **`grants_cdp.tf`** — add the grant next to `lfxone_cdp` and | ||
| `persona_service_cdp`: | ||
| ```hcl | ||
| resource "auth0_client_grant" "akrites_external_cdp" { | ||
| client_id = auth0_client.m2m_clients["Akrites External"].id | ||
| audience = auth0_resource_server.cdp_public_api.identifier | ||
| scopes = [ | ||
| "read:packages", | ||
| "read:advisories", | ||
| "read:maintainers", | ||
| ] | ||
|
|
||
| depends_on = [auth0_resource_server_scopes.cdp_public_api] | ||
| } | ||
| ``` | ||
|
|
||
| Note: `read:packages` is already granted to `lfxone_cdp`. Scope alone does | ||
| not identify the Akrites consumer — `azp` allowlist on the CDP side does. | ||
|
|
||
| --- | ||
|
|
||
| ### `lfx-secrets-management` | ||
|
|
||
| Add a new entry in `secrets/lfx/auth0_clients.yml` for the Akrites External | ||
| client. Pattern mirrors every other rotating `auth0_jwt` M2M client: | ||
|
|
||
| - **Source**: `auth0_jwt` with `client_name: Akrites External` | ||
| - **Destinations**: | ||
| - 1Password (all envs) — safe default, gives operators a browsable copy | ||
| - AWS Secrets Manager in the **LF account** — same SM account as every | ||
| other CDP M2M credential; path `auth0/Akrites_External`. Write is | ||
| same-account for LF. | ||
| - **Orchestration**: `secretsmanagement/sync.py` — no code change; the | ||
| existing `auth0_jwt` → destinations pipeline handles it. | ||
|
|
||
| **Resource policy on the LF secret** — grants Akrites' workload IAM role | ||
| `secretsmanager:GetSecretValue` + `secretsmanager:DescribeSecret`. Deny | ||
| wildcards. Akrites' AWS account ID + workload role ARN required from the | ||
| Akrites team before the resource policy can be written. | ||
|
|
||
| **Encryption (to validate with LF DevOps)** — AWS documentation indicates | ||
| that cross-account Secrets Manager reads require the secret to be | ||
| encrypted with a customer-managed KMS key; the default | ||
| `aws/secretsmanager` key policy is AWS-owned and cannot grant `kms:Decrypt` | ||
| to external principals. Before writing the resource policy, LF DevOps | ||
| should confirm which encryption mode existing cross-account CDP secrets | ||
| use. If a CMK is needed, the KMS key policy must also grant `kms:Decrypt` | ||
| to Akrites' workload role. | ||
|
|
||
| Role trust on Akrites' side (who can assume the workload role) is Akrites' | ||
| concern; LF configures only the item policy on the LF secret. | ||
|
|
||
| CDP holds no private key. Token verification is JWKS-only. | ||
|
|
||
| --- | ||
|
|
||
| ### `crowd.dev` (CDP — this repo) | ||
|
|
||
| The audience for `/akrites-external` is the existing CDP audience — same | ||
| `AUTH0_CONFIG` already used by every other public route. No new | ||
| `Auth0Configuration` block is needed. | ||
|
joanagmaia marked this conversation as resolved.
|
||
|
|
||
| **`backend/config/custom-environment-variables.json`** | ||
|
|
||
| Add one env var under the existing block: | ||
| ```json | ||
| "akritesExternal": { | ||
| "clientId": "CROWD_AKRITES_EXTERNAL_CLIENT_ID" | ||
| } | ||
| ``` | ||
|
|
||
| **`backend/src/conf/index.ts`** | ||
|
|
||
| Add: | ||
| ```ts | ||
| export const AKRITES_EXTERNAL_CLIENT_ID: string = config.get<string>('akritesExternal.clientId') | ||
| ``` | ||
|
|
||
| **`backend/src/security/scopes.ts`** | ||
|
|
||
| Add to the `SCOPES` const (only the two new ones — `READ_PACKAGES` already | ||
| exists): | ||
| ```ts | ||
| READ_ADVISORIES: 'read:advisories', | ||
| READ_MAINTAINERS: 'read:maintainers', | ||
| ``` | ||
|
|
||
| **`backend/src/api/public/middlewares/azpAllowlistMiddleware.ts`** _(new file)_ | ||
|
|
||
| Reads `req.auth.payload.azp`. Throws `UnauthorizedError` if the value is | ||
| missing or not in the allowlist passed at wire-up. Fails closed. | ||
|
|
||
| **`backend/src/api/public/v1/index.ts`** — update the existing | ||
| `/akrites-external` mount at line 46 to add the `azp` allowlist and the | ||
| mount-level scope check on top of the existing `oauth2Middleware`: | ||
| ```ts | ||
| router.use( | ||
| '/akrites-external', | ||
| oauth2Middleware(AUTH0_CONFIG), | ||
| azpAllowlistMiddleware([AKRITES_EXTERNAL_CLIENT_ID]), | ||
| requireScopes( | ||
| [SCOPES.READ_PACKAGES, SCOPES.READ_ADVISORIES, SCOPES.READ_MAINTAINERS], | ||
| 'all', | ||
| ), | ||
| akritesExternalRouter(), | ||
| ) | ||
| ``` | ||
|
|
||
| `akritesExternalRouter()` at | ||
| `backend/src/api/public/v1/akrites-external/index.ts` already exists (a | ||
| recent main merge brought it in) and currently carries per-subrouter | ||
| `requireScopes` guards inherited from the pre-decision scaffold — | ||
| `[READ_PACKAGES, READ_STEWARDSHIPS]` on packages, `[READ_PACKAGES]` on | ||
| advisories/blast-radius, `[READ_MAINTAINER_ROLES]` on contacts. Refactor | ||
| the module to strip all inner read-scope guards, so the mount-level | ||
| `requireScopes` above is the authoritative check for this route. Leave | ||
| any write-scope guards on write routes (there are none today, but the | ||
| convention holds). | ||
|
joanagmaia marked this conversation as resolved.
|
||
|
|
||
| `/akrites` (Self Serve) is untouched. | ||
|
|
||
| --- | ||
|
|
||
| ### Akrites (external repo) | ||
|
|
||
| Implement the token exchange described in the Auth Flow diagram: | ||
|
|
||
| 1. Fetch the RSA private key from LF's AWS Secrets Manager entry | ||
| (cross-account read). Akrites' workload IAM role's identity policy must | ||
| allow `secretsmanager:GetSecretValue` on the full LF secret ARN. If | ||
| LF confirms the secret is encrypted with a customer-managed KMS key | ||
| (see the Context KMS note), also allow `kms:Decrypt` on that KMS key | ||
| ARN. Common delivery patterns — Akrites picks one, and the specific | ||
| role ARN(s) LF grants in the item policy follow that choice | ||
| (task execution role for ECS `ValueFrom`, operator SA role for EKS | ||
| External Secrets Operator, workload role for a direct SDK read): | ||
| - **ECS `ValueFrom`** — secret injected at task start only, so | ||
| rotation means task replacement, not an in-process re-read. | ||
| - **EKS External Secrets Operator + IRSA** — pods pick up rotated | ||
| values on the operator's refresh interval. | ||
| - **Direct SDK read** — process reads the secret at boot and again | ||
| on demand for the step-5 retry; simplest match for the | ||
| retry-once flow. | ||
|
|
||
| Dev: 1Password via the LF-provided vault item. | ||
| 2. Build and sign a `client_assertion` JWT with `alg: RS256` and header | ||
| `kid` set to the current key's ID. Claims: `iss` = `sub` = | ||
| Akrites client ID, `aud` = **LFX Auth0 tenant base URL, with the | ||
| trailing slash** (e.g. `https://linuxfoundation.auth0.com/`, or the | ||
| custom-domain equivalent) — Auth0's `private_key_jwt` verifier | ||
| expects the issuer URL, not the `/oauth/token` endpoint. Short | ||
| `exp` (≤ 5 min), unique `jti`. | ||
| 3. POST to Auth0 `/oauth/token` as `application/x-www-form-urlencoded` | ||
| with: | ||
| - `grant_type=client_credentials` | ||
| - `client_id=<AKRITES_EXTERNAL_CLIENT_ID>` | ||
| - `audience=<cdp_public_api identifier>` — `https://cm.lfx.dev/api/` | ||
| in prod, `https://lf-staging.crowd.dev/api/` in dev + staging | ||
| - `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer` | ||
| - `client_assertion=<signed JWT from step 2>` | ||
|
|
||
| Cache the returned Bearer token until close to expiry (leave a | ||
| clock-skew margin, e.g. refresh at `exp - 60s`). | ||
| 4. Attach Bearer token to every `/api/v1/akrites-external/*` request as | ||
| `Authorization: Bearer <token>`. | ||
| 5. On `invalid_client`: discard the cached key → re-fetch from the secret | ||
| store (SDK read, or task/pod refresh depending on the pattern chosen | ||
| in step 1) → retry the token exchange once. LF rotates the keypair | ||
| without notice. | ||
|
|
||
| ## Alternatives Considered | ||
|
|
||
| ### Client secret instead of RSA private_key_jwt (fallback) | ||
|
|
||
| Use the OAuth2 client credentials flow with a shared `client_secret`, the | ||
| same shape as the current `/akrites` (Self Serve) route today, instead of | ||
| the RSA-keypair-signed `client_assertion` flow. | ||
|
|
||
| - **Pros**: No dependency on an Akrites AWS account, IAM role, or any | ||
| workload identity system on their side. Nothing to fetch from a secret | ||
| store at runtime — the secret is delivered once (out-of-band via a | ||
| 1Password share or equivalent) and lives in Akrites' own env config. | ||
| Token exchange is a plain `POST /oauth/token` with `client_id` + | ||
| `client_secret` form fields — no JWT signing, no RSA library, no vault | ||
| library. Fastest path to ship. | ||
| - **Cons**: Shared-secret model — both sides hold a copy of the same | ||
| credential. Rotation requires coordinated hand-off (LF rotates, delivers | ||
| new secret out-of-band, Akrites updates env and redeploys). No automated | ||
| re-fetch on rotation, so a rotation window causes downtime unless | ||
| scheduled with Akrites. Longer blast radius on credential compromise | ||
| compared to the asymmetric-key model where only the public key is shared. | ||
| Diverges from the LF convention of `private_key_jwt` for M2M clients. | ||
| - **Why not (as default)**: Every other LF-managed CDP M2M client is on | ||
| `private_key_jwt` with `lfx-secrets-management` auto-rotation. Sticking | ||
| with that pattern keeps operational load on the LF side and matches | ||
| reviewer expectations. | ||
| - **When to fall back**: If confirmation reveals Akrites has no AWS | ||
| account, or has one but no workload IAM role for LF to grant access to | ||
| via the item policy. The blocker is the cross-account read path, not | ||
| consumption — Akrites can always hold a static `client_secret` in their | ||
| own config. | ||
|
|
||
| Delta to the ADR body if this fallback is selected: | ||
|
|
||
| - **`auth0-terraform`** — no change to the client, scope, or grant. The | ||
| `Akrites External` client stays on the default `client_secret_post` | ||
| auth method (which is what a fresh client uses before | ||
| `lfx-secrets-management` rotates it to `private_key_jwt`). Drop the | ||
| rotation-to-JWT step for this client. | ||
| - **`lfx-secrets-management`** — flip the sync entry source from | ||
| `auth0_jwt` to `auth0` (pattern: `Reimbursement Service client secret`). | ||
| Destination is 1Password only. Drop `auto_rotate: true` — rotation for | ||
| this client becomes manual, coordinated with the Akrites team, and | ||
| performed by re-issuing the secret in Auth0 and re-delivering it. | ||
| - **`crowd.dev`** — no change. CDP receives an identical Bearer JWT | ||
| regardless of how Akrites authed to Auth0. The oauth2 / azp / scope | ||
| middleware chain, config, and env vars stay the same. | ||
| - **Akrites side** — drop the RSA signing, JWKS setup, and cross-account | ||
| IAM entirely. Store `client_id` + `client_secret` in their own | ||
| environment secret store. On `invalid_client`, pause and coordinate with | ||
| LF rather than auto-retry; do not tight-loop. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.