Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
863bdb8
docs: add ADR-0009 for Akrites CDP public API authentication (CM-1333)
joanagmaia Jul 15, 2026
31719fc
Merge branch 'main' into docs/CM-1333-akrites-cdp-auth-adr
joanagmaia Jul 15, 2026
a8c185e
docs: refine ADR-0009 for Akrites CDP auth (CM-1333)
joanagmaia Jul 21, 2026
2e01746
Merge branch 'main' into docs/CM-1333-akrites-cdp-auth-adr
joanagmaia Jul 21, 2026
70f1c0b
docs: address ADR-0009 review feedback (CM-1333)
joanagmaia Jul 21, 2026
2bb785c
docs: address ADR-0009 review comments on route/router refactor and A…
joanagmaia Jul 21, 2026
251453a
docs: switch ADR-0009 to claim-based isolation via Akrites-namespaced…
joanagmaia Jul 22, 2026
a2ffa93
Merge remote-tracking branch 'origin/main' into docs/CM-1333-akrites-…
joanagmaia Jul 22, 2026
53b0125
docs: change ADR number
joanagmaia Jul 22, 2026
8d793fd
docs: trim Akrites-side steps in ADR-0010 to high-level and note atom…
joanagmaia Jul 22, 2026
9fb6094
Merge remote-tracking branch 'origin/main' into docs/CM-1333-akrites-…
joanagmaia Jul 23, 2026
626a9fc
fix: link to adr
joanagmaia Jul 23, 2026
06323f3
Merge remote-tracking branch 'origin/main' into docs/CM-1333-akrites-…
joanagmaia Aug 6, 2026
e014147
docs: align akrites auth ADR with confirmed GCP hosting and 1Password…
joanagmaia Aug 6, 2026
57cd934
Merge remote-tracking branch 'origin/docs/CM-1333-akrites-cdp-auth-ad…
joanagmaia Aug 6, 2026
bd2e628
docs: document transitional scope acceptance on /akrites-external (CM…
joanagmaia Aug 10, 2026
eabaece
Merge remote-tracking branch 'origin/main' into docs/CM-1333-akrites-…
joanagmaia Aug 10, 2026
513483a
Merge branch 'main' into docs/CM-1333-akrites-cdp-auth-adr
joanagmaia Aug 10, 2026
ab89118
Merge branch 'main' into docs/CM-1333-akrites-cdp-auth-adr
joanagmaia Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
346 changes: 346 additions & 0 deletions docs/adr/0016-akrites-cdp-public-api-authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
# ADR-0016: Akrites → CDP public API authentication

**Date**: 2026-07-22
**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. The route runs on the existing CDP public API listener
Comment thread
joanagmaia marked this conversation as resolved.
(`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 the credential on the LF side; delivery to
Akrites is via 1Password for now (see below).

**Akrites' service is hosted on GCP** (confirmed with the Akrites team on
2026-07-28). `lfx-secrets-management` delivers credentials to AWS-side
destinations and 1Password today; GCP is not a supported destination, and
adding it is non-trivial (LF has no GCP accounts to test against, and the
auth/service-account setup is undefined). LF CloudOps has taken GCP support
as a backlog item, behind other secret-management automation work
(2026-07-28).

Until GCP support lands, credential delivery is manual: the RSA private
key, `client_id`, and credential `kid` are shared with Akrites via a
**1Password vault item**. Rotation is coordinated between LF and Akrites
rather than automated. The client's auth method is `private_key_jwt`
regardless of how the secret is stored and delivered.

Both `/akrites` (existing, called by LFX Self Serve) and the new
`/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 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
Comment on lines +45 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@joanagmaia this part doesn't reflect the current code, at the moment we also accept other scopes on this route. We should remember to remove the additional ones once the whole flow is in place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Updated the ADR to document the transitional state: added a note in the Context section explaining the requireScopes([...], 'any') fallback (Akrites-namespaced + legacy Self Serve scopes) and updated the implementation snippet to show the actual current scope lists with a call-out to drop the legacy ones on cut-over. Target state description stays as-is.

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.

**Transitional scope acceptance (current code):** During the Akrites
integration cut-over, each `/akrites-external` subrouter accepts either the
Akrites-namespaced scope _or_ the corresponding legacy Self Serve scope
(`read:packages` / `read:stewardships` / `read:maintainer-roles`) via
`requireScopes([...], 'any')`. This lets the Self Serve team test the route
before Akrites' token flow is live. The legacy fallback scopes are removed
once Akrites confirms end-to-end token exchange — at that point only the
Akrites-namespaced scopes remain, matching the target state described above.

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 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. The client authenticates to Auth0 with
`private_key_jwt`. Distribute the RSA private key, `client_id`, and `kid`
to Akrites via a **1Password vault item** (manual, LF-operated); rotation
is coordinated between LF and Akrites. Automated delivery is deferred until
`lfx-secrets-management` supports GCP as a destination (LF CloudOps backlog
item — Akrites runs on GCP).

## Auth Flow

```mermaid
sequenceDiagram
participant LF as LF operator
participant OP as 1Password
participant Akrites
participant Auth0 as LFX Auth0
participant CDP

Note over LF,Akrites: Credential provisioning, one-time and on rotation
LF->>OP: store private key + client_id + kid (from lfx-secrets-management sync)
OP-->>Akrites: shared vault item
Akrites->>Akrites: store credential in own GCP-side secret store

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 API audience URL, 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: requireScopes asserts required Akrites scope for the endpoint
CDP-->>Akrites: 200 OK

Note over LF,Auth0: On invalid_client, key rotated by LF
Akrites->>LF: coordinate re-delivery
LF->>OP: re-share rotated credential
Akrites->>Auth0: token exchange with new key
```

## Affected Repositories

### `auth0-terraform`

Three additions, all against the existing `cdp_public_api` resource server.
No new resource server.

**`resource_servers.tf`** — three Akrites-namespaced scopes inside
`auth0_resource_server_scopes.cdp_public_api`:
```hcl
scopes {
name = "read:akrites-packages"
description = "Read package data via the Akrites Enclave surface"
}
scopes {
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`** — one entry in `local.m2m_clients`:
```hcl
"Akrites Enclave" = { # Client for Akrites to consume the CDP public API
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`** — the grant, next to `lfxone_cdp` and
`persona_service_cdp`:
```hcl
# 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:akrites-packages",
"read:akrites-advisories",
"read:akrites-maintainers",
]

depends_on = [auth0_resource_server_scopes.cdp_public_api]
}
```

---

### `lfx-secrets-management`

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 Enclave`
- **Destinations**: 1Password only (all envs). Akrites runs on GCP, and
GCP is not a supported destination today — the 1Password item is the
hand-off point to the Akrites team.
- **Orchestration**: `secretsmanagement/sync.py` — no code change; the
existing `auth0_jwt` → destinations pipeline handles it.

**Rotation** — manual and coordinated for this client: LF rotates the
keypair in Auth0, re-delivers via 1Password, and Akrites updates their own
secret store. GCP as a delivery destination is an LF CloudOps backlog item;
once it lands, this sync entry gains the GCP destination and rotation
pickup becomes automated, with no change to the Auth0 client or CDP.

CDP holds no private key. Token verification is JWKS-only.

---

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

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/src/security/scopes.ts`**

Three Akrites consts (existing scopes unchanged):
```ts
READ_AKRITES_PACKAGES: 'read:akrites-packages',
READ_AKRITES_ADVISORIES: 'read:akrites-advisories',
READ_AKRITES_MAINTAINERS: 'read:akrites-maintainers',
```

**`backend/src/api/public/v1/index.ts`** — the route is mounted at the
existing position:
```ts
router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter())
```

No mount-level `requireScopes` — scope checks are per-subrouter inside
`akritesExternalRouter()`, same pattern as `akritesRouter()`.

**`backend/src/api/public/v1/akrites-external/index.ts`** — each subrouter
gates on its Akrites-namespaced scope. During the cut-over period the
legacy Self Serve scopes are also accepted (`'any'`); remove them once
Akrites confirms end-to-end token exchange:

```ts
// packages subrouter — drop READ_PACKAGES/READ_STEWARDSHIPS on cut-over
packagesSubRouter.use(
requireScopes([SCOPES.READ_AKRITES_PACKAGES, SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'),
)

// advisories subrouter — drop READ_PACKAGES on cut-over
advisoriesSubRouter.use(requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_AKRITES_ADVISORIES], 'any'))

// contacts subrouter — drop READ_MAINTAINER_ROLES on cut-over
contactsSubRouter.use(requireScopes([SCOPES.READ_MAINTAINER_ROLES, SCOPES.READ_AKRITES_MAINTAINERS], 'any'))

// blast-radius subrouter — drop READ_PACKAGES on cut-over
blastRadiusSubRouter.use(requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_AKRITES_ADVISORIES], 'any'))
```

Target state (post cut-over): `requireScopes([SCOPES.READ_AKRITES_*])` with
no `'any'` fallback — dedicated scope per subrouter, no legacy scopes.

**`backend/src/api/public/v1/akrites-external/openapi.yaml`** — advertises
the Akrites-namespaced scopes everywhere scopes appear: global and
operation-level `security` requirements, 403 response descriptions, tags,
and the OAuth2 flow description — the same scopes `requireScopes` enforces.

`/akrites` (Self Serve) is untouched.

---

### Akrites (external repo)

High-level responsibilities only. Concrete implementation (exact JWT
header/claim shape, HTTP form fields, choice of GCP-side secret store) is
Akrites' call.

1. **Receive** the credential material (RSA private key, `client_id`,
`kid`) from the LF-shared 1Password vault item and store it in their
own GCP-side secret store (e.g. GCP Secret Manager). The 1Password
payload is atomic — Akrites uses everything in it to build the
assertion.
2. **Sign a `client_assertion` JWT** with the private key and exchange
it at LFX Auth0 for a short-lived Bearer token against the
`cdp_public_api` audience.
3. **Call** `/api/v1/akrites-external/*` with `Authorization: Bearer
<token>`. Cache the token until close to expiry, refreshing with a
clock-skew margin.
4. **On `invalid_client`** (keypair rotated): coordinate with LF for
re-delivery of the rotated credential via 1Password — do not
tight-loop the token exchange.

## Alternatives Considered

### Client secret instead of RSA private_key_jwt (not taken)

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 workload identity or key-signing setup on the Akrites
side. Token exchange is a plain `POST /oauth/token` with `client_id` +
`client_secret` form fields — no JWT signing, no RSA library. Fastest
path to ship.
- **Cons**: Shared-secret model — both sides hold a copy of the same
credential. 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. Since
delivery is via 1Password either way, it saves nothing operationally —
rotation hand-off is identical.
- **Why not**: Every other LF-managed CDP M2M client is on
`private_key_jwt`; LF Auth sets the client up for JWT private key
assertions regardless of how the secret is stored and delivered
(2026-07-28). The `private_key_jwt` flow works fine with 1Password
delivery, so there is no reason to weaken the credential model.

### Cross-account AWS Secrets Manager delivery (not applicable)

Distribute the RSA private key via an LF-owned AWS Secrets Manager entry
with a resource policy granting `secretsmanager:GetSecretValue` to
Akrites' workload IAM role (ECS task role or EKS pod role via IRSA), read
cross-account — the same pattern as cross-account S3. Cross-account reads
would likely also require the secret to be encrypted with a
customer-managed KMS key whose key policy grants `kms:Decrypt` to the
Akrites role.

- **Pros**: Fully automated delivery and rotation pickup — LF rotates
inside its own AWS boundary and Akrites re-fetches the latest version
on `invalid_client`, with no manual hand-off or coordination window.
- **Cons**: Requires an Akrites AWS account and workload IAM role, plus
CMK/key-policy work on the LF side.
- **Why not**: Akrites' service runs on GCP (confirmed 2026-07-28). There
is no Akrites AWS workload role to grant, and `lfx-secrets-management`
has no GCP delivery path today — GCP support is an LF CloudOps backlog
item. When it lands, delivery moves from 1Password to an automated
GCP-side destination with no change to the Auth0 client or CDP.

### `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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions.
| [ADR-0013](./0013-api-e2e-test-suite-design.md) | API e2e test suite design | accepted | 2026-07-24 |
| [ADR-0014](./0014-collaboration-track-record-signal.md) | Collaboration track record signal | accepted | 2026-07-28 |
| [ADR-0015](./0015-how-cdp-stores-member-identities.md) | How CDP stores member identities | accepted | 2026-07-28 |
| [ADR-0016](./0016-akrites-cdp-public-api-authentication.md) | Akrites → CDP public API authentication | proposed | 2026-07-22 |

## Why ADRs?

Expand Down
Loading