From 39d3bed297142f328475f3fc61f4e28e14b42c05 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 4 Sep 2026 18:43:39 -0700 Subject: [PATCH 1/8] docs: add ARCHITECTURE.md for the Self Serve migration (M1-M3) Add a root ARCHITECTURE.md describing the target architecture of the EasyCLA -> LFX Self Serve migration through M3: cross-component contracts, authorization, and external dependencies. It is a roll-up of the reviewed decision record in docs/easycla-ss-migration, not a replacement for it, and deliberately does not describe EasyCLA's internal design. Only M1-M3 are covered as target architecture; M4 and M5 are recorded as not planned. "Implemented" is stated throughout as merged and dark-launched behind a LaunchDarkly flag, not as serving production traffic. Guard the file with a CODEOWNERS entry for the LFX architecture team, and link it from README.md. Verified against shipped code on origin/dev: all eight documented endpoints exist in cla.v2.yaml; the five documented statuses match those the backend produces (no superseded/expired); the PDF route returns NotFound; PresignedURLValidity = 15 min; activeSignatureTTLDays = 1. Signed-off-by: Michal Lehotsky --- ARCHITECTURE.md | 256 ++++++++++++++++++++++++++++++++++++++++++++++++ CODEOWNERS | 12 +++ README.md | 3 + 3 files changed, 271 insertions(+) create mode 100644 ARCHITECTURE.md create mode 100644 CODEOWNERS diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..b631f3700 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,256 @@ + + +# EasyCLA Architecture — LFX Self Serve Migration (M1–M3) + +**Scope**: the **target architecture** of the EasyCLA → LFX Self Serve migration through **M3** — cross-component +contracts, authorization, and external dependencies. Not EasyCLA's internal design: for that see +[CLAUDE.md](CLAUDE.md) (layout, build) and [docs/MY_CLAS_API.md](docs/MY_CLAS_API.md) (endpoint reference). + +**This is a roll-up.** The reviewed decision record is +[architecture-proposal.md](docs/easycla-ss-migration/architecture-proposal.md) (P1–P10, reviewed 2026-07-20; +P10 approved 2026-07-28) and +[role-mapping-feasibility.md](docs/easycla-ss-migration/role-mapping-feasibility.md) (code-cited authorization +analysis). Where they disagree with this document, **they are the record and this is stale**. + +**Milestone status.** "Implemented" means *merged and deployed behind a LaunchDarkly flag*, not serving +production traffic. The program aims to complete M1–M3; **M4 and M5 are not planned** ([below](#not-planned)). + +| # | Milestone | Status | Retires | +|---|---|---|---| +| [M1](specs/001-easycla-ss-integration-fable/01-milestone-read-only-me-lens-fable.md) | Read-only "My CLAs" (Me lens) | **Implemented**, behind `my-clas-enabled` | — | +| [M2](specs/001-easycla-ss-integration-fable/02-milestone-sign-cla-fable.md) | Sign-CLA entry + My CLAs actions; hands off to the Contributor Console | **Implemented**, behind `my-clas-m2-enabled` (fail closed) | — | +| [M3](specs/001-easycla-ss-integration-fable/03-milestone-ccla-org-lens-fable.md) | CCLA management (Organization lens) | **In progress** (started 2026-09) | Corporate CLA Console **+ its GraphQL BFF** | + +--- + +## 1. Shape of the system + +**Strangler pattern (P1)**: Self Serve becomes a *new client* of the existing EasyCLA APIs. No business logic +is reimplemented in SS, and **enforcement — roles, approval lists, sanctions, PR gating — stays in EasyCLA** +through M1–M3. SS holds no CLA storage and caches no agreement data beyond request scope. + +```mermaid +flowchart TB + subgraph clients["Clients"] + SS["LFX Self Serve (LFX One)
Me lens: M1–M2 · Org lens: M3"] + CTC["Contributor Console
(runs the signing ceremony)"] + CCC["Corporate CLA Console + GraphQL BFF
(retired by M3)"] + PCC["PCC EasyCLA module
(unchanged — M4 not planned)"] + end + GW["lfx-gateway (Traefik)
JWT signature + issuer · ACS warden · injects X-ACL"] + subgraph backend["EasyCLA backend (unchanged through M3)"] + V4["cla-backend-go — /v3 + /v4 Lambdas"] + LEG["cla-backend-legacy — /v1 + /v2"] + end + DDB[("DynamoDB — 19 tables
+ S3 signed PDFs")] + DS["DocuSign
(server-side, v2/sign)"] + ACS["ACS — CLA roles
(LFX v1 service, own DB)"] + US["user-service / org-service
(LFX v1)"] + AUTH["Auth0
(+ Management API)"] + + SS --> GW + CTC --> GW + CCC --> GW + PCC --> GW + CTC --> LEG + GW --> V4 + V4 --> DDB + V4 --> DS + V4 --> ACS + V4 --> US + V4 --> AUTH + SS -.->|"M2 hand-off: signUrl"| CTC +``` + +Two facts shape everything else: + +- **Two API surfaces are in the blast radius.** `cla-backend-go` serves `/v3` (us-east-1) and `/v4` + (us-east-2, LFX-Platform-integrated); `cla-backend-legacy` serves `/v1`+`/v2`, which contributor flows still + call and which **stays until M5** (P7). Even a "UI-only" milestone touches two backends. +- **The Contributor Console stays load-bearing.** M2 shipped as a hand-off *to* the Console, which keeps + running the signing ceremony; the PR-check remediation link is unchanged. Console and `easycla-landing-page` + retirement is **deferred to a future product decision**, not attached to any milestone. + +--- + +## 2. Authorization + +**Layer 1 — lfx-gateway.** Validates the JWT's **signature and issuer only** (the **audience check is +explicitly disabled**), calls the ACS warden with username/path/method, 403s on denial, and injects a +base64-encoded `X-ACL` plus `X-Username`/`X-Email`. + +**Layer 2 — v4 handlers.** As a general rule v4 does **no JWT validation of its own**: `SwaggerAuth` +base64-decodes `X-ACL` into an `authUser`, and handlers match the scope **type + ID** against the request's +project/company SFIDs — the part the gateway cannot do, since ACS only sees the URL. The scope's `Role` field +is never consulted. + +Consequences for every client: + +- **Authorization keys on the user's identity (LF username)**, not on which Auth0 client or audience minted + the token — which is why SS needed no gateway, auth, or token-infrastructure change to call v4 (P3). +- **Handlers trust `X-ACL`**, so the gateway is the authorization boundary and v4's invoke permissions are what + must keep it the only path. **This has not been audited** — the spike scoping those permissions was never + run. Treat it as a requirement to verify, not an established property. +- **One exception** (P10): the My CLAs handlers re-verify the bearer against Auth0 JWKS in-handler, because an + `azp` claim is only trustworthy on a signature the handler checked itself + (`cla-backend-go/auth/trusted_caller.go`). That path is **inactive while its SSM parameter is unset** — the + current state — so the general rule describes every deployed request today. + +### Roles: bridge, don't migrate (P2) + +CLA authority is **ACS role+scope tuples** (`cla-manager`, `cla-signatory`, `cla-manager-designee`) held in +ACS, an LFX v1 service with its own database. **No CLA object types exist in the OpenFGA model**, so V2 ReBAC +cannot express CLA authority; modeling it is deferred to M5. + +SS therefore **bridges rather than copies**: it gates UI on the user's **self permission check** +(`POST user-service/v1/me/permissions/checks`) — the same ACS decision the gateway enforces, so UI gating and +API enforcement agree by construction — and handles 403s gracefully instead of out-predicting enforcement. SS +builds no permission-string evaluator. The endpoint has no decommission timeline (ARCH-406, 2026-07-31). + +Three ACS properties leak into UX and cannot be designed away here: assignment is **asynchronous**, authorize +responses are cached **~30 minutes** (so revocations linger), and grants are **one-company-at-a-time**. +Separately, whether a **staff-admin scope** satisfies a check is **per endpoint, not a blanket rule** — +handlers pass `utils.ALLOW_ADMIN_SCOPE` or `utils.DISALLOW_ADMIN_SCOPE`, and ALLOW dominates (~79 call sites +vs ~10). Approval-list writes disallow it; most CLA writes do not. Clients must not assume it is excluded. + +Rejected, for the record: copying CLA into OpenFGA now (non-enforcing, and a third eventually-consistent truth +on an async pipeline), and mapping org-admin to CLA-manager (`b2b_org#writer` has no project dimension). + +### Tokens (P3) + +SS calls EasyCLA with **user-scoped access tokens, never ID tokens**, via its existing api-gw-audience +refresh-token exchange. No M2M by default. The load-bearing claim is `http://lfx.dev/claims/username`, which +Auth0 stamps **only** on tokens whose audience matches `https://api-gw.(env.)platform.linuxfoundation.org/` — +a token minted for another audience reaches v4 without the username the whole chain keys on. + +### Identity resolution for My CLAs + +The contributors this serves are frequently **GitHub-only historical signers with no `lf_username`**, so +record-based verification alone wrongly returns empty. Resolution therefore lives **inside EasyCLA**: SS +forwards session-derived identity keys (LF username, verified emails, GitHub IDs/usernames) and EasyCLA +resolves, aggregates, deduplicates, and enforces ownership. + +| Caller | Identity keys | Signature ownership | +|---|---|---| +| Untrusted (**SS today**) | every key verified against the caller's LF account via EasyCLA records, platform user-service, and the Auth0 Management API; unverifiable keys returned in `skippedIdentities` | enforced server-side; PDF route returns **404, never 403** | +| Admin / allow-listed `azp` | supplied directly, no per-key verification | enforced against the **supplied** set — such a caller can read another user's signatures and PDFs | + +**The trusted-caller path (P10) is deployed but inactive, and cannot simply be switched on**: SS's +`apiGatewayToken` is the same token it hands every logged-in user as `v1Token` via `GET /api/profile/developer`, +so allow-listing its client ID would let any user assert any identity. Activating P10 needs a **distinct +server-only token** first. The `azp` mechanism is sound only while the client is confidential and its tokens +never reach a browser; at M5 it is meant to be dropped in favour of EasyCLA calling the auth-service NATS RPC +directly. + +--- + +## 3. Cross-component contracts + +### SS → EasyCLA (`/cla-service/v3|v4` through lfx-gateway) + +All integration goes through **one SS server-side `cla` module** (Express routes, mirroring the crowdfunding +pattern), so an M5 re-platform has a single adapter to rework. The browser never sees raw EasyCLA user IDs. + +| Endpoint | Milestone | Contract | +|---|---|---| +| `GET /v4/my-clas` | M1 | identity-parameterized list; aggregation, dedup by `signatureID`, computed validity/status, ownership enforcement | +| `GET /v4/my-clas/{signatureID}/pdf` | M1 | 15-minute presigned S3 URL, **ICLA only**; fetch on click, never on page load | +| `GET /v4/my-clas/identities` | M1 | resolved-identity introspection; not consumed by the shipped UI | +| `GET /v4/my-clas/{signatureID}/cla-managers` | M2 | managers of the CCLA covering an owned ECLA | +| `POST /v4/my-clas/{signatureID}/cla-manager-requests` | M2 | emails an approval/removal/contact request and returns a generated request ID; logs a best-effort audit event, persists **no request record**, and **never** signature state | +| `GET /v4/cla-group/search` | M2 | CLA group / project / linked-org name and repo-URL resolution (in-process cache, ~30 min) | +| `POST /v4/self-serve/prepare-sign` | M2 | verifies identity, creates the EasyCLA user record if missing, writes a one-day `active_signature:{userID}` session record, returns the Console hand-off `signUrl` | +| `POST /v4/self-serve/request-corporate-signature` | M3 | initiates CCLA signing; 400 unless both `authority_acked` and `embargo_acked` are set | + +**M3's inventory is not yet complete.** Beyond the endpoint above, the org lens absorbs the Corporate +Console's existing v4 operations (approval-list CRUD, employee acknowledgements, CLA-manager +add/remove/designee, signed-CLA and activity views) plus the v3 org search and CLA metrics its BFF aggregates. +Those stay enumerated in the [M3 brief](specs/001-easycla-ss-integration-fable/03-milestone-ccla-org-lens-fable.md) +until settled. + +Field-level detail: [docs/MY_CLAS_API.md](docs/MY_CLAS_API.md). The status a row shows and the actions each +status permits: [docs/MY_CLAS_STATUS_MATRIX.md](docs/MY_CLAS_STATUS_MATRIX.md) — the single source of truth +SS renders against. + +### SS → Contributor Console (M2 hand-off) + +SS resolves a CLA group, then hands off; the Console's decision screen owns the ICLA-vs-ECLA choice. GitHub +goes through `prepare-sign` (the server verifies the returned `githubId` matches the chosen linked account); +**Gerrit** redirects directly, signing under LF SSO; **GitLab-only CLA groups are blocked by design** — SS +cannot yet verify a GitLab identity. The return URL is host-validated. + +### Guardrails that hold through M3 + +- SS runs **no signing ceremony** and makes **no signing-initiation calls**; `prepare-sign` only prepares the + hand-off. +- SS makes **no invalidation writes of any kind** (legal decision, 2026-08). Invalidation is a CLA-manager + action in the Corporate Console (`signature_approved = false`). **Sanctions screening is separate**: it sets + the company's `is_sanctioned` flag, surfacing as **Revoked**, not Invalidated — the two must never share + wording. +- **DocuSign never moves** (P4): envelope state, webhooks, and PDF storage stay in `v2/sign`; clients only + fetch a `sign_url` and redirect. **Email-based CCLA signatory signing is preserved** (P8) — the signatory is + never forced into SS or LF SSO. +- **Cutover is a config flip** (P5): LaunchDarkly flags for the lenses. Flag rollback is instant, and no + milestone through M3 writes data a rollback would strand. The SSM PR-check redirect base is a *potential* + lever, not an exercised one — **M2 deliberately left it unchanged**. **M3 still owes its own rollback plan**: + retiring the Corporate Console changes routing and entry points (email, designee links) that a lens flag + does not reverse. + +### v1-ID dependency (P9) + +v4 payloads still carry LFX **v1** user-service and org-service IDs, and both services are being deprecated +(users collapse to email/username references; orgs to name/domain except true B2B orgs). SS must not +hard-depend on v1 IDs it cannot resolve later: users resolve via the `lfx.lookup_v1_user_sfid.by_username` / +`.by_email` NATS RPCs, orgs via the v1 org service on the api-gw secondary token. + +--- + +## 4. External dependencies + +| Dependency | Used for | Contract note | +|---|---|---| +| **lfx-gateway** (Traefik) | routing + layer-1 authorization | EasyCLA is already behind it; nothing to onboard | +| **ACS** (LFX v1) | CLA role+scope truth | async assignment; ~30-min authorize cache; one-company-at-a-time grants | +| **user-service / org-service** (LFX v1) | self permission checks; identity and org resolution | deprecation planned — see P9 | +| **Auth0** (+ Management API) | authentication; identity linking; JWKS | the `http://lfx.dev/claims/username` claim is audience-conditional | +| **DocuSign** | e-signature | server-side only, in `v2/sign` | +| **DynamoDB + S3** | 19 tables; signed PDFs | unchanged through M3; PDFs served as presigned URLs | +| **Sanctions Screening Service** | OFAC/embargo screening | sets **Revoked**; system-owned, never set by SS | +| **GitHub** (public org membership) | evaluating whether an ECLA is still covered by an Approved List | a lookup failure makes a row **unevaluable**, not invalid (`EvaluateUserApproval`) | +| **LaunchDarkly** | dark launch | `my-clas-enabled` (M1), `my-clas-m2-enabled` (M2, fail closed) | +| **Salesforce**, via LFX Platform APIs | project/company records | reached through the platform services, not directly | +| **Elasticsearch / Snowflake** | CLA metrics/analytics the Corporate Console's BFF aggregates | **M3 only** — inherited when the BFF is retired; audit usage before porting | +| **Email / notification services** | CLA-manager requests, designee and signatory invitations | **M3 only** for the Console's flows; M2's requests already send mail from v4 | + +--- + +## 5. Known architectural tensions + +Recorded because they constrain M3 design, not as a defect list: + +- **Two stores of CLA-manager truth.** ACS scopes gate Approved List edits; the DynamoDB signature ACL + (`CurrentUserInACL`) governs other paths. Written at different times by different code, they can disagree. +- **Enforcement is uneven across v4 write paths**, so SS must mirror v4's actual behavior rather than present a + uniform permission model over it. +- **UI/enforcement drift is structural.** The ACS cache and async assignment mean a correct UI gate can still + be followed by a 403. Handle the 403; do not predict it. +- **M3 is not "migrate a console."** It is an Angular 13/NgRx frontend (~161 components) *plus* a GraphQL BFF + (~648 files, 20+ queries / 8 mutations) aggregating v4 REST, Salesforce, and analytics stores. The BFF's + logic must be absorbed or replaced, not just its screens. + +--- + +## Not planned + +**M4 — EasyCLA administration in the Project lens.** Whether project-level admin moves to SS or stays in PCC +is an open product decision; PCC's EasyCLA module remains the only place project admins configure EasyCLA. +[Design option](specs/001-easycla-ss-integration-fable/04-milestone-project-lens-pcc-fable.md). + +**M5 — EasyCLA API as an LFX V2 Kubernetes service** (optionally DynamoDB → Postgres). Where Heimdall, +NATS-native integration, OpenFGA modeling of CLA objects, and retirement of the Lambda/API-GW stack (including +the legacy `/v1`+`/v2` surface) would land. Separately gated. +[Design option](specs/001-easycla-ss-integration-fable/05-milestone-k8s-v2-api-fable.md). + +Neither is committed scope and either may never be implemented. Anything above described as "until M5" should +be read as **indefinite**. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..101138044 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,12 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +# Architecture documentation requires review by the LFX architecture team. +# +# There is deliberately no `*` rule here: this file exists only to guard +# ARCHITECTURE.md, not to impose review on the rest of the repository. +# +# Note: GitHub silently skips code owners that lack read access to the +# repository. If the team below cannot read this repo, this rule will not fire. + +/ARCHITECTURE.md @linuxfoundation/lfx-architecture-team diff --git a/README.md b/README.md index 81cd21787..d1fcaee25 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,9 @@ The following diagram explains the EasyCLA architecture. ![CLA Architecture](.gitbook/assets/easycla-architecture-overview.png) +For the **target architecture of the EasyCLA → LFX Self Serve migration** (cross-component contracts, +authorization, and external dependencies through milestone M3), see [ARCHITECTURE.md](ARCHITECTURE.md). + ## Bot CLA Exemptions See [BOT_ALLOWLIST.md](BOT_ALLOWLIST.md) for information on configuring bots that are exempt from CLA checks. From aa3abf07039bbb132151551c6c1b40294ead6363 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 4 Sep 2026 19:18:09 -0700 Subject: [PATCH 2/8] docs(arch): correct the v1-ID deprecation claim in P9 The claim that user-service and org-service "are being deprecated" had no source outside P9 itself. org-service is live and in use today (its repo deploys to prod, lfx-gateway routes it, lfx-self-serve calls organization-service/v1/orgs/search), and no lfx-v2-* architecture doc states it is being retired. It is not attributable to the 2026-07-20 architecture review: unlike P2 and P10, P9 carries no date, ticket, or confirmation, and the one sourced statement nearby (ARCH-406) says a user-service endpoint stays. - P9: user-service deprecation stated as anticipated; org-service noted as having no announced deprecation. The load-bearing guidance is unchanged -- SS must not hard-depend on v1 IDs it cannot resolve later. - Dependency table: "deprecation planned" -> "v1 IDs may not remain resolvable". Signed-off-by: Michal Lehotsky --- ARCHITECTURE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b631f3700..c054f6713 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -199,8 +199,9 @@ cannot yet verify a GitLab identity. The return URL is host-validated. ### v1-ID dependency (P9) -v4 payloads still carry LFX **v1** user-service and org-service IDs, and both services are being deprecated -(users collapse to email/username references; orgs to name/domain except true B2B orgs). SS must not +v4 payloads still carry LFX **v1** user-service and org-service IDs. user-service deprecation is +anticipated (users collapse to email/username references); org-service has **no announced deprecation** — +the v2 model keeps true B2B orgs. Either way, SS must not hard-depend on v1 IDs it cannot resolve later: users resolve via the `lfx.lookup_v1_user_sfid.by_username` / `.by_email` NATS RPCs, orgs via the v1 org service on the api-gw secondary token. @@ -212,7 +213,7 @@ hard-depend on v1 IDs it cannot resolve later: users resolve via the `lfx.lookup |---|---|---| | **lfx-gateway** (Traefik) | routing + layer-1 authorization | EasyCLA is already behind it; nothing to onboard | | **ACS** (LFX v1) | CLA role+scope truth | async assignment; ~30-min authorize cache; one-company-at-a-time grants | -| **user-service / org-service** (LFX v1) | self permission checks; identity and org resolution | deprecation planned — see P9 | +| **user-service / org-service** (LFX v1) | self permission checks; identity and org resolution | v1 IDs may not remain resolvable — see P9 | | **Auth0** (+ Management API) | authentication; identity linking; JWKS | the `http://lfx.dev/claims/username` claim is audience-conditional | | **DocuSign** | e-signature | server-side only, in `v2/sign` | | **DynamoDB + S3** | 19 tables; signed PDFs | unchanged through M3; PDFs served as presigned URLs | From 2d9aa7bbb84ed0366fc3dd1d361b1d5165591ce4 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 4 Sep 2026 19:25:48 -0700 Subject: [PATCH 3/8] docs(arch): drop the SS-internal implementation note from the contracts section "Express routes, mirroring the crowdfunding pattern" describes how the SS `cla` module is built internally, which is lfx-self-serve's concern, not a cross-component contract. The load-bearing point -- one module, so an M5 re-platform has a single adapter to rework -- is unchanged. Signed-off-by: Michal Lehotsky --- ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c054f6713..4b03d6003 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -149,8 +149,8 @@ directly. ### SS → EasyCLA (`/cla-service/v3|v4` through lfx-gateway) -All integration goes through **one SS server-side `cla` module** (Express routes, mirroring the crowdfunding -pattern), so an M5 re-platform has a single adapter to rework. The browser never sees raw EasyCLA user IDs. +All integration goes through **one SS server-side `cla` module**, so an M5 re-platform has a single adapter +to rework. The browser never sees raw EasyCLA user IDs. | Endpoint | Milestone | Contract | |---|---|---| From 1a3e08898b4904c9635fb3f97e70d3bf9af59cae Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 4 Sep 2026 19:44:42 -0700 Subject: [PATCH 4/8] docs(arch): drop the trailing reference-doc pointers from the contracts section MY_CLAS_API.md is already linked from the scope note in the header, and the per-status action matrix is SS rendering detail rather than a cross-component contract. Signed-off-by: Michal Lehotsky --- ARCHITECTURE.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4b03d6003..8ccbf6ffb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -169,10 +169,6 @@ add/remove/designee, signed-CLA and activity views) plus the v3 org search and C Those stay enumerated in the [M3 brief](specs/001-easycla-ss-integration-fable/03-milestone-ccla-org-lens-fable.md) until settled. -Field-level detail: [docs/MY_CLAS_API.md](docs/MY_CLAS_API.md). The status a row shows and the actions each -status permits: [docs/MY_CLAS_STATUS_MATRIX.md](docs/MY_CLAS_STATUS_MATRIX.md) — the single source of truth -SS renders against. - ### SS → Contributor Console (M2 hand-off) SS resolves a CLA group, then hands off; the Console's decision screen owns the ICLA-vs-ECLA choice. GitHub From b7696d25fe5bb00c4388ed1927350da377c96c42 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 4 Sep 2026 19:48:11 -0700 Subject: [PATCH 5/8] fix(review): address PR #5196 review feedback Address review comments from copilot-pull-request-reviewer, coderabbitai: - ARCHITECTURE.md: qualify the "browser never sees raw EasyCLA user IDs" claim. M2's prepare-sign returns a signUrl of the form /#/cla/project/{claGroupID}/user/{userID} (v2/self_serve_sign/service.go), which the browser navigates to, so the absolute claim was wrong. - ARCHITECTURE.md: scope the no-signing-initiation and no-invalidation guardrails to M2. M3 absorbs the Corporate Console's manager-facing operations, which include server-side CCLA signing initiation (request-corporate-signature, already listed in the contracts table) and manager-initiated ECLA invalidation. Stating them program-wide would tell M3 implementers to omit required operations. - ARCHITECTURE.md: use "Approved List" consistently (CLAUDE.md convention). - CODEOWNERS: correct the access note -- GitHub requires write access for a code-owner team, not read, so the previous note understated what an admin must grant for the guard to fire. Resolves 5 review threads. Signed-off-by: Michal Lehotsky --- ARCHITECTURE.md | 29 ++++++++++++++++++----------- CODEOWNERS | 5 +++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8ccbf6ffb..5ceb76ff5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,7 +27,7 @@ production traffic. The program aims to complete M1–M3; **M4 and M5 are not pl ## 1. Shape of the system **Strangler pattern (P1)**: Self Serve becomes a *new client* of the existing EasyCLA APIs. No business logic -is reimplemented in SS, and **enforcement — roles, approval lists, sanctions, PR gating — stays in EasyCLA** +is reimplemented in SS, and **enforcement — roles, Approved Lists, sanctions, PR gating — stays in EasyCLA** through M1–M3. SS holds no CLA storage and caches no agreement data beyond request scope. ```mermaid @@ -112,7 +112,7 @@ Three ACS properties leak into UX and cannot be designed away here: assignment i responses are cached **~30 minutes** (so revocations linger), and grants are **one-company-at-a-time**. Separately, whether a **staff-admin scope** satisfies a check is **per endpoint, not a blanket rule** — handlers pass `utils.ALLOW_ADMIN_SCOPE` or `utils.DISALLOW_ADMIN_SCOPE`, and ALLOW dominates (~79 call sites -vs ~10). Approval-list writes disallow it; most CLA writes do not. Clients must not assume it is excluded. +vs ~10). Approved List writes disallow it; most CLA writes do not. Clients must not assume it is excluded. Rejected, for the record: copying CLA into OpenFGA now (non-enforcing, and a third eventually-consistent truth on an async pipeline), and mapping org-admin to CLA-manager (`b2b_org#writer` has no project dimension). @@ -150,7 +150,8 @@ directly. ### SS → EasyCLA (`/cla-service/v3|v4` through lfx-gateway) All integration goes through **one SS server-side `cla` module**, so an M5 re-platform has a single adapter -to rework. The browser never sees raw EasyCLA user IDs. +to rework. API responses carry no raw EasyCLA user IDs — with one exception: M2's `prepare-sign` +hand-off returns a `signUrl` containing the user's UUID, which the browser navigates to. | Endpoint | Milestone | Contract | |---|---|---| @@ -164,7 +165,7 @@ to rework. The browser never sees raw EasyCLA user IDs. | `POST /v4/self-serve/request-corporate-signature` | M3 | initiates CCLA signing; 400 unless both `authority_acked` and `embargo_acked` are set | **M3's inventory is not yet complete.** Beyond the endpoint above, the org lens absorbs the Corporate -Console's existing v4 operations (approval-list CRUD, employee acknowledgements, CLA-manager +Console's existing v4 operations (Approved List CRUD, employee acknowledgements, CLA-manager add/remove/designee, signed-CLA and activity views) plus the v3 org search and CLA metrics its BFF aggregates. Those stay enumerated in the [M3 brief](specs/001-easycla-ss-integration-fable/03-milestone-ccla-org-lens-fable.md) until settled. @@ -176,14 +177,20 @@ goes through `prepare-sign` (the server verifies the returned `githubId` matches **Gerrit** redirects directly, signing under LF SSO; **GitLab-only CLA groups are blocked by design** — SS cannot yet verify a GitLab identity. The return URL is host-validated. -### Guardrails that hold through M3 +### Guardrails -- SS runs **no signing ceremony** and makes **no signing-initiation calls**; `prepare-sign` only prepares the - hand-off. -- SS makes **no invalidation writes of any kind** (legal decision, 2026-08). Invalidation is a CLA-manager - action in the Corporate Console (`signature_approved = false`). **Sanctions screening is separate**: it sets - the company's `is_sanctioned` flag, surfacing as **Revoked**, not Invalidated — the two must never share - wording. +The first two are **M2 scope**, not program-wide: M3 moves the Corporate Console's manager-facing operations +into SS, which includes both server-side CCLA signing initiation +(`POST /v4/self-serve/request-corporate-signature`, above) and manager-initiated ECLA invalidation. The rest +hold through M3. + +- **M2 only** — SS runs **no signing ceremony** and makes **no signing-initiation calls**; `prepare-sign` only + prepares the hand-off. +- **M2 only** — SS makes **no invalidation writes** from the My CLAs surface (legal decision, 2026-08): + a contributor cannot invalidate their own agreement. Invalidation is a CLA-manager action + (`signature_approved = false`), in the Corporate Console today and in the M3 org lens after. + **Sanctions screening is separate**: it sets the company's `is_sanctioned` flag, surfacing as **Revoked**, + not Invalidated — the two must never share wording. - **DocuSign never moves** (P4): envelope state, webhooks, and PDF storage stay in `v2/sign`; clients only fetch a `sign_url` and redirect. **Email-based CCLA signatory signing is preserved** (P8) — the signatory is never forced into SS or LF SSO. diff --git a/CODEOWNERS b/CODEOWNERS index 101138044..3f9b86f04 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -6,7 +6,8 @@ # There is deliberately no `*` rule here: this file exists only to guard # ARCHITECTURE.md, not to impose review on the rest of the repository. # -# Note: GitHub silently skips code owners that lack read access to the -# repository. If the team below cannot read this repo, this rule will not fire. +# Note: GitHub silently skips code owners that lack **write** access to the +# repository. If the team below does not have write access, this rule will not +# fire -- read access is not enough. /ARCHITECTURE.md @linuxfoundation/lfx-architecture-team From 442fa5badabc0e457b8fc62fa65d063c6cf9a821 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 4 Sep 2026 19:58:24 -0700 Subject: [PATCH 6/8] fix(review): address second-round PR #5196 review feedback Address review comments from copilot-pull-request-reviewer, coderabbitai: - ARCHITECTURE.md: scope the raw-user-ID statement to M1-M2 and name the M3 exceptions. docs/M3_ORG_LENS_API.md exposes userID/user_id in the organization-list, manager-request and ECLA-invalidation responses, so prepare-sign was not the only exception; the M3 fields are cross-linked to the v1-ID mapping rules in P9. - ARCHITECTURE.md: the backend node said "unchanged through M3", which reads as backend-change-free even though M3 adds v4 endpoints and server-side sanctions guards. It now says "system of record; not re-platformed before M5", which is what was actually meant. Resolves 2 review threads. Signed-off-by: Michal Lehotsky --- ARCHITECTURE.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5ceb76ff5..4ae029292 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -39,7 +39,7 @@ flowchart TB PCC["PCC EasyCLA module
(unchanged — M4 not planned)"] end GW["lfx-gateway (Traefik)
JWT signature + issuer · ACS warden · injects X-ACL"] - subgraph backend["EasyCLA backend (unchanged through M3)"] + subgraph backend["EasyCLA backend (system of record; not re-platformed before M5)"] V4["cla-backend-go — /v3 + /v4 Lambdas"] LEG["cla-backend-legacy — /v1 + /v2"] end @@ -150,8 +150,11 @@ directly. ### SS → EasyCLA (`/cla-service/v3|v4` through lfx-gateway) All integration goes through **one SS server-side `cla` module**, so an M5 re-platform has a single adapter -to rework. API responses carry no raw EasyCLA user IDs — with one exception: M2's `prepare-sign` -hand-off returns a `signUrl` containing the user's UUID, which the browser navigates to. +to rework. Through **M1–M2** the only raw EasyCLA user ID reaching the browser is in `prepare-sign`'s +`signUrl`, which embeds the user's UUID in the hand-off route. **M3 carries more**: its organization-list, +manager-request and ECLA-invalidation responses expose `userID`/`user_id` +([docs/M3_ORG_LENS_API.md](docs/M3_ORG_LENS_API.md)), which are v1 IDs subject to the mapping rules in +[§v1-ID dependency](#v1-id-dependency-p9). | Endpoint | Milestone | Contract | |---|---|---| From f7320ea2c5fbbbb9f6bc39c61597d109d55c4303 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 4 Sep 2026 20:45:07 -0700 Subject: [PATCH 7/8] docs(architecture): correct gateway/ACS onboarding prerequisite The dependency row claimed EasyCLA has "nothing to onboard" behind lfx-gateway. Verified against the sibling repos that this is wrong for new operations: - lfx-gateway/dynamic/services/cla-service.yaml defines two routers: an explicit unauthenticated PathPrefix allow-list, and a blanket `cla-service-secured` (jwks + acs) catching everything else. New operations correctly fall to the secured router. - acs-cli/services/11-cla-service.yaml registers each operation individually as an ACS resource + permission. All M1-M3 endpoints have entries; an unregistered path 403s in production. - M1/M2 resources are anyRole: true; M3's self-serve/request-corporate-signature is anyRole: false over objectTypeIDs [organization], so it also needs an org-scoped role assignment. Per review feedback on PR #5196 (flagged by copilot-pull-request-reviewer). Signed-off-by: Michal Lehotsky --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4ae029292..3251bfdb3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -217,7 +217,7 @@ hard-depend on v1 IDs it cannot resolve later: users resolve via the `lfx.lookup | Dependency | Used for | Contract note | |---|---|---| -| **lfx-gateway** (Traefik) | routing + layer-1 authorization | EasyCLA is already behind it; nothing to onboard | +| **lfx-gateway** (Traefik) | routing + layer-1 authorization | The gateway route needs no change — `cla-service.yaml` sends anything off its unauthenticated path allow-list to the `secured` router (jwks + ACS). But each new operation needs a companion **ACS resource + permission** registered in `acs-cli/services/11-cla-service.yaml` before production QA, or it 403s. M1/M2 are registered `anyRole: true`; M3's `self-serve/request-corporate-signature` is `anyRole: false` over `organization`, so it additionally needs a real org-scoped role assignment | | **ACS** (LFX v1) | CLA role+scope truth | async assignment; ~30-min authorize cache; one-company-at-a-time grants | | **user-service / org-service** (LFX v1) | self permission checks; identity and org resolution | v1 IDs may not remain resolvable — see P9 | | **Auth0** (+ Management API) | authentication; identity linking; JWKS | the `http://lfx.dev/claims/username` claim is audience-conditional | From c07924b67d155a893cfdf58de07b7053582f2a29 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 8 Sep 2026 19:42:21 -0700 Subject: [PATCH 8/8] docs(architecture): fix ACS onboarding note per acs-cli sync bug Verified against acs-cli sync/resources.go: buildResourcePayload never sets object_type_id on the create path, so POST /v1/api/resources always lands as the server default type regardless of the YAML declaration. Registering an operation in 11-cla-service.yaml is necessary but not sufficient -- a manual PUT + cache flush is required, as already documented in docs/M3_ORG_LENS_API.md's dev rollout notes. Dropped the anyRole/organization object-type claim for M3's self-serve/request-corporate-signature resource: the YAML declares organization but M3_ORG_LENS_API.md records dev was hand-fixed to project, and I can't confirm current prod state either way -- the verifiable defect is the sync bug itself. Per review feedback on PR #5196 (flagged by copilot-pull-request-reviewer). Signed-off-by: Michal Lehotsky --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3251bfdb3..5499ff36d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -217,7 +217,7 @@ hard-depend on v1 IDs it cannot resolve later: users resolve via the `lfx.lookup | Dependency | Used for | Contract note | |---|---|---| -| **lfx-gateway** (Traefik) | routing + layer-1 authorization | The gateway route needs no change — `cla-service.yaml` sends anything off its unauthenticated path allow-list to the `secured` router (jwks + ACS). But each new operation needs a companion **ACS resource + permission** registered in `acs-cli/services/11-cla-service.yaml` before production QA, or it 403s. M1/M2 are registered `anyRole: true`; M3's `self-serve/request-corporate-signature` is `anyRole: false` over `organization`, so it additionally needs a real org-scoped role assignment | +| **lfx-gateway** (Traefik) | routing + layer-1 authorization | The gateway route needs no change — `cla-service.yaml` sends anything off its unauthenticated path allow-list to the `secured` router (jwks + ACS). Each new operation also needs a companion ACS resource + permission declared in `acs-cli/services/11-cla-service.yaml`, but the acs-cli sync's create path (`sync/resources.go: buildResourcePayload`) never sends `object_type_id` on `POST /v1/api/resources` — every new resource lands as the server's default type regardless of the YAML, so registering the YAML alone is not sufficient. Dev required a manual `PUT /resources/{id}` per resource plus a warden/resource cache flush before scope checks worked (see `docs/M3_ORG_LENS_API.md`); the same manual step is needed on prod before M3 goes live | | **ACS** (LFX v1) | CLA role+scope truth | async assignment; ~30-min authorize cache; one-company-at-a-time grants | | **user-service / org-service** (LFX v1) | self permission checks; identity and org resolution | v1 IDs may not remain resolvable — see P9 | | **Auth0** (+ Management API) | authentication; identity linking; JWKS | the `http://lfx.dev/claims/username` claim is audience-conditional |