Date: 2026-07-22 Status: proposed Deciders: CDP team, LF Auth, Akrites team
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
(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/secretsmanagerkey policy can't be modified to grantkms:Decryptto 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 grantkms:Decryptto 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 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
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.
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).
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: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 Akrites,Auth0: On invalid_client, key rotated by LF
Akrites->>SM: GetSecretValue cross-account, latest version
Akrites->>Auth0: retry token exchange once
Three edits, all against the existing cdp_public_api resource server. No new
resource server.
resource_servers.tf — replace the two scopes added in this branch with
three Akrites-namespaced ones inside auth0_resource_server_scopes.cdp_public_api:
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 — add one entry to local.m2m_clients:
"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 — add the grant next to lfxone_cdp and
persona_service_cdp:
# 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]
}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_jwtwithclient_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_Enclave. Write is same-account for LF.
- Orchestration:
secretsmanagement/sync.py— no code change; the existingauth0_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.
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
Add three new consts (only these three are new — existing scopes are unchanged):
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 — mount at the existing position:
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 — replace the
placeholder scope constants (current TODO comments call this out explicitly)
with the newly-provisioned Akrites-namespaced scopes:
// packages subrouter
packagesSubRouter.use(requireScopes([SCOPES.READ_AKRITES_PACKAGES]))
// advisories subrouter
advisoriesSubRouter.use(requireScopes([SCOPES.READ_AKRITES_ADVISORIES]))
// contacts subrouter
contactsSubRouter.use(requireScopes([SCOPES.READ_AKRITES_MAINTAINERS]))
// blast-radius subrouter (same surface as advisories per the contract)
blastRadiusSubRouter.use(requireScopes([SCOPES.READ_AKRITES_ADVISORIES]))Remove the TODO comments once the scopes are provisioned — the swap is complete.
/akrites (Self Serve) is untouched.
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;
- 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. - Sign a
client_assertionJWT with the fetched private key and exchange it at LFX Auth0 for a short-lived Bearer token against thecdp_public_apiaudience. The SM payload is atomic — it carries the private key together with any Auth0-side identifiers (e.g. the credentialkid) LF ships alongside the key. Akrites reads the whole payload as one unit and uses everything in it to build the assertion. - Call
/api/v1/akrites-external/*withAuthorization: Bearer <token>. Cache the token until close to expiry, refreshing with a clock-skew margin. - 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.
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/tokenwithclient_id+client_secretform 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_jwtfor M2M clients. - Why not (as default): Every other LF-managed CDP M2M client is on
private_key_jwtwithlfx-secrets-managementauto-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_secretin their own config.
Delta to the ADR body if this fallback is selected:
auth0-terraform— no change to the client, scope, or grant. TheAkrites Enclaveclient stays on the defaultclient_secret_postauth method (which is what a fresh client uses beforelfx-secrets-managementrotates it toprivate_key_jwt). Drop the rotation-to-JWT step for this client.lfx-secrets-management— flip the sync entry source fromauth0_jwttoauth0(pattern:Reimbursement Service client secret). Destination is 1Password only. Dropauto_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 + requireScopes middleware chain stays the same.- Akrites side — drop the RSA signing, JWKS setup, and cross-account
IAM entirely. Store
client_id+client_secretin their own environment secret store. Oninvalid_client, pause and coordinate with LF rather than auto-retry; do not tight-loop.
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-terraformPR (@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 existingrequireScopesmiddleware.