From 863bdb80d3204e2cac16a20384f6a2eef4927eef Mon Sep 17 00:00:00 2001 From: Joana Maia Date: Wed, 15 Jul 2026 11:29:09 +0100 Subject: [PATCH 1/7] docs: add ADR-0009 for Akrites CDP public API authentication (CM-1333) Signed-off-by: Joana Maia --- ...9-akrites-cdp-public-api-authentication.md | 204 ++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 205 insertions(+) create mode 100644 docs/adr/0009-akrites-cdp-public-api-authentication.md diff --git a/docs/adr/0009-akrites-cdp-public-api-authentication.md b/docs/adr/0009-akrites-cdp-public-api-authentication.md new file mode 100644 index 0000000000..45e3bfbcfb --- /dev/null +++ b/docs/adr/0009-akrites-cdp-public-api-authentication.md @@ -0,0 +1,204 @@ +# ADR-0009: Akrites → CDP public API authentication + +**Date**: 2026-07-15 +**Status**: proposed +**Deciders**: CDP team, LF Auth, Akrites team + +## Context + +Akrites is a new upstream consumer that needs read-only access to CDP's public +API. LF Auth provisions LF-managed M2M credentials using RSA keypair +authentication: Akrites signs a JWT `client_assertion` with a vault-resident +private key, exchanges it at LFX Auth0 for a short-lived Bearer token, then +calls CDP. CDP verifies the token, enforces a single-consumer restriction, and +gates endpoints on a dedicated scope. + +The Auth0 audience (resource-server identifier) is not yet finalized — it is +referenced throughout as `{{AKRITES_CDP_AUDIENCE}}`. + +High-level overview: https://linuxfoundation-dxwx.dsp.so/fkNtkFCx-akrites-cdp-auth + +## Decision + +Authenticate Akrites via **LFX Auth0 with a dedicated resource server and +required scopes**. On the CDP side, add an `azp` allowlist middleware +after JWT verification that asserts the token was issued specifically to +`{{AKRITES_AUTH0_CLIENT_ID}}`, so no other M2M client granted the same +audience can reach the `/akrites` router. + +## Auth Flow + +```mermaid +sequenceDiagram + participant Vault as LF Vault + participant Akrites + participant Auth0 as LFX Auth0 + participant CDP + + Note over Akrites,Vault: On startup or after rotation + Akrites->>Vault: read RSA private key + Vault-->>Akrites: private key + + Note over Akrites,Auth0: Token exchange (repeated on expiry) + Akrites->>Akrites: sign client_assertion JWT (RS256) + Akrites->>Auth0: POST /oauth/token (client_credentials + jwt-bearer) + Auth0-->>Akrites: Bearer access_token (scope=read:) + + Note over Akrites,CDP: API call + Akrites->>CDP: GET /public/v1/akrites/* + Bearer token + CDP->>Auth0: fetch JWKS (cached) + CDP->>CDP: oauth2Middleware: verify sig + iss + aud + CDP->>CDP: azpAllowlistMiddleware: assert azp == AKRITES_CLIENT_ID + CDP->>CDP: requireScopes: assert read: + CDP-->>Akrites: 200 OK + + Note over Akrites,Auth0: On invalid_client (key rotated by LF) + Akrites->>Vault: re-read private key + Akrites->>Auth0: retry token exchange once +``` + +## Affected Repositories + +### `auth0-terraform` + +Three changes. The existing `auth0_resource_server.cdp_public_api` (lines 400–494 +of `resource_servers.tf`) and `grants_cdp.tf` are the direct reference — Akrites +gets its own isolated resource server, not a grant on the shared CDP one. + +**`resource_servers.tf`** — add these two blocks: +```hcl +resource "auth0_resource_server" "cdp_akrites_api" { + name = "CDP Akrites API" + identifier = { + "dev" = "{{AKRITES_CDP_AUDIENCE_DEV}}" + "staging" = "{{AKRITES_CDP_AUDIENCE_STAGING}}" + "prod" = "{{AKRITES_CDP_AUDIENCE_PROD}}" + }[terraform.workspace] + + signing_alg = "RS256" + token_lifetime = { + "dev" = 3600 + "staging" = 10800 + "prod" = 10800 + }[terraform.workspace] + token_dialect = "access_token" + allow_offline_access = false + + subject_type_authorization { + user { policy = "deny_all" } + client { policy = "require_client_grant" } + } +} + +resource "auth0_resource_server_scopes" "cdp_akrites_api" { + resource_server_identifier = auth0_resource_server.cdp_akrites_api.identifier + + scopes { + name = "read:" + description = "Read-only access to CDP data via the Akrites API" + } +} +``` + +**`clients_m2m.tf`** — add one entry to `local.m2m_clients` (line ~10): +```hcl +"Akrites" = { oidc_conformant = true }, +``` +The existing `auth0_client.m2m_clients` `for_each` resource instantiates it +automatically with `grant_types = ["client_credentials"]`. Auth method starts +as `client_secret_post`; `lfx-secrets-management` rotation converts it to +Private Key JWT — same as all other M2M clients. + +**`grants_akrites.tf`** _(new file)_ +```hcl +resource "auth0_client_grant" "akrites_cdp" { + client_id = auth0_client.m2m_clients["Akrites"].id + audience = auth0_resource_server.cdp_akrites_api.identifier + scopes = ["read:"] + depends_on = [auth0_resource_server_scopes.cdp_akrites_api] +} +``` + +--- + +### `lfx-secrets-management` + +No structural changes. Add Akrites as a new entry in the sync config: + +- **Source**: Auth0 — uses existing `Auth0JWTConfig` model + (`secretsmanagement/services/auth0.py`) +- **Destinations**: + - AWS Secrets Manager (prod) — `secretsmanagement/services/aws.py` via `boto3` + - 1Password (dev) — `secretsmanagement/services/onepassword.py` via `op` CLI +- **Orchestration**: `secretsmanagement/sync.py` lines 37–96 — Akrites follows + same source → destination pattern as all other M2M clients + +CDP holds no private key. Token verification is JWKS-only. + +--- + +### `crowd.dev` (CDP — this repo) + +**`backend/config/custom-environment-variables.json`** (currently lines 161–166) + +Add alongside the existing `auth0` block: +```json +"auth0Akrites": { + "issuerBaseURLs": "CROWD_AUTH0_AKRITES_ISSUER_BASE_URLS", + "audience": "CROWD_AUTH0_AKRITES_AUDIENCE", + "clientId": "CROWD_AUTH0_AKRITES_CLIENT_ID" +} +``` + +**`backend/src/conf/index.ts`** (line 106) + +Add: +```ts +export const AKRITES_AUTH0_CONFIG: Auth0Configuration = config.get('auth0Akrites') +``` +`Auth0Configuration` interface (`backend/src/conf/configTypes.ts` lines 68–73) is reused as-is. + +**`backend/src/security/scopes.ts`** + +Add to `SCOPES` const: +```ts +READ_RESOURCE: 'read:', +``` + +**`backend/src/api/public/middlewares/azpAllowlistMiddleware.ts`** _(new file)_ + +Reads `req.auth.payload.azp`. Throws `UnauthorizedError` if not in the +configured allowlist. Fails closed — any unknown client ID is rejected even if +Auth0 is misconfigured to grant it the same audience. + +**`backend/src/api/public/v1/index.ts`** (line 44) + +Replace: +```ts +router.use('/akrites', oauth2Middleware(AUTH0_CONFIG), akritesRouter()) +``` +With: +```ts +router.use( + '/akrites', + oauth2Middleware(AKRITES_AUTH0_CONFIG), + azpAllowlistMiddleware([AKRITES_AUTH0_CONFIG.clientId]), + requireScopes([SCOPES.READ_AKRITES], 'all'), + akritesRouter(), +) +``` + +--- + +### Akrites (external repo) + +Implement the token exchange described in the Auth Flow diagram above: + +1. Read RSA private key from vault (AWS Secrets Manager in prod via + `lfx-secrets-management`, 1Password in dev). +2. Build and sign `client_assertion` JWT (RS256). +3. POST to Auth0 `/oauth/token` — cache the returned Bearer token until it + expires. +4. Attach Bearer token to all CDP requests via `Authorization` header. +5. On `invalid_client`: discard cached key → re-read from vault → retry token + exchange once. LF rotates the keypair without notice. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1aec4e9273..5413c6b0f2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,6 +15,7 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions. | [ADR-0006](./0006-database-schema-types-as-source-of-truth.md) | Database schema types as the source of truth | accepted | 2026-07-09 | | [ADR-0007](./0007-test-factory-primitives-and-defaults.md) | Test factory primitives and defaults | accepted | 2026-07-10 | | [ADR-0008](./0008-how-we-write-unit-tests.md) | How we write unit tests | accepted | 2026-07-13 | +| [ADR-0009](./0009-akrites-cdp-public-api-authentication.md) | Akrites → CDP public API authentication | proposed | 2026-07-14 | ## Why ADRs? From a8c185ef0f785e202d0a65a5a8c884ef2f93e9cd Mon Sep 17 00:00:00 2001 From: Joana Maia Date: Tue, 21 Jul 2026 14:43:15 +0100 Subject: [PATCH 2/7] docs: refine ADR-0009 for Akrites CDP auth (CM-1333) Align proposed approach with LF-owned Secrets Manager cross-account read model, prune Alternatives to shared client_secret fallback only, switch scopes to read:packages + read:advisories + read:maintainers, and fix Mermaid diagram parse errors. Signed-off-by: Joana Maia --- ...9-akrites-cdp-public-api-authentication.md | 336 ++++++++++++------ 1 file changed, 223 insertions(+), 113 deletions(-) diff --git a/docs/adr/0009-akrites-cdp-public-api-authentication.md b/docs/adr/0009-akrites-cdp-public-api-authentication.md index 45e3bfbcfb..2027ff425a 100644 --- a/docs/adr/0009-akrites-cdp-public-api-authentication.md +++ b/docs/adr/0009-akrites-cdp-public-api-authentication.md @@ -1,59 +1,103 @@ # ADR-0009: Akrites → CDP public API authentication -**Date**: 2026-07-15 +**Date**: 2026-07-21 **Status**: proposed **Deciders**: CDP team, LF Auth, Akrites team ## Context -Akrites is a new upstream consumer that needs read-only access to CDP's public -API. LF Auth provisions LF-managed M2M credentials using RSA keypair -authentication: Akrites signs a JWT `client_assertion` with a vault-resident -private key, exchanges it at LFX Auth0 for a short-lived Bearer token, then -calls CDP. CDP verifies the token, enforces a single-consumer restriction, and -gates endpoints on a dedicated scope. - -The Auth0 audience (resource-server identifier) is not yet finalized — it is -referenced throughout as `{{AKRITES_CDP_AUDIENCE}}`. - -High-level overview: https://linuxfoundation-dxwx.dsp.so/fkNtkFCx-akrites-cdp-auth +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 that secret grants +`secretsmanager:GetSecretValue` (+ `kms:Decrypt` if CMK-encrypted) to +Akrites' workload role ARN. Akrites' app makes a direct cross-account read +(same pattern as cross-account S3), validated by the item policy on LF's +secret. LF rotates the keypair inside LF's AWS boundary; Akrites re-reads +on `invalid_client` without redeploy. + +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. + +Two consumer-shape distinctions matter: + +- `/akrites` (existing, unchanged) is called by LFX Self Serve. Self Serve + users already hold tokens in the LFX platform realm, so that route accepts + the LFX platform audience (`lfx_v2_api`). This avoids a second token + exchange for user-context calls. +- `/akrites-external` (new) is pure M2M with no pre-existing realm on the + Akrites side. It accepts the existing `cdp_public_api` audience + (`https://cm.lfx.dev/api/` in prod, `https://lf-staging.crowd.dev/api/` in + dev + staging) — the natural audience for calls into CDP's own API. + +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. + +High-level overview: `docs/akrites-cdp-auth-highlevel.md` +(mirror of the internal DSP page). ## Decision -Authenticate Akrites via **LFX Auth0 with a dedicated resource server and -required scopes**. On the CDP side, add an `azp` allowlist middleware -after JWT verification that asserts the token was issued specifically to -`{{AKRITES_AUTH0_CLIENT_ID}}`, so no other M2M client granted the same -audience can reach the `/akrites` router. +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 +granting Akrites' workload IAM role** `GetSecretValue` (+ `kms:Decrypt` if +CMK); 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 Vault as LF Vault participant Akrites + participant SM as LF AWS Secrets Manager participant Auth0 as LFX Auth0 participant CDP - Note over Akrites,Vault: On startup or after rotation - Akrites->>Vault: read RSA private key - Vault-->>Akrites: private key + 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 (RS256) - Akrites->>Auth0: POST /oauth/token (client_credentials + jwt-bearer) - Auth0-->>Akrites: Bearer access_token (scope=read:) + 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 /public/v1/akrites/* + Bearer token - CDP->>Auth0: fetch JWKS (cached) - CDP->>CDP: oauth2Middleware: verify sig + iss + aud - CDP->>CDP: azpAllowlistMiddleware: assert azp == AKRITES_CLIENT_ID - CDP->>CDP: requireScopes: assert read: + Akrites->>CDP: GET /public/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->>Vault: re-read private key + Note over Akrites,Auth0: On invalid_client, key rotated by LF + Akrites->>SM: GetSecretValue cross-account, latest version Akrites->>Auth0: retry token exchange once ``` @@ -61,77 +105,78 @@ sequenceDiagram ### `auth0-terraform` -Three changes. The existing `auth0_resource_server.cdp_public_api` (lines 400–494 -of `resource_servers.tf`) and `grants_cdp.tf` are the direct reference — Akrites -gets its own isolated resource server, not a grant on the shared CDP one. +Three edits, all against the existing `cdp_public_api` resource server. No new +resource server. -**`resource_servers.tf`** — add these two blocks: +**`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 -resource "auth0_resource_server" "cdp_akrites_api" { - name = "CDP Akrites API" - identifier = { - "dev" = "{{AKRITES_CDP_AUDIENCE_DEV}}" - "staging" = "{{AKRITES_CDP_AUDIENCE_STAGING}}" - "prod" = "{{AKRITES_CDP_AUDIENCE_PROD}}" - }[terraform.workspace] - - signing_alg = "RS256" - token_lifetime = { - "dev" = 3600 - "staging" = 10800 - "prod" = 10800 - }[terraform.workspace] - token_dialect = "access_token" - allow_offline_access = false - - subject_type_authorization { - user { policy = "deny_all" } - client { policy = "require_client_grant" } - } +scopes { + name = "read:advisories" + description = "Read security advisories" } - -resource "auth0_resource_server_scopes" "cdp_akrites_api" { - resource_server_identifier = auth0_resource_server.cdp_akrites_api.identifier - - scopes { - name = "read:" - description = "Read-only access to CDP data via the Akrites API" - } +scopes { + name = "read:maintainers" + description = "Read package maintainer data" } ``` -**`clients_m2m.tf`** — add one entry to `local.m2m_clients` (line ~10): +**`clients_m2m.tf`** — add one entry to `local.m2m_clients`: ```hcl -"Akrites" = { oidc_conformant = true }, +"Akrites External" = { + oidc_conformant = true +} ``` -The existing `auth0_client.m2m_clients` `for_each` resource instantiates it -automatically with `grant_types = ["client_credentials"]`. Auth method starts -as `client_secret_post`; `lfx-secrets-management` rotation converts it to -Private Key JWT — same as all other M2M clients. +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_akrites.tf`** _(new file)_ +**`grants_cdp.tf`** — add the grant next to `lfxone_cdp` and +`persona_service_cdp`: ```hcl -resource "auth0_client_grant" "akrites_cdp" { - client_id = auth0_client.m2m_clients["Akrites"].id - audience = auth0_resource_server.cdp_akrites_api.identifier - scopes = ["read:"] - depends_on = [auth0_resource_server_scopes.cdp_akrites_api] +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` -No structural changes. Add Akrites as a new entry in the sync config: +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 — uses existing `Auth0JWTConfig` model - (`secretsmanagement/services/auth0.py`) +- **Source**: `auth0_jwt` with `client_name: Akrites External` - **Destinations**: - - AWS Secrets Manager (prod) — `secretsmanagement/services/aws.py` via `boto3` - - 1Password (dev) — `secretsmanagement/services/onepassword.py` via `op` CLI -- **Orchestration**: `secretsmanagement/sync.py` lines 37–96 — Akrites follows - same source → destination pattern as all other M2M clients + - 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. If the secret is CMK-encrypted, the KMS key policy must also +allow `kms:Decrypt` for that role. Akrites' AWS account ID + workload role +ARN required from the Akrites team before the resource policy can be +written. + +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. @@ -139,66 +184,131 @@ CDP holds no private key. Token verification is JWKS-only. ### `crowd.dev` (CDP — this repo) -**`backend/config/custom-environment-variables.json`** (currently lines 161–166) +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. -Add alongside the existing `auth0` block: +**`backend/config/custom-environment-variables.json`** + +Add one env var under the existing block: ```json -"auth0Akrites": { - "issuerBaseURLs": "CROWD_AUTH0_AKRITES_ISSUER_BASE_URLS", - "audience": "CROWD_AUTH0_AKRITES_AUDIENCE", - "clientId": "CROWD_AUTH0_AKRITES_CLIENT_ID" +"akritesExternal": { + "clientId": "CROWD_AKRITES_EXTERNAL_CLIENT_ID" } ``` -**`backend/src/conf/index.ts`** (line 106) +**`backend/src/conf/index.ts`** Add: ```ts -export const AKRITES_AUTH0_CONFIG: Auth0Configuration = config.get('auth0Akrites') +export const AKRITES_EXTERNAL_CLIENT_ID: string = config.get('akritesExternal.clientId') ``` -`Auth0Configuration` interface (`backend/src/conf/configTypes.ts` lines 68–73) is reused as-is. **`backend/src/security/scopes.ts`** -Add to `SCOPES` const: +Add to the `SCOPES` const (only the two new ones — `READ_PACKAGES` already +exists): ```ts -READ_RESOURCE: 'read:', +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 not in the -configured allowlist. Fails closed — any unknown client ID is rejected even if -Auth0 is misconfigured to grant it the same audience. +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`** (line 44) +**`backend/src/api/public/v1/index.ts`** (line 46) Replace: ```ts -router.use('/akrites', oauth2Middleware(AUTH0_CONFIG), akritesRouter()) +router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter()) ``` With: ```ts router.use( - '/akrites', - oauth2Middleware(AKRITES_AUTH0_CONFIG), - azpAllowlistMiddleware([AKRITES_AUTH0_CONFIG.clientId]), - requireScopes([SCOPES.READ_AKRITES], 'all'), - akritesRouter(), + '/akrites-external', + oauth2Middleware(AUTH0_CONFIG), + azpAllowlistMiddleware([AKRITES_EXTERNAL_CLIENT_ID]), + requireScopes( + [SCOPES.READ_PACKAGES, SCOPES.READ_ADVISORIES, SCOPES.READ_MAINTAINERS], + 'all', + ), + akritesExternalRouter(), ) ``` +`/akrites` (Self Serve) is untouched. + --- ### Akrites (external repo) -Implement the token exchange described in the Auth Flow diagram above: +Implement the token exchange described in the Auth Flow diagram: -1. Read RSA private key from vault (AWS Secrets Manager in prod via - `lfx-secrets-management`, 1Password in dev). +1. Fetch RSA private key from LF's AWS Secrets Manager entry + (cross-account read). Akrites' workload IAM role's identity policy must + allow `secretsmanager:GetSecretValue` (+ `kms:Decrypt` if CMK) on the + full LF secret ARN. Prod options: ECS `ValueFrom` on the task definition + with the LF ARN, or EKS External Secrets Operator + IRSA. Dev: + 1Password via the LF-provided vault item. 2. Build and sign `client_assertion` JWT (RS256). -3. POST to Auth0 `/oauth/token` — cache the returned Bearer token until it - expires. -4. Attach Bearer token to all CDP requests via `Authorization` header. -5. On `invalid_client`: discard cached key → re-read from vault → retry token - exchange once. LF rotates the keypair without notice. +3. POST to Auth0 `/oauth/token` with `grant_type=client_credentials` + + `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. + Cache the returned Bearer token until close to expiry. +4. Attach Bearer token to every `/public/v1/akrites-external/*` request as + `Authorization: Bearer `. +5. On `invalid_client`: discard cached key → `GetSecretValue` for the latest + version → retry 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. From 70f1c0b131ef24b2d9a143564650eac231c0a3dc Mon Sep 17 00:00:00 2001 From: Joana Maia Date: Tue, 21 Jul 2026 15:13:56 +0100 Subject: [PATCH 3/7] 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 --- ...9-akrites-cdp-public-api-authentication.md | 141 ++++++++++++------ docs/adr/README.md | 2 +- 2 files changed, 95 insertions(+), 48 deletions(-) diff --git a/docs/adr/0009-akrites-cdp-public-api-authentication.md b/docs/adr/0009-akrites-cdp-public-api-authentication.md index 2027ff425a..2db2df747d 100644 --- a/docs/adr/0009-akrites-cdp-public-api-authentication.md +++ b/docs/adr/0009-akrites-cdp-public-api-authentication.md @@ -19,12 +19,22 @@ exchanges it at LFX Auth0 for a short-lived Bearer token, then calls CDP. 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 that secret grants -`secretsmanager:GetSecretValue` (+ `kms:Decrypt` if CMK-encrypted) to -Akrites' workload role ARN. Akrites' app makes a direct cross-account read -(same pattern as cross-account S3), validated by the item policy on LF's -secret. LF rotates the keypair inside LF's AWS boundary; Akrites re-reads -on `invalid_client` without redeploy. +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' @@ -36,16 +46,16 @@ 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. -Two consumer-shape distinctions matter: - -- `/akrites` (existing, unchanged) is called by LFX Self Serve. Self Serve - users already hold tokens in the LFX platform realm, so that route accepts - the LFX platform audience (`lfx_v2_api`). This avoids a second token - exchange for user-context calls. -- `/akrites-external` (new) is pure M2M with no pre-existing realm on the - Akrites side. It accepts the existing `cdp_public_api` audience - (`https://cm.lfx.dev/api/` in prod, `https://lf-staging.crowd.dev/api/` in - dev + staging) — the natural audience for calls into CDP's own API. +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 @@ -55,9 +65,6 @@ 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. -High-level overview: `docs/akrites-cdp-auth-highlevel.md` -(mirror of the internal DSP page). - ## Decision Authenticate Akrites against the **existing `cdp_public_api` resource @@ -65,10 +72,12 @@ 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 -granting Akrites' workload IAM role** `GetSecretValue` (+ `kms:Decrypt` if -CMK); Akrites reads cross-account, same pattern as cross-account S3. -Assumes Akrites has an AWS account and a workload role — pending -confirmation (see Context). +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 @@ -89,7 +98,7 @@ sequenceDiagram 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 /public/v1/akrites-external/* + Bearer token + 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 @@ -170,10 +179,17 @@ client. Pattern mirrors every other rotating `auth0_jwt` M2M client: **Resource policy on the LF secret** — grants Akrites' workload IAM role `secretsmanager:GetSecretValue` + `secretsmanager:DescribeSecret`. Deny -wildcards. If the secret is CMK-encrypted, the KMS key policy must also -allow `kms:Decrypt` for that role. Akrites' AWS account ID + workload role -ARN required from the Akrites team before the resource policy can be -written. +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. @@ -218,13 +234,9 @@ READ_MAINTAINERS: 'read:maintainers', 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`** (line 46) - -Replace: -```ts -router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter()) -``` -With: +**`backend/src/api/public/v1/index.ts`** — insert a **new** route +registration after the existing `/akrites` mount (line 44) and before the +404 catch-all at line 46: ```ts router.use( '/akrites-external', @@ -238,6 +250,11 @@ router.use( ) ``` +`akritesExternalRouter()` is a **new module** at +`backend/src/api/public/v1/akrites-external/index.ts` — separate from the +existing `akritesRouter` and free of its inner scope guards, so the +mount-level `requireScopes` above is authoritative for this route. + `/akrites` (Self Serve) is untouched. --- @@ -246,20 +263,50 @@ router.use( Implement the token exchange described in the Auth Flow diagram: -1. Fetch RSA private key from LF's AWS Secrets Manager entry +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` (+ `kms:Decrypt` if CMK) on the - full LF secret ARN. Prod options: ECS `ValueFrom` on the task definition - with the LF ARN, or EKS External Secrets Operator + IRSA. Dev: - 1Password via the LF-provided vault item. -2. Build and sign `client_assertion` JWT (RS256). -3. POST to Auth0 `/oauth/token` with `grant_type=client_credentials` + - `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. - Cache the returned Bearer token until close to expiry. -4. Attach Bearer token to every `/public/v1/akrites-external/*` request as + 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. Choose one of the delivery patterns Eric outlined; each has a + different rotation behavior: + - **ECS `ValueFrom`** on the task definition, referencing the LF + secret ARN. The secret is injected as an environment variable at + task start only — ECS does not refresh `ValueFrom` values while the + task is running. Rotation therefore requires task replacement; the + step-5 retry-once flow means restarting the task, not an in-process + re-read. + - **EKS External Secrets Operator + IRSA**, with the operator + configured to re-sync the secret on a short refresh interval so + pods pick up the rotated value shortly after LF rotates. In-process + retry is still possible if the operator has already refreshed. + - **Direct SDK read** using the workload role — process reads the + secret via `GetSecretValue` at boot and again on demand for the + step-5 retry. Simplest match for the retry-once flow without + touching the task/pod lifecycle. + + 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 token endpoint URL, 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=` + - `audience=` — `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=` + + 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 `. -5. On `invalid_client`: discard cached key → `GetSecretValue` for the latest - version → retry token exchange once. LF rotates the keypair without notice. +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 diff --git a/docs/adr/README.md b/docs/adr/README.md index 5413c6b0f2..4f591e695d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,7 +15,7 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions. | [ADR-0006](./0006-database-schema-types-as-source-of-truth.md) | Database schema types as the source of truth | accepted | 2026-07-09 | | [ADR-0007](./0007-test-factory-primitives-and-defaults.md) | Test factory primitives and defaults | accepted | 2026-07-10 | | [ADR-0008](./0008-how-we-write-unit-tests.md) | How we write unit tests | accepted | 2026-07-13 | -| [ADR-0009](./0009-akrites-cdp-public-api-authentication.md) | Akrites → CDP public API authentication | proposed | 2026-07-14 | +| [ADR-0009](./0009-akrites-cdp-public-api-authentication.md) | Akrites → CDP public API authentication | proposed | 2026-07-21 | ## Why ADRs? From 2bb785c6a0d467ea42a5e1c8ec28b70d1cf9e730 Mon Sep 17 00:00:00 2001 From: Joana Maia Date: Tue, 21 Jul 2026 15:56:57 +0100 Subject: [PATCH 4/7] docs: address ADR-0009 review comments on route/router refactor and AWS caller identity (CM-1333) Signed-off-by: Joana Maia --- ...9-akrites-cdp-public-api-authentication.md | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/docs/adr/0009-akrites-cdp-public-api-authentication.md b/docs/adr/0009-akrites-cdp-public-api-authentication.md index 2db2df747d..1c0580691c 100644 --- a/docs/adr/0009-akrites-cdp-public-api-authentication.md +++ b/docs/adr/0009-akrites-cdp-public-api-authentication.md @@ -234,9 +234,9 @@ READ_MAINTAINERS: 'read:maintainers', 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`** — insert a **new** route -registration after the existing `/akrites` mount (line 44) and before the -404 catch-all at line 46: +**`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', @@ -250,10 +250,16 @@ router.use( ) ``` -`akritesExternalRouter()` is a **new module** at -`backend/src/api/public/v1/akrites-external/index.ts` — separate from the -existing `akritesRouter` and free of its inner scope guards, so the -mount-level `requireScopes` above is authoritative for this route. +`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). `/akrites` (Self Serve) is untouched. @@ -268,27 +274,25 @@ Implement the token exchange described in the Auth Flow diagram: 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. Choose one of the delivery patterns Eric outlined; each has a - different rotation behavior: - - **ECS `ValueFrom`** on the task definition, referencing the LF - secret ARN. The secret is injected as an environment variable at - task start only — ECS does not refresh `ValueFrom` values while the - task is running. Rotation therefore requires task replacement; the - step-5 retry-once flow means restarting the task, not an in-process - re-read. - - **EKS External Secrets Operator + IRSA**, with the operator - configured to re-sync the secret on a short refresh interval so - pods pick up the rotated value shortly after LF rotates. In-process - retry is still possible if the operator has already refreshed. - - **Direct SDK read** using the workload role — process reads the - secret via `GetSecretValue` at boot and again on demand for the - step-5 retry. Simplest match for the retry-once flow without - touching the task/pod lifecycle. + 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 token endpoint URL, short + 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: From 251453a225e0794c5e483b5e6794086e81c0c262 Mon Sep 17 00:00:00 2001 From: Joana Maia Date: Wed, 22 Jul 2026 12:43:59 +0100 Subject: [PATCH 5/7] docs: switch ADR-0009 to claim-based isolation via Akrites-namespaced scopes (CM-1333) Signed-off-by: Joana Maia --- ...9-akrites-cdp-public-api-authentication.md | 223 ++++++++++-------- 1 file changed, 124 insertions(+), 99 deletions(-) diff --git a/docs/adr/0009-akrites-cdp-public-api-authentication.md b/docs/adr/0009-akrites-cdp-public-api-authentication.md index 1c0580691c..acf0ef79b4 100644 --- a/docs/adr/0009-akrites-cdp-public-api-authentication.md +++ b/docs/adr/0009-akrites-cdp-public-api-authentication.md @@ -7,9 +7,9 @@ ## 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. +API through a new route. 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. @@ -47,37 +47,48 @@ 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 +`/akrites-external` route 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. +two routes is the route-level middleware chain and the set of scopes +required: `/akrites-external` requires Akrites-namespaced scopes +(`read:akrites-packages`, `read:akrites-advisories`, +`read:akrites-maintainers`) via per-endpoint `requireScopes`. Consumers of +`/akrites` continue to use it with LFX-internal scopes (`read:packages`, +`read:stewardships`, etc.) — no change to that route. + +Consumer isolation is claim-based via Auth0 grants. Three Akrites-namespaced +scopes (`read:akrites-packages`, `read:akrites-advisories`, +`read:akrites-maintainers`) are defined on `cdp_public_api` and granted only +to the `Akrites Enclave` client. Auth0 will not issue these scopes to any +other client, so tokens from other CDP consumers (e.g. `lfx_one` for LFX +Self Serve) cannot carry them. CDP's per-endpoint `requireScopes` middleware +is the sole enforcement point at the API layer — no `azp` allowlist or other +client-identity inspection in CDP source. + +CDP does not validate client_id or client_secret. It only sees the +Auth0-signed bearer token. Both `lfx_one` (`client_secret_post`) and +`Akrites Enclave` (`private_key_jwt`) obtain tokens against the same +`cdp_public_api` audience; from CDP's perspective the tokens are +shape-identical. The `private_key_jwt` vs `client_secret_post` distinction +lives entirely between client and Auth0. ## Decision -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). +Authenticate the `Akrites Enclave` client against the **existing +`cdp_public_api` resource server**, gated by three Akrites-namespaced scopes +(`read:akrites-packages`, `read:akrites-advisories`, +`read:akrites-maintainers`) granted only to this client on `cdp_public_api`. +CDP enforces via per-endpoint `requireScopes`; no consumer-identity check +outside the scope claim. 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 @@ -95,14 +106,13 @@ sequenceDiagram 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 + Auth0-->>Akrites: Bearer access_token aud=cdp_public_api scope=read:akrites-packages+read:akrites-advisories+read:akrites-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->>CDP: requireScopes asserts required Akrites scope for the endpoint CDP-->>Akrites: 200 OK Note over Akrites,Auth0: On invalid_client, key rotated by LF @@ -117,23 +127,26 @@ sequenceDiagram 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): +**`resource_servers.tf`** — replace the two scopes added in this branch with +three Akrites-namespaced ones inside `auth0_resource_server_scopes.cdp_public_api`: ```hcl scopes { - name = "read:advisories" - description = "Read security advisories" + name = "read:akrites-packages" + description = "Read package data via the Akrites Enclave surface" } scopes { - name = "read:maintainers" - description = "Read package maintainer data" + name = "read:akrites-advisories" + description = "Read security advisories via the Akrites Enclave surface" +} +scopes { + name = "read:akrites-maintainers" + description = "Read package maintainer data via the Akrites Enclave surface" } ``` **`clients_m2m.tf`** — add one entry to `local.m2m_clients`: ```hcl -"Akrites External" = { +"Akrites Enclave" = { # Client for Akrites to consume the CDP public API oidc_conformant = true } ``` @@ -145,34 +158,43 @@ client with `grant_types = ["client_credentials"]`. Auth method starts as **`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 +# Akrites Enclave CDP grant. Consumer isolation is claim-based: the three +# `read:akrites-*` scopes below are granted only to this client on +# cdp_public_api. Auth0 refuses to issue these scopes to any other client, +# so tokens from other CDP consumers (e.g. lfx_one) cannot carry them, and +# CDP's per-endpoint requireScopes middleware blocks any request without +# them. To add another external consumer of the Akrites-shaped surface, +# add a new grant here with its own scopes; to open a scope to another +# consumer, add it to that consumer's grant here — the governance surface +# is this file. +# +# Client credential is rotated from client_secret_post to private_key_jwt +# by lfx-secrets-management (same path used by other CDP M2M clients). +resource "auth0_client_grant" "akrites_enclave_cdp" { + client_id = auth0_client.m2m_clients["Akrites Enclave"].id audience = auth0_resource_server.cdp_public_api.identifier scopes = [ - "read:packages", - "read:advisories", - "read:maintainers", + "read:akrites-packages", + "read:akrites-advisories", + "read:akrites-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 +Add a new entry in `secrets/lfx/auth0_clients.yml` for the Akrites Enclave client. Pattern mirrors every other rotating `auth0_jwt` M2M client: -- **Source**: `auth0_jwt` with `client_name: Akrites External` +- **Source**: `auth0_jwt` with `client_name: Akrites Enclave` - **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 + other CDP M2M credential; path `auth0/Akrites_Enclave`. Write is same-account for LF. - **Orchestration**: `secretsmanagement/sync.py` — no code change; the existing `auth0_jwt` → destinations pipeline handles it. @@ -200,66 +222,48 @@ 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 +The audience for the Akrites Enclave route is the existing CDP audience — +same `AUTH0_CONFIG` already used by every other public route. No new `Auth0Configuration` block is needed. -**`backend/config/custom-environment-variables.json`** +**`backend/src/security/scopes.ts`** -Add one env var under the existing block: -```json -"akritesExternal": { - "clientId": "CROWD_AKRITES_EXTERNAL_CLIENT_ID" -} +Add three new consts (only these three are new — existing scopes are +unchanged): +```ts +READ_AKRITES_PACKAGES: 'read:akrites-packages', +READ_AKRITES_ADVISORIES: 'read:akrites-advisories', +READ_AKRITES_MAINTAINERS: 'read:akrites-maintainers', ``` -**`backend/src/conf/index.ts`** - -Add: +**`backend/src/api/public/v1/index.ts`** — mount at the existing position: ```ts -export const AKRITES_EXTERNAL_CLIENT_ID: string = config.get('akritesExternal.clientId') +router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter()) ``` -**`backend/src/security/scopes.ts`** +No mount-level `requireScopes` — scope checks are per-subrouter inside +`akritesExternalRouter()`, same pattern as `akritesRouter()`. + +**`backend/src/api/public/v1/akrites-external/index.ts`** — replace the +placeholder scope constants (current TODO comments call this out explicitly) +with the newly-provisioned Akrites-namespaced scopes: -Add to the `SCOPES` const (only the two new ones — `READ_PACKAGES` already -exists): ```ts -READ_ADVISORIES: 'read:advisories', -READ_MAINTAINERS: 'read:maintainers', -``` +// packages subrouter +packagesSubRouter.use(requireScopes([SCOPES.READ_AKRITES_PACKAGES])) -**`backend/src/api/public/middlewares/azpAllowlistMiddleware.ts`** _(new file)_ +// advisories subrouter +advisoriesSubRouter.use(requireScopes([SCOPES.READ_AKRITES_ADVISORIES])) -Reads `req.auth.payload.azp`. Throws `UnauthorizedError` if the value is -missing or not in the allowlist passed at wire-up. Fails closed. +// contacts subrouter +contactsSubRouter.use(requireScopes([SCOPES.READ_AKRITES_MAINTAINERS])) -**`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(), -) +// blast-radius subrouter (same surface as advisories per the contract) +blastRadiusSubRouter.use(requireScopes([SCOPES.READ_AKRITES_ADVISORIES])) ``` -`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). +Remove the TODO comments once the scopes are provisioned — the swap is +complete. `/akrites` (Self Serve) is untouched. @@ -297,7 +301,7 @@ Implement the token exchange described in the Auth Flow diagram: 3. POST to Auth0 `/oauth/token` as `application/x-www-form-urlencoded` with: - `grant_type=client_credentials` - - `client_id=` + - `client_id=` - `audience=` — `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` @@ -347,7 +351,7 @@ the RSA-keypair-signed `client_assertion` flow. 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` + `Akrites Enclave` 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. @@ -357,9 +361,30 @@ Delta to the ADR body if this fallback is selected: 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. + regardless of how Akrites authed to Auth0. The oauth2 + requireScopes + middleware chain stays 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. + +### `azp` allowlist middleware for consumer identity (superseded) + +Earlier revisions of this ADR gated the Akrites Enclave route with an +`azpAllowlistMiddleware` reading `req.auth.payload.azp` against the Akrites +client ID from env. Consumer identity lived in CDP source code (a client-ID +allowlist), independent of Auth0's grant model. + +- **Pros**: consumer gate does not depend on how scopes are named or + granted; a single generic scope set could be shared across consumers. +- **Cons**: resource server becomes coupled to specific client IDs; every + new consumer or client-ID rotation is a CDP code + redeploy change; + identity is invisible to Auth0's governance surface (grants, resource- + server scope model). Diverges from the rest of the CDP public API, which + already gates on scopes via `requireScopes` (e.g. + `/packages:batch-stewardship`). +- **Why superseded**: reviewer feedback on the `auth0-terraform` PR + (@detjensrobert, 2026-07-21) — trust decisions on resource servers + should be claim-based, not caller-metadata based. Namespaced + Akrites-only scopes put the identity gate inside Auth0's grant model and + let CDP stay pure-claims via the existing `requireScopes` middleware. From 53b01259161bd24d86c4efbeb98528a6f0728222 Mon Sep 17 00:00:00 2001 From: Joana Maia Date: Wed, 22 Jul 2026 16:19:28 +0100 Subject: [PATCH 6/7] docs: change ADR number Signed-off-by: Joana Maia --- ...ation.md => 0010-akrites-cdp-public-api-authentication.md} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename docs/adr/{0009-akrites-cdp-public-api-authentication.md => 0010-akrites-cdp-public-api-authentication.md} (99%) diff --git a/docs/adr/0009-akrites-cdp-public-api-authentication.md b/docs/adr/0010-akrites-cdp-public-api-authentication.md similarity index 99% rename from docs/adr/0009-akrites-cdp-public-api-authentication.md rename to docs/adr/0010-akrites-cdp-public-api-authentication.md index acf0ef79b4..3be01ed719 100644 --- a/docs/adr/0009-akrites-cdp-public-api-authentication.md +++ b/docs/adr/0010-akrites-cdp-public-api-authentication.md @@ -1,6 +1,6 @@ -# ADR-0009: Akrites → CDP public API authentication +# ADR-0010: Akrites → CDP public API authentication -**Date**: 2026-07-21 +**Date**: 2026-07-22 **Status**: proposed **Deciders**: CDP team, LF Auth, Akrites team From 8d793fd091aef6e00a5c5800c7c41d7fd7c6227b Mon Sep 17 00:00:00 2001 From: Joana Maia Date: Wed, 22 Jul 2026 16:45:38 +0100 Subject: [PATCH 7/7] docs: trim Akrites-side steps in ADR-0010 to high-level and note atomic secret payload (CM-1333) Signed-off-by: Joana Maia --- ...0-akrites-cdp-public-api-authentication.md | 67 +++++++------------ 1 file changed, 23 insertions(+), 44 deletions(-) diff --git a/docs/adr/0010-akrites-cdp-public-api-authentication.md b/docs/adr/0010-akrites-cdp-public-api-authentication.md index 3be01ed719..7b218a182c 100644 --- a/docs/adr/0010-akrites-cdp-public-api-authentication.md +++ b/docs/adr/0010-akrites-cdp-public-api-authentication.md @@ -271,50 +271,29 @@ complete. ### 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=` - - `audience=` — `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=` - - 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 `. -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. +High-level responsibilities only. Concrete implementation (delivery +pattern, IAM wiring, exact JWT header/claim shape, HTTP form fields) is +deferred until Akrites' AWS setup is confirmed (week of 2026-07-27) and +`lfx-secrets-management` finalizes the secret payload; + +1. **Fetch** the credential material from LF's AWS Secrets Manager + entry cross-account. The delivery pattern (ECS `ValueFrom`, EKS + External Secrets Operator, direct SDK read) is chosen based on where + Akrites runs; LF grants the item policy to whichever IAM role + performs the fetch. Dev environment: 1Password via the LF-provided + vault item. +2. **Sign a `client_assertion` JWT** with the fetched private key and + exchange it at LFX Auth0 for a short-lived Bearer token against the + `cdp_public_api` audience. The SM payload is atomic — it carries the + private key together with any Auth0-side identifiers (e.g. the + credential `kid`) LF ships alongside the key. Akrites reads the whole + payload as one unit and uses everything in it to build the assertion. +3. **Call** `/api/v1/akrites-external/*` with `Authorization: Bearer + `. Cache the token until close to expiry, refreshing with a + clock-skew margin. +4. **On `invalid_client`** (LF rotated the keypair without notice): + discard the cached credential material, re-read from the secret + store, retry the token exchange once. ## Alternatives Considered