diff --git a/docs/easycla-ss-migration/README.md b/docs/easycla-ss-migration/README.md index 099226cdb..e0303aa40 100644 --- a/docs/easycla-ss-migration/README.md +++ b/docs/easycla-ss-migration/README.md @@ -5,9 +5,10 @@ SPDX-License-Identifier: CC-BY-4.0 --> Self-contained materials for the architecture review of the EasyCLA-to-Self-Serve migration. Reading order: -1. **[ARCHITECTURE.md](../../ARCHITECTURE.md)** — start here for the **current** target architecture (M1–M3): cross-component contracts, authorization, and external dependencies, as a roll-up. The two documents below are the reviewed decision record behind it. -2. **[architecture-proposal.md](architecture-proposal.md)** — the reviewed proposal (Eric Searcy, 2026-07-20). Current state, milestones, what leadership already settled, the proposed architecture (P1–P10, including the audit and trusted-caller designs), top risks, and what the review should challenge. -3. **[role-mapping-feasibility.md](role-mapping-feasibility.md)** — the supporting deep analysis for the roles/permissions bridge (P2/P3): how EasyCLA v4 authorization actually works, token paths, read paths, options assessment, and the spike list. All claims cite `file:line`. -4. **[Slide deck (Google Slides)](https://docs.google.com/presentation/d/1FQJOpiETIO_H10c6_eP2Zu-LM7qlvG_t7blhRm--2KA/edit)** — presentation for the review session. +1. **[architecture-proposal.md](architecture-proposal.md)** — the reviewed proposal (Eric Searcy, 2026-07-20). Current state, milestones, what leadership already settled, the proposed architecture (P1–P10, including the audit and trusted-caller designs), top risks, and what the review should challenge. +2. **[role-mapping-feasibility.md](role-mapping-feasibility.md)** — the supporting deep analysis for the roles/permissions bridge (P2/P3): how EasyCLA v4 authorization actually works, token paths, read paths, options assessment, and the spike list. All claims cite `file:line`. +3. **[m3-org-visibility.md](m3-org-visibility.md)** — why most EasyCLA companies are invisible in the Org Lens and what makes them visible (2026-09-10 call outcome, with prod data). **Reverses P2's "no CLA object types in OpenFGA before M5"** — read it against item 1. +4. **[m3-fga-model.md](m3-fga-model.md)** — the proposed M3 slice of that model: one org-level `cla_admin` relation, gating the UI only, with ACS still authorizing every API call. Simplifies item 3's §4.2 — read it against that section. +5. **[Slide deck (Google Slides)](https://docs.google.com/presentation/d/1FQJOpiETIO_H10c6_eP2Zu-LM7qlvG_t7blhRm--2KA/edit)** — presentation for the review session. Implementation-level specifications (milestone scopes, acceptance criteria, per-milestone plans — used by the Spec Kit workflow) live separately in [specs/001-easycla-ss-integration-fable/](../../specs/001-easycla-ss-integration-fable/spec.md). This folder is for evaluating the architecture; that folder is for building it. diff --git a/docs/easycla-ss-migration/m3-fga-model.md b/docs/easycla-ss-migration/m3-fga-model.md new file mode 100644 index 000000000..6ba7fe13f --- /dev/null +++ b/docs/easycla-ss-migration/m3-fga-model.md @@ -0,0 +1,433 @@ + + +# M3 FGA model — one org-level relation, UI-only + +**Status**: proposal, for the spec-044 ADR review (simplifies the M3 slice of the model agreed on the 2026-09-10 architecture call) +**Owner**: Michal (engineering) +**Related**: [m3-org-visibility.md](m3-org-visibility.md) §4.2 (the four-type variant this simplifies) · [role-mapping-feasibility.md](role-mapping-feasibility.md) + +**Spec 044** = the [lfx-v2-cla-service plan](https://docs.google.com/document/d/1hyWZUE_kofeAjVSmeXsRXtPSxSsj7uWvRj3tTNgwdik/edit) +(Luis, rev 5, 2026-09-10): a new platform CLA service that mirrors EasyCLA's DynamoDB into +OpenSearch/OpenFGA and proxies writes back to v4. Its spec package +`specs/044-lfx-v2-cla-service/` (branch `044-lfx-v2-cla-service`) is not yet published in +any `linuxfoundation` repo; the recap doc above is the citable source. + +**In one line**: M3 needs exactly one thing from OpenFGA — *CLA managers can enter their org in the Org Lens and see the EasyCLA tab* — so M3 adds exactly one relation, and everything below the tab stays with the existing v4 APIs under ACS. + +**Division of labor — FGA gates the UI, ACS gates the APIs.** OpenFGA decides only what +the Self Serve UI shows (org selector entry, EasyCLA tab). Every EasyCLA API call is +authorized by ACS, unchanged: gateway → ACS warden → `X-ACL` header → v4 scope check. +FGA never authorizes an API call; ACS never decides what the UI renders. This holds for +the whole M3→M5 bridge period. + +--- + +## 1. The one requirement + +Most CLA managers hold no membership-based grant, so without a new FGA grant they open +Self Serve and have no organization to select (gate 2 in +[m3-org-visibility.md](m3-org-visibility.md) §1). The tab's *contents* don't need FGA in +M3: the CLA pages run on the bridge (Self Serve → EasyCLA v4 via the API gateway), where +every request is already enforced by ACS. + +## 2. The model + +One new relation on the existing org type. No new object types in M3. + +``` +type b2b_org + relations + ...existing (writer, auditor, ...) + define cla_admin: [user] +``` + +The relation is named **`cla_admin`** throughout this document and that is the name +proposed for the ADR. `cla_manager` was the alternative but is rejected: it collides +with the existing ACS `cla-manager` role, and the two are deliberately not the same +thing — the tuple is projected from `signature_acl`, not from the ACS role (§3). + +| Question | Answered by | How | +|---|---|---| +| Does org X appear in the user's selector? | FGA | user holds any org relation, `cla_admin` now included | +| Does the EasyCLA tab render for org X? | FGA | `cla_admin` (or an org read relation) | +| Which CCLAs are listed, which actions work? | ACS via the v4 bridge | unchanged — gateway warden check + `X-ACL` scopes, per request | + +`cla_admin` grants **nothing else**: no org read, no People/membership access. A user +holding only `cla_admin` gets the CLA-only view — the org appears in their selector and +the EasyCLA tab renders; every other section (People, memberships, key contacts, +meetings) is hidden or empty, because query-service fails closed without a `b2b_org` +read relation and member-service routes require `auditor`/`writer`. On the API side they +still reach CLA data only: v4 authorizes them via their existing ACS `cla-manager` role +over the bridge; non-CLA org APIs deny them. Org admin ≠ CLA manager stays true in both +directions ("never grant org-wide read to CLA managers", spec 044 Q4). + +## 3. Tuple lifecycle + +- **Source**: the CCLA signature's `signature_acl` (DynamoDB) — the synchronous write, + same source for backfill and ongoing projection. Never derived from ACS. +- **Projection**: managers of any of an org's **signed** CCLAs (`signature_signed = true`; + see §5 item 2 for why `signature_approved` is excluded) → one `b2b_org:#cla_admin` + tuple per user × org (deduped across the org's CLA groups). Remove the tuple when the + user leaves the last such `signature_acl` of that org. +- **Cross-check**: the read-only ACS drift report (spec 044 item 22) compares ACS + `cla-manager` roles against the tuples; run it periodically, not once — ACS keeps being + written by v4 for as long as the bridge exists. + +## 4. What this defers, and what it costs + +Spec 044's four CLA types (`cla_group`, `cla_ccla` with `manager`/`signatory`, +`cla_ecla`, `cla_icla`) earn their keep only when FGA starts **enforcing** — per-agreement +read authorization on the query plane, at M5. Nothing in M3 uses them: + +| Spec-044 type | First actually needed | +|---|---| +| `cla_ccla` per-agreement relations | M5 — query-plane enforcement (`#auditor`) | +| `cla_group` | M5 — project lens does not read CLA data through FGA before then | +| `cla_ecla`, `cla_icla` | M5 — Me lens works today on in-handler ownership checks, no tuples | + +**Cost**: a second FGA model bump at M5, when the per-agreement types land. Acceptable +because the M5 bump happens under spec 044 regardless, and the tuples are projections — +migrating is a re-backfill from `signature_acl`, not a data migration. + +Note this is **not** settled as a straight replacement. §6 concludes that company-wide +read has to survive into M5, which needs a per-company relation there too — so the +per-agreement types may **extend** `cla_admin` (`... or cla_admin from b2b_org`) rather +than replace it, or replace it with a CLA-owned `cla_company`. Which of those the M5 +model does is part of the open question in §7, and it changes the migration. + +**Model shape, not independence**: the relation is defined on a specific object type. +As drafted that is `b2b_org`, which means the model still requires each EasyCLA company +to be represented by a B2B organization — without that record there is nothing to attach +the relation to. The earlier claim that this made M3 independent of the "EasyCLA orgs +become B2B Salesforce accounts" decision +([linuxfoundation/easycla#5210](https://github.com/linuxfoundation/easycla/pull/5210)) +overstated it: what the single relation avoids is new *CLA object types*, not the org +record itself. Treat the B2B org record as a **precondition** of this model. + +### 4.1 Blocking constraint: `b2b_org` tuples are reaped by member-service + +**A `cla_admin` relation placed on `b2b_org` would be silently deleted**, and this must +be resolved before Variant B can be chosen. Verified in code: + +- member-service publishes an `update_access` FGA message on every `b2b_org` create and + update, from three paths — the writer orchestrator, the CDC (Salesforce change feed) + consumer, and the org-settings writer — plus `/admin/reindex` and backfill + ([`internal/service/messaging.go` `BuildB2BOrgFGAMessage`](https://github.com/linuxfoundation/lfx-v2-member-service/blob/main/internal/service/messaging.go)). +- fga-sync treats that message as a **full sync** of the object: `SyncObjectTuples` + reads every live tuple on the object and deletes any not in the desired set + ([`fga.go` `SyncObjectTuples`](https://github.com/linuxfoundation/lfx-v2-fga-sync/blob/main/fga.go)). +- Only two things survive a relation the publisher does not know about: membership in + the message's `ExcludeRelations` list, or a `team:`-prefixed subject. `cla_admin` + tuples carry `user:` subjects, so **neither applies**. The current exclude list is + hardcoded to `parent`, `child`, and conditionally `global_org_admin`, `membership`, + `writer`, `auditor`. + +The failure mode is silent: managers lose lens access on the next unrelated org update, +with no error surfaced anywhere. Making Variant B safe therefore requires a +**member-service change** (add `cla_admin` to `ExcludeRelations` on every publish path), +a **deploy-order constraint** (that change ships before any tuple backfill), and a +**regression test** that an org update preserves CLA tuples. If member-service will not +own that, the manager grant belongs on a CLA-owned object instead — as §5 notes, a single +CLA-owned type in M3 avoids the reaping problem but reopens the "no CLA types before M5" +decision the same way Variant A does. + +### 4.2 Second blocking constraint: the tuple alone does not reach the consumer + +A `cla_admin` tuple is necessary but **not sufficient** — nothing in Self Serve reads it +today, and two specific gates would still refuse a manager who holds only that tuple. +Verified in `lfx-self-serve` at the time of writing: + +- **The org selector** builds its roster from `b2b_org_settings` `member:` + documents and classifies each entry as **`writer` or `auditor` only** + ([`org-role-grants.service.ts:401-403`](https://github.com/linuxfoundation/lfx-self-serve/blob/main/apps/lfx-one/src/server/services/org-role-grants.service.ts#L401-L403)). + A `cla_admin` grant produces no roster entry, so the org never appears in the picker. +- **The CLA BFF route** is guarded by `requireOrgLensAccess` + ([`org-clas.route.ts`](https://github.com/linuxfoundation/lfx-self-serve/blob/main/apps/lfx-one/src/server/routes/org-clas.route.ts)), + which delegates to `assertOrgLensRead`. That helper accepts a roster grant **or** a + direct `b2b_org:#auditor` answer from the authorizer, and nothing else + ([`org-lens-read-access.helper.ts`](https://github.com/linuxfoundation/lfx-self-serve/blob/main/apps/lfx-one/src/server/helpers/org-lens-read-access.helper.ts)). + A `cla_admin`-only caller gets a 403. + +So Variant B needs **three** changes, not one: the relation, a selector +discovery/materialization path that surfaces `cla_admin` orgs into the picker, and a +CLA-route-specific `cla_admin` gate **that retains the existing auditor gate for non-CLA +lens routes** — a CLA manager must not thereby acquire read access to meetings, ROI or +the people roster, which is exactly the "never grant org-wide read to CLA managers" +constraint in §2. + +This does not sink Variant B, but it does change the comparison in §7: the "one relation +versus four types" framing understates Variant B's cost, because the consumer-side work +is real and lands in a third repo. It must be weighed against Variant A with these items +included on Variant B's side of the ledger. + +## 5. Explicitly out of scope for this model + +Unchanged open items — this proposal solves none of them, under either model shape: + +1. **CLA-only view** — UI work to hide membership sections for `cla_admin`-only users. +2. **Designees — and the pre-signing window.** The `cla-manager-designee` role itself + exists only in ACS. But the earlier claim that there is "no `signature_acl` entry + before the CCLA is signed" is **wrong**, and the projection must account for it: v4 + creates the CCLA row with `SignatureSigned: false` and writes the initiating user's + LFID into `SignatureACL` on that same row + ([sign/service.go:2941 and :2953](../../cla-backend-go/v2/sign/service.go#L2941)), + persisting it via `populateSignURL`. A projection keyed on "appears in + `signature_acl`" would therefore grant lens access to a **pending, unsigned** CCLA. + + **Therefore the projector gates on `signature_signed`, and on that alone:** project a + `cla_admin` tuple once the signature is `signature_signed = true`, and remove it when + the ACL entry is removed, the signature is deleted, **or `signature_signed` goes back + to `false`**. + + **That last condition is load-bearing, and an ACL-diff-driven projector would miss + it.** `ActivateSignature` sets `signature_approved = true` and + `signature_signed = false` in one expression, leaving `signature_acl` untouched + ([`signatures/repository.go:5749-5760`](../../cla-backend-go/signatures/repository.go#L5749-L5760), + called from + [`v2/gitlab_organizations/service.go:889`](../../cla-backend-go/v2/gitlab_organizations/service.go#L889)). + So a signed CCLA can transition to unsigned with its ACL fully populated. A projector + that only reacts to ACL differences — the shape the ACS updater uses — sees no change + and strands a `cla_admin` grant on a CCLA that is no longer signed, which is exactly + what this section says must not happen. **The projector must watch the + `signature_signed` attribute itself, not just the ACL.** Raised by copilot[bot] in + review. + + **`signature_approved` is deliberately *not* part of the gate**, even though §2's + counting predicate uses it. Invalidation sets `signature_approved = false` and the + `note` field without touching `signature_acl` + ([`signatures/repository.go` `InvalidateProjectRecord`](../../cla-backend-go/signatures/repository.go)), + while the ACS updater reacts only to ACL differences + ([`v2/dynamo_events/signatures.go:366`](../../cla-backend-go/v2/dynamo_events/signatures.go#L366), + which logs "No changes in ACL" and exits otherwise). An approval-gated projection + would therefore strand managers at exactly the wrong moment: v4 still authorizes them + via ACS, but FGA removes their only route into the lens — so they cannot inspect the + invalidated agreement's history or initiate a replacement CCLA. Signed-but-unapproved + is precisely the state in which a manager most needs the surface. + + The consequence is that the tuple set is **not** identical to §2's active-CCLA + population: it is the slightly larger "has ever signed, ACL intact" set. That + divergence is intentional and must be stated wherever the two numbers are compared. + + With this gate, the designee case still resolves cleanly: a designee's job is the + pre-signing window (initiate DocuSign), which runs on the unscoped Sign CLA flow, not + inside an org's lens, and the moment they have something to see in the lens (a signed + CCLA) is the moment the gate opens for the ACL entry v4 already wrote for them. +3. **Signatory lens access** — no `cla_admin` tuple, but *not* "no M3 work". Do **not** + fold signatories into `cla_admin`: the tab would offer manager actions v4 rejects. + Proper read access is spec 044's `cla_ccla#signatory` at M5. + + Two things are nevertheless true of M3 and must not be read away by the paragraph + above. First, **M3 ships the signatory flow**: [`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) + FR-030 puts "CCLA signing initiation (signatory flow, including send-by-email)" in + the parity inventory, and [linuxfoundation/lfx-self-serve#2150](https://github.com/linuxfoundation/lfx-self-serve/issues/2150) + registers `self_serve_request_corporate_signature:create` with an ACS policy on + `cla-manager-designee` **and `cla-signatory`**. So the role does get an M3 + enforcement point on the write path, even though it gets no FGA relation. Second, + FR-031 governs org-lens access by "CLA-manager/signatory authority", which the + manager-only selector union does not satisfy. + + The unresolved part is therefore **how a signatory reaches the lens at all**, not + whether they may read the agreement once inside (spec 044 computes + `cla_ccla#auditor` as `manager or signatory or …`). That is an open Product + + Architecture decision tracked in + [`m3-org-visibility.md` §4.2](m3-org-visibility.md) and narrowed by §4.5 — if CCLA + signing starts from a dedicated CLA landing page rather than the Org Lens, a + signatory never needs selector access. It is not decided here, and the claim that + the `cla-signatory` ACS role "is checked by no endpoint" describes only today's + code: the two current mentions in + [`v2/sign/handlers.go:153`](../../cla-backend-go/v2/sign/handlers.go#L153) and + [`v2/self_serve_sign/handlers.go:132`](../../cla-backend-go/v2/self_serve_sign/handlers.go#L132) + are error-message strings on an org-service lookup failure, not authorization + checks — and [lfx-self-serve#2150](https://github.com/linuxfoundation/lfx-self-serve/issues/2150) changes that. +4. **ACS/FGA parity** — FGA gates the UI, ACS enforces the API, for the whole M3→M5 + bridge period; both are fed by the same v4 write (`signature_acl` synchronous, ACS + role asynchronous), and disagreement handling remains open item 6 there. + +## 6. What v4 already enforces — two tiers, and M3 replicates both + +"The API decides the contents" means the **existing** enforcement, which is not uniform. +Reads and writes sit at different widths: + +| Tier | Gate | Effective scope | +|---|---|---| +| **Read / list** | `IsUserAuthorizedForOrganization(..., ALLOW_ADMIN_SCOPE)` | **Company-wide** | +| **Write — approval list / signature** | `IsUserAuthorizedForProjectOrganizationTree(..., DISALLOW_ADMIN_SCOPE)`, **then** `CurrentUserInACL` on the signature | **Per CLA group**, twice over | +| **Write — CLA-manager administration** | `IsUserAuthorizedForProjectOrganizationTree(..., DISALLOW_ADMIN_SCOPE)` **only** | **Per CLA group**, once | + +The two write rows are not interchangeable. `CurrentUserInACL` is applied at exactly two +call sites in the backend — both on the approval-list/signature path +([`signatures/service.go:523`](../../cla-backend-go/signatures/service.go#L523), +[`v2/signatures/handlers.go:1499`](../../cla-backend-go/v2/signatures/handlers.go#L1499)). +CLA-manager create and delete enforce the project|organization tree and nothing else +([`v2/cla_manager/handlers.go:65`](../../cla-backend-go/v2/cla_manager/handlers.go#L65), +[`:122`](../../cla-backend-go/v2/cla_manager/handlers.go#L122)), so a manager scoped to a +CLA group can add or remove managers there without being in any signature's ACL. An M5 +parity model must preserve this endpoint-specific difference rather than applying the +stricter ACL rule uniformly. + +The read tier is company-wide because the shared scope matcher accepts a +`project|organization` scope on its **organization half alone**, ignoring the project +half ([lfx-kit `auth/user.go` `IsUserAuthorizedForOrganizationScope`](https://github.com/LF-Engineering/lfx-kit/blob/main/auth/user.go)), +while CLA-manager ACS roles are granted as `project|org` pairs +([`v2/dynamo_events/cla_manager.go:205`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L205)). +So a manager appointed for one CLA group at a company passes the org-scope check on that +company's other CLA groups: `GetCompanyClaGroups` +([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134)) and the +other listing endpoints return the company's full CLA set. + +The write tier is narrow and cannot be widened by staff: the approval-list update checks +the project|org tree with **admin scope disallowed** +([`v2/signatures/handlers.go:121`](../../cla-backend-go/v2/signatures/handlers.go#L121)), +and the service layer then requires the caller to be in that specific signature's ACL +([`signatures/service.go:523`](../../cla-backend-go/signatures/service.go#L523), +[`v2/signatures/handlers.go:1499`](../../cla-backend-go/v2/signatures/handlers.go#L1499)). +Managing one CLA group's approval list therefore requires membership in *that* signature's +`signature_acl`; a manager on a sibling CLA group is refused. + +**M3 replicates both tiers unchanged** — this is feature parity, and it is also the +cheapest option. The shipped M3 endpoints already encode exactly these rules: the org CLA +list documents auth as "`organization` scope for the `companySFID`, or any +`project|organization` scope whose organization half matches", and the manager/ +acknowledgment write ops as "`project|organization` tree scope for the project/company +pair, LF admin disallowed" ([`docs/M3_ORG_LENS_API.md`](../M3_ORG_LENS_API.md)). Parity is +the result of *not* writing new code. + +**Do not add per-manager filtering to v4.** Building new per-user filtering would diverge +from today's Corporate Console behavior and violate the program rule that Self Serve +mirrors v4's decisions rather than re-deriving them +([role-mapping-feasibility.md](role-mapping-feasibility.md) §3/§5). Nor is narrowing the +read tier an EasyCLA change at all — the breadth lives in the shared lfx-kit matcher used +by every LFX service, so it would mean changing shared platform code or bolting +CLA-specific filtering onto v4. The concrete version of this proposal, and why it fails on +its own terms, is recorded as a rejected alternative below. + +**Cross-project visibility: settled as intended product behavior, not a gap.** Eric raised +it in review of this proposal as a possible legal concern — a CLA manager for one project +can see that their employer holds agreements with other projects. Scoped correctly it is a +**read-tier** question only; no cross-group writes are possible. Put to Product +(Heather Willson, 2026-09-18) as a choice between full read-only, listed-but-not-openable, +and hidden entirely: **full read-only is the decision**, on the grounds that a CLA's +information should not be hidden, precisely because someone may need to become a CLA +manager or get authorized under their company's CCLA — and they can do neither if they +cannot see the agreement exists and who manages it. No Legal escalation, and no M3 work: +this is what v4 already does. + +**This constrains M5, it is not deferred to it.** An earlier revision of this section +parked the question for M5 on the assumption that `cla_ccla#auditor` would narrow reads per +agreement once FGA enforces. That reading is now wrong: since company-wide CLA visibility +is deliberate, M5's per-agreement read model must **preserve** it rather than narrow it — +`#auditor` has to keep admitting a company's non-manager CLA admins to read its other CLA +groups. Carry this into the M5 ADR as a requirement on the model, not an open item. + +**This requirement is in tension with Variant A's read model, and that tension is +unresolved.** Raised by Luis Moriguerra in review (2026-09-21). Spec 044 computes +`cla_ccla#auditor` as `manager or signatory or auditor from b2b_org or auditor from +cla_group` — a **per-agreement** relation +([m3-org-visibility.md](m3-org-visibility.md) §4.2). A manager of CLA group X therefore +gets no read on the same company's sibling group Y unless they hold `b2b_org#auditor`, +which §4.2 of that document explicitly forbids for CLA managers ("Never org-wide read for +CLA managers"). So Variant A as written does not satisfy the requirement above, while +Variant B preserves today's behaviour in M3 through the bridge but defines no M5 read +model at all. Neither variant, as currently specified, carries company-wide read into M5. + +**The gap is not marginal.** Measured read-only against the prod DynamoDB mirror +(2026-09-21, appendix method): **318 organizations hold more than one signed CCLA group**, +and within those, **849 of 1,033 manager × org pairs (82%) manage only a subset of their +organization's groups**. Under Variant A as written, that 82% loses visibility it has +today. Luis measured 287 orgs and 815 of 980 pairs (83%) with slightly different filters; +the two derivations agree on the conclusion. + +**Grain: per Salesforce org, not per EasyCLA company row.** `GetCompanyClaGroups` resolves +its rows through `GetCompaniesByExternalID(ctx, companySFID, true)` +([`v2/company/service.go:1325`](../../cla-backend-go/v2/company/service.go#L1325)), which +fans out over every company record sharing that SFID — so a single Salesforce org can +carry multiple signing entities with divergent ACLs. Any per-company read relation has to +be keyed on the SFID to match what v4 returns today. Luis counts 4 SFIDs carrying multiple +signing entities, 3 with divergent ACLs across 19 groups. + +**Proposed resolution, for the ADR.** Luis's conclusion is that a **per-company CLA read +relation is needed in both M3 and M5**, which reframes §7's question from "Variant A or +Variant B" to *where that relation lives*: + +- **On `b2b_org`** (`cla_admin`, extended as `... or cla_admin from b2b_org`) — carries + §4.1's member-service coupling forward into M5. +- **On a CLA-owned object** (`cla_company#manager`, extended as `... or manager from + cla_company`) — no member-service coupling, but adds a type and reopens P2's "no CLA + object types before M5" on its own terms. + +Not decided here. This is for the spec-044 ADR review (§7), which is the venue that can +settle it. + +**Rejected alternative: filter the list per CLA group in v4.** Considered — the response +already carries `claManagers` per row from `signature_acl` +([`v2/company/service.go:1522`](../../cla-backend-go/v2/company/service.go#L1522)), so +filtering to "CLA groups where the caller is a manager" would cost ~10 lines and no extra +queries. Rejected on three counts, before the Product decision made it moot: it breaks +`needsClaManager` (an agreement with **zero** managers can match no caller, so the rows the +field exists to surface would vanish) and newly appointed managers (empty lens, no route +forward); it puts FGA and the API at different widths, since `cla_admin` is org-level by +construction; and it would make reads *narrower than writes* in the foundation case, where +the write gate is a project|org **tree** check — a manager appointed at foundation level can +write on a child project's agreement whose own ACL they are not in. + +**Why `cla_admin` is deliberately coarser than the write gate.** The relation is projected +from `signature_acl` deduped across the org's CLA groups, so one ACL membership grants +lens entry to the org. That matches the read tier exactly, and it keeps the projection to +one tuple per user × org. A per-CLA-group relation would have to track v4's write gate, +giving two systems that can disagree — the parity problem in open item 6 of +[m3-org-visibility.md](m3-org-visibility.md) §5. + +## 7. Decision venue + +The spec-044 ADR review (open item 3 in +[m3-org-visibility.md](m3-org-visibility.md) §5): adopt this as the M3 model, with the +four CLA types moved to the M5 ADR where they become enforcing. + +**Proposed form of the decision**, so the "no CLA types before M5" reopen is explicitly +scoped rather than implied: + +> **Variant B (single `cla_admin` relation) for M3, conditional on §4.1 and §4.2 being resolved; +> Variant A (dedicated CLA types) for M5.** + +**This form may be the wrong shape of question.** §6 records a tension neither variant +resolves: Variant A's per-agreement read model does not preserve the company-wide read +Product has confirmed as intended, and Variant B defines no M5 read model at all. If the +ADR accepts that a per-company CLA read relation is needed in both milestones, the +decision becomes *where that relation lives* — on `b2b_org` or on a CLA-owned object — +rather than a choice between the two variants. The recommendation above stands as the +starting position; §6 states what would displace it. + +The ADR should record three things alongside it: + +1. **The §4.1 owner and resolution** (and, with it, the §4.2 consumer-side work — selector + materialization plus a CLA-route gate, which lands in `lfx-self-serve`) — either member-service accepts the + `ExcludeRelations` change with a deploy-order constraint and regression test, or the + grant moves to a CLA-owned object. Variant B is not safe to build until this is + answered. +2. **The bridge ID-translation contract — and only then an ACS re-grant owner.** Under + Path B of the org import ([m3-org-visibility.md](m3-org-visibility.md) open item 5) + the lens holds a new B2B SFID while ACS scopes and `company_external_id` still carry + the old one. Whether that needs an ACS migration depends entirely on a contract + nobody has written down yet: + + - **Bridge translates (new → old) before calling v4** — as + [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) + describes: requests still match existing ACS scopes, **no re-grant is needed**, and + the cost is that every bridged path must translate without exception. + - **New ID passed through untranslated** — v4 returns 403 for managers FGA has + already admitted, and an ACS-scope migration with a named owner becomes mandatory. + + **Decide and document the boundary contract first**; assign the ACS re-grant owner + only if the second option is chosen. Under Path A (the working assumption — IDs are + preserved) neither arises **for companies that store a real old-org SFID**, which is + a further reason to settle Path A/B first. Path A is not a blanket exemption: the + 530 `lf`-shaped IDs have no Salesforce record to preserve, and the 374 domain-linked + orgs point at a B2B account whose ID differs from the stored value + ([m3-org-visibility.md](m3-org-visibility.md) §2.1 and §2.3). Both sets need a rewrite + or translation under either path, and both therefore still need an owner. +3. **The FGA-vs-ACS disagreement rule**, defined before cutover: what the system does + when FGA lets someone into the lens but ACS refuses the API call. "FGA gates the UI, + ACS gates the APIs" describes the split but does not say which wins, what the user + sees, or how the drift is reported. Tracked as open item 6 in + [m3-org-visibility.md](m3-org-visibility.md) §5. diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md new file mode 100644 index 000000000..249bf3050 --- /dev/null +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -0,0 +1,299 @@ + + +# M3 Org Lens: Why EasyCLA Companies Are Invisible, and What Makes Them Visible + +**Status**: Updated after the 2026-09-10 architecture call and the 2026-09-11 proposal review · data re-measured 2026-09-11 +**Owner**: Michal (engineering) +**Related**: [architecture-proposal.md](architecture-proposal.md) P2 · [role-mapping-feasibility.md](role-mapping-feasibility.md) §6 · [EasyCLA → LFX One recap](https://docs.google.com/document/d/1hyWZUE_kofeAjVSmeXsRXtPSxSsj7uWvRj3tTNgwdik/edit) (Luis, rev 5, 2026-09-10) — the CLA-service plan. Its spec package `specs/044-lfx-v2-cla-service/` (branch `044-lfx-v2-cla-service`) is not yet published in any `linuxfoundation` repo; the recap doc above is the citable source for every "spec 044" reference in this file. + +**The problem in one line**: the Self Serve Org Lens lists organizations that have **held an LF membership**, most EasyCLA customers never have, so most CLA managers would open Self Serve and see nothing. + +--- + +## 1. Summary + +Two independent gates keep EasyCLA companies out of the Org Lens, and they need different fixes: + +| | Gate | Affected | Fix | +|---|---|---|---| +| **1a** | No account in the B2B Salesforce org at all | 1,633 of 2,995 (55%) | Ingest accounts (Eric's proposal — needs sales-ops approval) | +| **1b** | Account exists but has no Membership Asset | 314 | Widen the member-service `b2b_org` predicate | +| **2** | User holds no OpenFGA grant on the org | every CLA manager without an independent `b2b_org` grant | CLA FGA types + tuples projected from `signature_acl` | + +Gate 1a is the critical path: it depends on a sales-ops approval nobody on this team controls. Gates 1b and 2 are engineering work inside LFX. + +**This document reverses a previously approved decision.** See §6 — the M5 deferral of CLA-in-OpenFGA is recorded in at least five documents, one of them an architecture-review-approved proposal. + +**It also closes an open spike in the CLA-service plan.** Spec 044 rev 5 names the same membership-asset boundary as its "biggest dependency" and defers the sizing to spike item #4, *"catalogue-gap count"* — "CCLA-holding companies whose `company_external_id` is absent from the membership-asset accounts". §2 is that count, with the caveat that the naive form of the query overstates it by 536 (see §2.1). + +--- + +## 2. The gap, quantified + +Measured in Snowflake against prod on 2026-09-10. member-service reads the **B2B Salesforce org** (18,231 accounts, carved out of the old platform Salesforce with record IDs preserved) — not the old platform org (100,592 accounts) that EasyCLA's `company_external_id` values point at. + +### 2.1 What EasyCLA actually stores + +EasyCLA has 3,705 company rows; 3,537 carry a non-empty `company_external_id`, collapsing to 3,531 distinct values. **Those 3,531 are not all Salesforce IDs:** + +| `company_external_id` shape | Distinct | In B2B org | Live in old platform org | +|---|---:|---:|---:| +| `001…` — a real Salesforce ID | **2,995** | 1,362 | 2,920 | +| `lf…` — an LFX-generated Org Service ID | **530** | 0 | 520 | +| other / malformed | 6 | 0 | 3 | +| *(empty — 168 rows, excluded above)* | — | — | — | + +The 530 `lf`-prefixed values are the Org Service's own 18-character identifiers, [deliberately shaped to be drop-in compatible with a Salesforce ID](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/TECHNICAL.md). **They are not Salesforce IDs and can never match a B2B account by ID** — none of the 530 do. Any analysis that treats `company_external_id` as an SFID overcounts the population by 536. + +> **Consequence for the ingest.** These 530 companies are not a lookup failure to be remapped — there is no Salesforce record to find. They need an account **created** (or domain-matched), exactly like the missing 1,633, and they are invisible to any SFID-keyed remediation. They are, however, in scope for Eric's domain-based sizing, because 520 of them still resolve to a live Org Service organization carrying a domain. + +### 2.2 Visibility, among the 2,995 with a real SFID + +| Metric | Count | Share | +|---|---:|---:| +| Distinct real SFIDs in EasyCLA | 2,995 | 100% | +| …with an account in the B2B org | 1,362 | 45% | +| ……has a Membership Asset → **visible in Self Serve today** | **1,048** | **35%** | +| ………of which hold a *current* membership | 691 | 23% | +| ………of which are lapsed — visible but not members today | 357 | 12% | +| ……present, no Membership Asset ever (gate 1b) | 314 | 10% | +| …with **no B2B-org account** (gate 1a) | **1,633** | **55%** | +| ……still a live account in the old platform org | 1,559 | | +| ……dangling — resolves nowhere | 74 | | + +Restricted to companies with an **active signed CCLA** — the population that actually matters for M3: + +| Metric | Count | Share | +|---|---:|---:| +| Orgs with an active signed CCLA (real SFID) | 1,948 | 100% | +| …visible today (has a Membership Asset) | 808 | 41% | +| ……of which hold a *current* membership | 529 | 27% | +| ……of which are lapsed | 279 | 14% | +| …invisible — no Membership Asset 180, absent 960 | **1,140** | **59%** | + +> **"Member" here means *has ever held a membership*, not *is a member today*.** member-service's catalogue predicate is `Product2.Family = 'Membership' AND IsDeleted = false` with **no status filter** ([`account_repo.go:41-45`](https://github.com/linuxfoundation/lfx-v2-member-service/blob/main/internal/infrastructure/salesforce/account_repo.go#L41-L45)) — an `Expired` or `Invoice Cancelled` Asset qualifies exactly like an `Active` one. Across the whole B2B org, 8,065 accounts have a Membership Asset but only 4,595 hold a current one; the other 3,470 are lapsed and still in the Org Lens. This is why the Lens shows organizations that are not members — Heather observed this on 2026-09-11 and Eric traced the example to a 2020–2023 LFN membership. It is existing intended behavior, not a defect, but it matters here in two ways: it inflates what "visible today" means (of our 1,048, only 691 are current members), and it means **widening the predicate to "membership or CLA-referenced" is a smaller semantic change than it first appears** — the catalogue already contains non-members. + +> **Basis note.** All counts are distinct IDs, not company rows — 3,537 rows collapse to 3,531 values, the duplicate-row problem tracked in [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056). B2B-org matching compares the first 15 characters, since Salesforce 15- and 18-character IDs denote the same record. Totals drift by a few rows between syncs as DynamoDB grows; percentages are stable. + +### 2.3 How this relates to Eric's sizing + +Eric's [ingest proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md) sizes the same problem a different way, and both results are correct — they answer different questions: + +| | This document | Eric's proposal | +|---|---|---| +| **Question** | Which EasyCLA orgs can member-service resolve *today*? | How many Salesforce accounts must be *created*? | +| **Population** | 2,995 real SFIDs | 3,693 CCLA companies, less 258 with a missing/broken org reference → 3,435 | +| **Match key** | stored SFID present **by ID** in the B2B org | normalized **domain**, via the Org Service org (EasyCLA stores no domain) | +| **Already covered** | 1,362 | 1,725 | +| **Not covered** | 1,633 | 1,710 → **1,709 distinct orgs, +9.4%** on 18,181 | + +The two "not covered" figures are close, but they are **not the same set**, and the difference is the operationally important part. Measured directly on the same rows: + +- **374** orgs are absent from the B2B org by ID yet **domain-match an existing B2B account**. These need **linking**, not creating — and they still need an ID remap, because the account they link to has a different ID than the one EasyCLA stores. +- **11** orgs are present by ID but have no domain match, so Eric's method counts them as needing an account they already have. A negligible error bound on his figure, noted for completeness. +- Eric's population includes the `lf`-shaped and broken-reference companies that §2.1 separates out, which is why his totals run higher. + +> **The "50% vs 75% gap" question, resolved.** Eric [raised this on 2026-09-10](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md): his scripts report a ~50% gap while the architecture call discussed ~75%, and he correctly identified the cause — he matches against **all** B2B accounts, whereas the Org Lens additionally filters to accounts holding a Membership Asset. Both numbers are right at their own gate, and the difference *is* gate 1b. In this document's terms: 45% of EasyCLA's real SFIDs resolve to a B2B account (Eric's gate), but only 35% clear the membership filter as well (the Lens gate). His conclusion that the membership constraint must also be removed is the same change as the predicate widening in §4.2 — the two proposals agree, and neither is sufficient alone. + +**Steady state after the backfill**: ~362 new CCLA companies/year, of which ~196 (~16/month) need a new account — flat over three years. + +> **Caveat worth raising with Eric.** The 1,725 "already maps" and the 374 links above are *inferred from domain equality*, not verified identity. Shared or reused domains (subsidiaries, acquisitions, ISP-hosted sites) can mislink. Eric's staged review covers the records he *creates*; the links get no equivalent review pass, yet a mislink silently points a signed CCLA at the wrong company. Worth a review gate on the link set, not just the create set. + +--- + +## 3. Why they are invisible — the two gates + +```mermaid +flowchart LR + OLD[("Old platform Salesforce
100,592 accounts — where
EasyCLA IDs point")] -.->|"B2C decouple carved out
18.2k accounts, IDs preserved"| SF + SF["B2B Salesforce org
18,231 accounts"] -->|"gate 1b: has Membership Asset
(8,065 accounts)"| MS["member-service"] + MS -->|"b2b_org docs"| QS["query-service
(OpenSearch)"] + MS -->|"writer / auditor tuples"| FGA["OpenFGA"] + QS -->|"org list"| SS["Self Serve
Org Lens"] + FGA -->|"gate 2: user must hold
a grant on the org"| SS + CLA[("EasyCLA DynamoDB
2,995 real SFIDs
+ 536 non-Salesforce IDs")] -.->|"gate 1a: 1,633 have no
B2B-org account"| SF +``` + +1. **The org record does not exist**, in two layers. **(1a)** 55% of EasyCLA orgs with a real SFID have no account in the B2B Salesforce org — they were left behind in the old platform org during the B2C decouple. No predicate change can surface them; the accounts must be ingested. **(1b)** The 314 that do exist fail the Membership-Asset gate — predicate widening covers exactly these, and nothing else. +2. **The user holds no grant.** Org Lens eligibility is an OpenFGA relation on `b2b_org` / CLA objects. EasyCLA CLA-manager roles live in ACS and Org Service scopes; OpenFGA knows nothing about them. + +> **ID remapping is a conditional work item — it depends on how the ingest creates accounts.** A standard Salesforce insert cannot choose a record ID, so accounts created that way get a **new SFID** and EasyCLA's stored `company_external_id` stops resolving for the affected population (compounded by the 374 domain-links and the 530 `lf`-shaped IDs, which never resolved). But the B2B→old sync provably *does* preserve record IDs across orgs, so the mechanism the ingest uses decides the outcome: +> +> - **Path A — the import preserves existing SFIDs** (working assumption, pending Mindy's confirmation): no remap, no crosswalk, no EasyCLA or ACS rewrites. Already true today for the 995 synced active-CCLA companies. +> - **Path B — the import mints new B2B IDs**: an old-ID → new-ID map is required, but **only for the ingested set**, applied at the Self Serve bridge rather than by rewriting the EasyCLA DB. +> +> Do not plan identity or scope rewrites until this is settled — see [open item 5](#5-open-items) for the measurements and [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) for the Path A/B decision. Eric's proposal supplies the ongoing mechanism either way — each CCLA organization gets a foreign key to its Salesforce Account ID and participates in future account merges, so the key follows the surviving record, "the part that does not exist today". Ordering and ownership are [open item 5](#5-open-items). + +--- + +## 4. Direction agreed on the 2026-09-10 architecture call + +**EasyCLA companies are B2B engagements — they become real Salesforce B2B accounts.** No separate "EasyCLA organization" entity, no new B2C org type, no parallel org catalogue. A CCLA attaches to a B2B account the same way a membership does. + +Eric's written proposal — [Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md), tracked in [linuxfoundation/lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16) — is published and going to sales ops. **That approval is the critical-path dependency for M3.** If sales ops pushes back, a different approach is needed (acknowledged on the call). + +### 4.1 What this document depends on from that proposal + +1. **CCLA signing is a recognized B2B onboarding path** — structurally parallel to member enrollment, minus the financial relationship. Consistent with Eric's earlier [organization-decoupling proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2024-12%20Decoupling%20orgs%20and%20users/README.md#a-proposal-for-organization-decoupling) (2024-12), where B2B org records are created only when a user starts a B2B flow "like Member Enrollment or Corporate CLA", and the parallel LFX org database is sunset rather than extended. +2. **LFX creates the ~1,709 accounts**, staged and reviewable in tranches; Sales Ops approves the records and field semantics. `IsMember__c` stays **false** — which is precisely why the predicate widening in §4.2 is still required: ingested accounts would otherwise keep failing the Membership-Asset gate. +3. **The 258 companies with a missing or broken org reference are excluded** from the ingest and remediated in EasyCLA (§6 prerequisites). Since EasyCLA stores no company domain, the one credible signal is the CLA managers' email domains — a per-record exercise, not a query. +4. **Future CCLA onboarding routes through Salesforce account creation at signing time**, so the backfill is one-time. End-user UX does not change. *Which* service creates the account was an open governance question in Eric's proposal; per the 2026-09-17 sales-ops meeting (open item 1), the expected direction is a membership-free Apex endpoint that keeps matching policy in Salesforce, not direct sObject create access from LFX. + +> **Mechanical note.** member-service's existing `create-b2b-org` (`POST /b2b_orgs`) **registers** an Account that already exists in Salesforce; it does not create one, and it performs no duplicate check ([TECHNICAL.md](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/TECHNICAL.md) §5). That is the same operation as `b2b_org_ensure` below — account creation happens upstream of it. + +### 4.2 The technical shape, converging with spec 044 rev 5 + +```mermaid +flowchart LR + subgraph v1 ["EasyCLA v1 (AWS)"] + DDB[("DynamoDB
companies + signatures
(signature_acl = managers)")] + V4["EasyCLA v4 API"] + end + subgraph platform ["LFX One platform"] + SH["lfx-v1-sync-helper
(streams + Meltano)"] + KV["NATS KV
cla-v1-objects"] + MS["member-service
predicate: membership
OR CLA-referenced"] + FGA["OpenFGA
cla_group / cla_ccla /
cla_ecla / cla_icla"] + QS["query-service"] + end + DDB --> SH --> KV + KV -->|"projector: ACL diff →
manager / signatory tuples"| FGA + KV -.->|"b2b_org_ensure for
unknown accounts"| MS + MS -->|"b2b_org docs + reindex"| QS + SS["Self Serve"] -->|"org list + selector union
(org read OR cla_ccla manager)"| QS + SS -->|"/access-check"| FGA + SS -->|"CLA tabs: bridge (v4 via gateway)
until parity gate flips flags"| V4 +``` + +- **Org catalogue** — member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus an `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand, then a full `b2b_org` reindex. member-service remains the single Salesforce org service. +- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M5 — all four dedicated CLA types land together in that milestone under the companion model, so no intermediate model bump or tuple backfill is required at M4). Both are on the table for the ADR review (§5 item 3). Under either shape, v4's own two-tier enforcement is replicated unchanged for M3 — company-wide reads, per-agreement writes. Product has confirmed the company-wide read is intended and must be preserved at M5, not narrowed; see [m3-fga-model.md](m3-fga-model.md) §6 — which also records that **neither variant as currently specified carries that guarantee into M5**, since spec 044's `cla_ccla#auditor` is per-agreement while the read it must preserve is company-wide. +- **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. +- **Console cutover** — **staged, not a hard cut.** The 2026-09-10 call framed this as a hard cut; the rollout trackers have since superseded that. Epic [linuxfoundation/lfx-self-serve#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) retires the Console only after production rollout and further validation, and [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) step 7 requires a **few-week parallel window** with both UIs live before decommissioning. What the original point got right is the narrower claim it was making: no live **ACS↔FGA dual sync** is needed, because CLA tuples are projected from `signature_acl` for newly admitted accounts exactly as for existing ones — the same source for initial backfill and ongoing projection (spec 044 item 10, the KV projector), so **no grant is ever derived from ACS**. + + Note the distinction this collapses: ACS is not reduced to reporting. **Every bridged v4 call remains ACS-authorized** for the whole M3→M5 period — that is the "FGA gates the UI, ACS gates the APIs" split. ACS's read-only role is specifically in **validating the FGA projection**: the drift report (section H, item 22 — `--backfill-acs --roles cla --dry-run`) compares ACS `cla-manager` roles against the tuples and is expected to show divergence where ACS is stale, rather than to correct anything. Enforcement and drift-reporting are separate jobs ACS holds at the same time. + +> **Open question — how a signatory reaches the lens.** Not a permissions gap: spec 044 computes `cla_ccla#auditor` as `manager or signatory or …`, and the agreement's query-plane gate is `#auditor`, so a signatory **can** read the agreement once inside. The gap is the **org selector**, whose union is "org viewer/admin **or** manages an agreement" — manager-only. A signatory holding no `b2b_org` grant therefore has no organization to select, and so never reaches the CLA data they are authorized to read. That does not square with the signatory flow M3 must deliver ([`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) FR-030 lists CCLA signing initiation in the parity inventory; FR-031 ties org-lens access to "CLA-manager/signatory authority"). Either the selector union admits `signatory` — with relation-specific screen permissions, so it confers neither manager nor org-wide access — or the signatory flow enters by another route. Rev 5 lists "selector union + CLA-only view" as an open **Product + Architecture** decision, recommending the no-model-change option; the signatory case is not called out within it. For Luis and Eric; not decided here. **See also §4.5** — if CCLA signing starts from a dedicated CLA landing page rather than the Org Lens, as Eric has proposed, a signatory never needs selector access and this question narrows to signatories who must *review* an existing agreement. + +### 4.3 What changed versus the pre-call version + +| Pre-call proposal | Now | +|---|---| +| New `cla_manager` relation on `b2b_org` | Superseded by dedicated CLA FGA types (`cla_ccla#manager` etc.), manager derived from `signature_acl` | +| Admit EasyCLA orgs "tagged non-member" | Sharpened: they become real B2B accounts (sales-ops approval pending), and the predicate widens | +| CLA tabs call v4 via gateway; no replication in M3 | Converges with spec 044's rollout: pages run on the bridge behind per-env flags, flipping to the CLA service only at zero parity differences | + +### 4.4 Second-order effects of admitting these orgs + +Admitting ~1,600 non-member companies to the org catalogue changes more than the catalogue. Spec 044's companion note ["What changes if every CLA-signing company becomes a real organization"](https://docs.google.com/document/d/1hyWZUE_kofeAjVSmeXsRXtPSxSsj7uWvRj3tTNgwdik/edit) raises five, none of which have owners yet. They are consequences of the decision this document argues for, so they belong in its scope: + +| Effect | Why it matters | Needs deciding | +|---|---|---| +| **Company admins appear automatically** | EasyCLA records whoever creates a company as its administrator. The v1-sync-helper already imports company admins as **org admins** — but filters to member companies. Widen the catalogue and that filter decides whether every CLA requester becomes an org admin who can edit People and key contacts. | Import as admins, as viewers, or not at all — relying on the CLA-manager relation only. Product/legal, not technical. Work item sits in the sync helper. | +| **Staff read extends to them** | LF staff hold blanket read on every org; the new companies inherit it, exposing thousands of non-member companies and their CLA data. The blanket grant was reviewed for member companies only. | Re-confirm the grant covers non-member orgs; record it in the decision. | +| **"CLA-only organization" state** | Memberships, key contacts, ROI, meetings are all empty for a never-member company. Rev 5 covers the *user* who enters as a CLA manager, not the *company kind* — so even a full admin lands on empty pages. | An org-level CLA-only marker: hide or explain the membership sections. | +| **Volume** | The org list grows by roughly 9.4%. Affects the staff selector (may need search-first), full-rebuild time, and the Salesforce API budget. | Measure rebuild time before launch; adjust the selector if needed. | +| **Making onboarding stick** | `b2b_org_ensure` is a one-time event. A full rebuild starts from Salesforce again and **drops these companies** unless "referenced by a CLA" is stored durably. | Choose the durable signal — a field on the Salesforce account set by EasyCLA, or a marker LFX keeps — as part of the decision. | + +The last one compounds the remap problem in [open item 5](#5-open-items): both are silent failures surfacing only as an org that quietly stops appearing. + +### 4.5 The entry-point question this raises (Heather / Eric, 2026-09-11) + +Reviewing the ingest proposal, Heather Willson asked two questions that this document's data does not answer and that bound its scope. Recorded here because they gate what M3 should build, not because they are settled: + +1. **Must a company be a B2B account before it can sign a CCLA?** Under this proposal, yes — CCLA signing creates the account. Eric's position is that this is true regardless of where B2B orgs are stored, and is already the case in v1: if the Org Service organization does not exist, the signing flow has to create one. The change is *what gets created* (a Salesforce account instead of an Org Service record), not *whether* something is. +2. **What do non-B2B (B2C-only) organizations see in the Org Lens?** Today, Insights routes such users to a dead end. The options split on what the user is meant to *do* there: a read-only view can be built from CDP attestation data alone and needs no account; anything transactional — signing a CCLA, joining as a member — routes through account creation regardless. Eric's framing: there is no meaningful "view-only org" action for a company with no memberships, no committee seats, and no project logo. + +Eric also questions whether CCLA signing should start from the Org Lens at all — the alternative being a single CLA landing page that disambiguates ICLA/ECLA/CCLA, carries the user through org creation, and hands off to the Org Lens only once there is an org to land in (the pattern already used for Member Enrollment, which does not live in the Org Dashboard). **If that is the chosen flow, the signatory-selector problem in §4.2 largely dissolves** — a signatory would never need to enter the Lens to sign. That makes this a product decision with direct architectural consequence, not a sequencing detail. + +A related unresolved question: how a user's Org Lens is determined for attested (non-CCLA) organizations — by email domain, by work-history employer, and what happens with generic email domains or multiple concurrent employers. Eric considers these foundational to the engagement model rather than edge cases. Out of scope for M3 as scoped here, which keys on CCLA signatures rather than attestation, but it constrains any later "every employee sees their employer" ambition. + +--- + +## 5. Open items + +1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy White (sales ops); the blocker for the catalogue change. The proposal is published and tracked in [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16). **Update 2026-09-17**: met with the Salesforce team; no objection in principle, but they want to confirm internally with Dolan/Stephanie before committing effort this close to renewal season — answer expected the week of 2026-09-21. Three takeaways: (a) they expect LFX to go through an Apex-exposed interface, not direct sObject create access (see §4.1 item 4); (b) they will accept an AI-written contribution to their repo, tested in sandbox, to accelerate the work; (c) they are motivated to close this out before their renewal-season load increases. Note the ask is not only a catalogue change: 55% of EasyCLA orgs need an account **created or domain-linked**, and predicate widening alone covers only the 314 already-present non-member accounts. +2. **Product decision on the CCLA entry point** (§4.5) — does CCLA signing start from the Org Lens, or from a dedicated CLA landing page that creates the org and hands off? Raised by Heather Willson and Eric Searcy on 2026-09-11; David Deal's position is that product should drive these requirements. This is upstream of the selector-union question in §4.2 and can eliminate it. Unowned as of this writing. +3. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` replica bucket, read-plane-first for an external system of record, and the catalogue boundary + selector rule. Gates the FGA model bump and the member-service PR; rev 5's own recommendation is to approve all four, with #4 being the member-service PR plus a full `b2b_org` reindex. This review is also where the reversal in §6 should be formally recorded, and where the second-order effects in §4.4 need owners. +4. **M3 sequencing — conditional on which FGA model is selected.** The common minimum is the catalogue change, an FGA model bump plus tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. **What item 9/10 actually project depends on the model decision in [`m3-fga-model.md` §7](m3-fga-model.md):** + - **Variant A (spec 044's dedicated CLA types)** — item 9 adds `cla_group`/`cla_ccla`, item 10 projects `cla_ccla` tuples. No member-service change. + - **Variant B (single `b2b_org#cla_admin` relation, the companion doc's M3 proposal)** — item 9 adds one relation to the existing `b2b_org` type and item 10 projects `b2b_org:#cla_admin` from `signature_acl`. This variant carries **an extra, blocking work item the list above does not name**: member-service must add `cla_admin` to the `ExcludeRelations` of its `update_access` message, or fga-sync's full-sync semantics silently reap every projected tuple on the next org write ([`m3-fga-model.md` §4.1](m3-fga-model.md)). That change must deploy **before** the projector, and it is owned by a different team. Variant B additionally needs consumer-side work in `lfx-self-serve` — the selector classifies only `writer`/`auditor`, and the CLA BFF route's gate accepts only a roster grant or `b2b_org#auditor`, so a `cla_admin`-only manager reaches neither ([`m3-fga-model.md` §4.2](m3-fga-model.md)). So Variant B's sequence is longer than Variant A's and spans three repos, despite the smaller model diff. + + **Sequencing note — the B2B→old sync is one-way.** Companies created through v4 or the Corporate CLA Console land in the old platform org and never propagate to B2B, so they acquire no `b2b_org` object and cannot appear in the lens until the ingest covers them. While both UIs run in parallel ([lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) step 7), Console-created companies keep arriving on the wrong side of that sync — so the ingest is not a one-shot backfill that can run before cutover and be done. Either Console company-creation is closed off early, or the ingest repeats until it is. Raised by Luis Moriguerra, 2026-09-21. + + The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency under either variant. +5. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before CLA tuple projection (`cla_ccla` or `b2b_org#cla_admin`, per item 4). An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. + **A fourth system needs the map, not just three.** Existing CLA-manager ACS roles are created against `company.CompanyExternalID` at grant time ([`v2/dynamo_events/cla_manager.go:144`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L144), which calls `assignCLAManager` → [`orgService.CreateOrgUserRoleOrgScopeProjectOrg(..., companySFID, ...)`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L205)), while every API call authorizes against the SFID the caller passes in the request ([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134), `IsUserAuthorizedForOrganization(ctx, authUser, params.CompanySFID, ...)`). After an org's SFID changes, an existing manager's ACS scope stays pinned to the old SFID: FGA admits them to the lens (their `cla_admin` tuple re-projects fine from `signature_acl`), but v4 may return 403 on every call. + + **Whether an ACS migration is actually needed is conditional on the bridge's ID-translation contract, and that contract is the thing to settle first** (the same fork as [`m3-fga-model.md` §7](m3-fga-model.md) item 2): + + - **Under Path A** ([linuxfoundation/lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750), the working assumption) the import preserves existing old-org SFIDs, no ID changes, and none of this arises. + - **Under Path B, if the bridge translates new→old** before calling v4, the ACS scope stays valid as written and **no re-grant is required** — the translation is the migration. + - **Under Path B with untranslated pass-through**, the 403 above is real and an ACS org/project-scope migration to the new SFID becomes mandatory, with a named owner. + + So this is a work item to *scope*, not one to assume. Under whichever branch applies, cutover acceptance should include: an existing non-admin CLA manager, not just a fresh admin, can call the endpoints for their org. + **Update 2026-09-18: the map already exists as live data — the old org's `Account.sfid_b2b` crosswalk.** Measured in Snowflake (appendix): every B2B account, including all 1,045 created since the 2026-06 carve-out, is referenced by exactly one old-org account's `sfid_b2b` (1,045 B2B accounts → 1,045 join rows → 1,045 distinct old-org rows, an exact 1:1); the pointer is never stale (every one resolves to a live B2B account). More than a pointer, it is an **identity**: in 100% of pairs (17,303 carve-out + 1,045 post-carve-out) the old-org account's own SFID equals its `sfid_b2b` — the sync preserves record IDs across orgs (which also explains the copied `CreatedDate`s), so for every synced account the old-org SFID, the org-service SFID, and the B2B record ID are the same string. Org-service already reads and exposes it as `SalesforceB2BAccountID`, with a `hasb2baccountid` filter ([organization-service `organization/repository.go`](https://github.com/LF-Engineering/organization-service/blob/main/organization/repository.go), the `sa.sfid_b2b` column). So the answer to "where does the map live" may be **none of the three candidates above** — **but only for the subset this actually covers.** For a company whose stored `company_external_id` *is* a real old-org SFID, EasyCLA keeps it untouched and anything needing the B2B ID joins through the old-org account, so ACS scopes never need carrying forward and the Corporate Console keeps working unchanged for as long as it runs. + + **Two populations are outside that guarantee and still need an explicit mapping or remediation path, whatever the ingest does with IDs:** + + - **The 530 `lf`-shaped IDs (§2.1).** There is no Salesforce record to preserve, so ID preservation is vacuous for them. `sfid_b2b` proves identity only where an old-org account exists *and* EasyCLA stores that account's SFID; neither holds here. They need an account **created** and `company_external_id` **rewritten** — they are invisible to any SFID-keyed remediation. + - **The 374 domain-linked orgs (§2.3).** These link to an existing B2B account whose ID differs from the one EasyCLA stores, so the stored value cannot address the target account no matter which path the ingest takes. They need a translation or a rewrite. + + Path A therefore removes the remap for the *SFID-resolvable* population, not for the whole affected set. The mapping work item shrinks; it does not disappear. Coverage today: 995 of 1,957 active-CCLA companies (51%) resolve old SFID → `sfid_b2b` → live B2B account; the 962 that don't are the ingest set, and 18 active-CCLA companies point at SFIDs absent even from the old org (cleanup input). Two caveats: (a) the sync is **one-way, B2B→old** — of new old-org accounts since June, only the ~30–42%/month that are shadow rows of B2B-created accounts have `sfid_b2b`; accounts created in the old org (which is what v4's `CreateOrg` and both Corporate Console creation paths mint) never flow to B2B on their own, so newly signed CCLA companies stay invisible in the lens until the ongoing ingest mechanism sweeps them or v4 creates B2B accounts directly; (b) the shadow rows' `CreatedDate` is copied verbatim from the B2B record (identical to the second on all 1,045 pairs), so replication latency and mechanism are unmeasurable from the data. Confirmation path (status 2026-09-18): **Mindy is first confirming whether EasyCLA orgs can be imported into the B2B org at all**; once that lands, the follow-up is whether the import can create B2B accounts **preserving the companies' existing old-org SFIDs as the B2B record IDs**, the way the ongoing sync does (standard Salesforce inserts cannot choose record IDs, so the sync mechanism is special) — if yes, `company_external_id` stays valid in the B2B org for every company **that stores a real old-org SFID** (already proven for the 995 synced active-CCLA companies) and no remap is needed for that population — the 530 `lf`-shaped IDs and 374 domain-links still need rewriting either way; if the import mints new B2B IDs instead, a remap/crosswalk is needed only for the ingested set. **Working assumption while we wait: IDs are preserved (Path A in [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)).** Separately, ask Eric what the B2B→old write-back latency is after a new B2B account is created — it gates switching v4 org creation to the B2B path. Record-type check: 1,019/1,045 shadow rows share the record type of the accounts EasyCLA resolves today (`01241000000bkf1AAA`); 23 use `012QP000002OBQzYAO` and 3 use `01241000001E1xlAAC`, both of which should be confirmed against org-service's `ORG_SERVICE_RECORD_TYPE_ID` allow-list. +6. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. **Both ultimately derive from the same `signature_acl` write** — the ACS CLA-manager role is granted from the same DynamoDB event ([`v2/dynamo_events/cla_manager.go:144`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L144)) that the tuple projection keys on. They can still disagree in both directions, because they are **two independent asynchronous projections into separate stores**: either can lag, fail, or be reaped (§4.1 of [m3-fga-model.md](m3-fga-model.md)) without the other noticing. That is the failure mode the parity rule has to handle — drift between two copies of one source, not two systems reading different inputs. Concretely: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. + +--- + +## 6. Documents this supersedes + +The 2026-09-10 call reversed the position that CLA object types enter the platform authorization model only at M5, gating M3 on the ACS permission bridge instead. That position is recorded in **five** documents plus an epic, and all of them now contradict this one: + +| Document | Where | +|---|---| +| [`architecture-proposal.md`](architecture-proposal.md) | **P2** (line 90) — "Roles: bridge, don't migrate. No CLA object types in OpenFGA before M5"; also lines 57, 105, 110 | +| [`role-mapping-feasibility.md`](role-mapping-feasibility.md) | §0 (line 25), option B rejection (line 202), option C (line 204) | +| [`00-overview-fable.md`](../../specs/001-easycla-ss-integration-fable/00-overview-fable.md) | §3 item 3 (line 64); also lines 50, 89 | +| [`03-milestone-ccla-org-lens-fable.md`](../../specs/001-easycla-ss-integration-fable/03-milestone-ccla-org-lens-fable.md) | line 37 — "A for M3, B deferred into M5, C rejected" | +| [`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) | line 223 — "deferred to M5 scope… no CLA object types in the platform authorization model" | +| Epic [lfx-self-serve#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) | same statement | + +**P2 is not a passing mention — it is an architecture-review-approved proposal** (Eric, with the endpoint-deprecation risk closed 2026-07-31, ARCH-406). Reversing it means reopening that approval at the spec-044 ADR review (open item 3), not merely editing prose. Until these are updated, two incompatible authorization architectures are documented side by side. + +Two further documents describe behavior that changes but are not "superseded" in the same sense: + +- [`docs/M3_ORG_LENS_API.md`](../M3_ORG_LENS_API.md) documents per-endpoint **ACS scope** authorization for shipped endpoints. If CLA FGA types land in M3, this describes live behavior that changes — arguably a higher-stakes update than the planning specs. +- `spec.md` FR-032 pins role-assignment consistency to "the system of record used by EasyCLA's enforcement" (ACS), which an M3 FGA move puts in tension. + +**What does not change**: FGA governs **lens entry and UI gating**; EasyCLA v4 via ACS remains the **enforcement** point for every write through M3. Two layers, not two systems of record — but see open item 6 for the parity requirement that makes this safe. + +--- + +## 7. Prerequisites already tracked + +Data cleanup needed regardless of the outcome ([parent story lfx-self-serve#2043](https://github.com/linuxfoundation/lfx-self-serve/issues/2043)): + +- [lfx-self-serve#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) — companies with a missing or invalid SFID are unreachable under any design. This overlaps Eric's 258 excluded companies (168 empty plus 90 dangling references, counted against Org Service — hence the different total) and, more importantly, the **530 `lf`-shaped IDs** in §2.1, which are not malformed but simply are not Salesforce IDs. The ticket's scope should be checked against both sets. +- [lfx-self-serve#2055](https://github.com/linuxfoundation/lfx-self-serve/issues/2055) — legacy `POST /v1/company` still creates companies with no Salesforce link. Eric's proposal point 4 (account creation at signing time) makes closing this more urgent: that path would bypass the new flow entirely and keep generating the problem this document describes. +- [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) — duplicate company rows per SFID break resolution. + +--- + +## Appendix: reproducing the numbers + +All figures in §2 come from Snowflake, measured 2026-09-10, read-only: + +| Source | Table | +|---|---| +| B2B Salesforce org (what member-service reads) | `FIVETRAN_INGEST.SALESFORCE.ACCOUNT` | +| Membership gate | `…SALESFORCE.ASSET` ⋈ `…SALESFORCE.PRODUCT_2` on `FAMILY = 'Membership'` | +| Old platform org (where EasyCLA IDs point) | `FIVETRAN_INGEST.SFDC_CONNECTOR_PROD_SALESFORCE.ACCOUNT` | +| EasyCLA companies | `FIVETRAN_INGEST.DYNAMODB_PRODUCT_US_EAST_1.CLA_PROD_COMPANIES` | +| EasyCLA signatures | `…DYNAMODB_PRODUCT_US_EAST_1.CLA_PROD_SIGNATURES` | + +Rules applied throughout: exclude `IS_DELETED`/`ISDELETED` and `_FIVETRAN_DELETED`; join B2B IDs on `LEFT(id, 15)`; treat `company_external_id` as a real SFID only when it matches `001%`. Active CCLA means a `CLA_PROD_SIGNATURES` row with `signature_type = 'ccla'`, `signature_reference_type = 'company'`, and both `signature_signed` and `signature_approved` true. + +The membership gate deliberately applies **no** `ASSET.STATUS` filter, matching member-service's SOQL. The current-vs-lapsed split in §2.2 adds `STATUS IN ('Active','Purchased','At Risk')`; the remaining statuses in the data are `Expired`, `Invoice Cancelled` and `Associate Cancelled`. Re-measured 2026-09-11: the §2.2 and §2.3 totals reproduce exactly, with active-CCLA counts drifting by one row (1,949/809) as DynamoDB grows — the drift the basis note describes. + +Eric's domain-matching figures are reproducible from [his scripts](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend/scripts); the 374/11 overlap figures in §2.3 come from running his classification and the SFID-presence test over the same rows in one query. + +The crosswalk figures in open item 5 (measured 2026-09-18, read-only) come from the same sources plus the old-org mirror's `SFID_B2B` column: coverage joins `SALESFORCE.ACCOUNT` to `SFDC_CONNECTOR_PROD_SALESFORCE.ACCOUNT` on `LEFT(sfid_b2b, 15) = LEFT(id, 15)`; the one-way-sync finding compares monthly counts of new old-org accounts carrying `sfid_b2b` against monthly B2B account creations; the latency non-finding compares `CREATEDDATE` across the join (delta is 0 minutes for all 1,045 post-carve-out pairs — copied, not independent); EasyCLA coverage extends the §2 company join through `sfid_b2b` to a live B2B row, split by the active-CCLA rule above. Note the Dynamo mirrors store rows as a `DATA` variant column, so company/signature fields are read as `DATA:company_external_id::string` etc.