Skip to content

feat(org-selector): foundation-level auditor sees all member orgs - #1127

Draft
manishdixitlfx wants to merge 5 commits into
mainfrom
feat/LFXV2-2750-foundation-auditor-org-selector
Draft

feat(org-selector): foundation-level auditor sees all member orgs#1127
manishdixitlfx wants to merge 5 commits into
mainfrom
feat/LFXV2-2750-foundation-auditor-org-selector

Conversation

@manishdixitlfx

Copy link
Copy Markdown
Contributor

Summary

Adds a foundation-level Auditor path to the org-selector dropdown (LFXV2-2750). If the caller holds the FGA auditor relation on a foundation (a project), the dropdown additionally lists all member organizations of every foundation they audit, rendered with a view-only (eye) persona — never the Edit (pen) affordance. Everyone else keeps exactly today's behavior (direct b2b_org_settings grants + cascading children remain the catch-all).

How it works

Both /api/nav/org-items (the list) and /api/orgs/me/role-grants (the persona sets) derive from one resolver — OrgRoleGrantsService.getAccessAwareOrgs. The new augmentation folds foundation-auditor member orgs into that resolved map with a foundation-auditor role source, so a single integration point lights up both the row and the persona.

  • Discover audited foundations (user token): enumerate foundations (funding/membership/stage filters, mirrors NavigationService) → batch access-check project:<uid>#auditor (chunked). checkAccess fails closed (all-false) on upstream error.
  • Fetch member orgs (M2M verify-then-elevate): after verifying auditor status on the user token, swap to an M2M token only for the member-org display reads (project_membership by project_slugb2b_org docs), then restore in finally. Elevation is required because a project auditor does not inherit auditor on b2b_org and b2b_org has no public viewer.
  • Merge: pure, unit-tested mergeFoundationAuditorOrgs — direct/inherited grants always win, additive rows capped at ORG_ROLE_GRANTS_HARD_CAP. Only active/purchased memberships surface (mirrors OrgMembershipResolverService / OrgPeopleKeyContactsService).

Design decisions (agreed with requester)

  • Union of all audited foundations — not scoped to a single selected foundation (the org lens has no foundation context).
  • M2M verify-then-elevate — the documented M2M exception; user-level authz enforced first, elevation scoped to reads the user token genuinely can't see, token restored immediately.
  • Scope = dropdown + persona only — see lens-read note below.
  • Capped in-memory list (v1) — reuses the existing in-memory search/sort with a hard cap; real server pagination is a follow-up.

Rollout / kill-switch

Gated behind env var FOUNDATION_AUDITOR_ORG_SELECTOR_ENABLED (default off). When off, behavior is byte-for-byte today's. Turning it on is a capacity decision: on a cold 30s cache, every org-selector caller (including the ~99% who audit no foundation) pays an enumerate-all-foundations + batched access-check, since auditor status lives in FGA and can't be pre-filtered from the already-fetched b2b_org_settings. Mitigations: 30s per-username Valkey cache, per-username in-flight coalescing, explicit hard caps, chunked batches, and the M2M member-org fetch runs only when ≥1 foundation is audited.

Contract reference

project#auditor is a real, grantable FGA relation that cascades from parent — verified in linuxfoundation/lfx-v2-helm charts/lfx-platform/files/model.fga (define auditor: [user, team#member] or executive_director or writer or auditor from parent) and lfx-v2-project-service docs/fga-contract.md. This PR adds 'auditor' to AccessCheckAccessType; the /access-check endpoint resolves it against the OpenFGA model. No upstream change required.

Documented trade-offs / follow-ups

  • Test coverage: apps/lfx-one has no server-side unit-test runner (Vitest lives only in packages/shared), so FoundationAuditorOrgsService's I/O orchestration is not unit-tested. The riskiest pure logic (mergeFoundationAuditorOrgs) has a 7-case Vitest spec, and the render path is covered by e2e S10b. Structural repo constraint.
  • Zero-audit hot-path cost (above) — flag-gated; confirm cold-cache latency before enabling in prod.
  • Lens read enforcement out of scope: /api/orgs/:orgUid/lens/* READ routes are already unenforced for everyone today (any authenticated user can read any org's lens by SFID; only writes are gated). A foundation auditor can therefore already view lens pages, and "nobody else gains anything" already holds. Per-org read-authz hardening across all lens routes is a separate ticket.
  • Permission-gate change: hasOrgSelectorAccess now opens the org-selector / Org-Overview surfaces to foundation-only auditors (behind the kill-switch).
  • per_page: mirrors the established, deployed query-service read pattern used across sibling services; not changed here.

Validation

yarn check-types, yarn lint, yarn format, yarn build all pass. New foundation-auditor.utils.spec.ts passes (7/7). Note: root yarn test also runs apps/lfx-one ng test (no runner configured — pre-existing failure, not introduced here) and one pre-existing broken shared spec (meeting-privacy.utils.spec.ts, missing its vitest import) — both independent of this branch.

🤖 Generated with Claude Code

…XV2-2750)

Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org>
…ads (LFXV2-2750)

Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org>
… auditors (LFXV2-2750)

Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 18, 2026 04:09
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 84431db8-5b9d-41c2-a2ee-cadb2343effc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/LFXV2-2750-foundation-auditor-org-selector

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds flag-gated foundation-auditor organization access to the org selector.

Changes:

  • Resolves audited foundations and their active member organizations.
  • Adds view-only foundation-auditor personas to API and UI state.
  • Adds merge unit tests and selector E2E coverage.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/shared/src/utils/index.ts Exports the merge utility.
packages/shared/src/utils/foundation-auditor.utils.ts Merges auditor organizations into grants.
packages/shared/src/utils/foundation-auditor.utils.spec.ts Tests merge behavior.
packages/shared/src/interfaces/org-selector.interface.ts Adds foundation-auditor contracts.
packages/shared/src/interfaces/access-check.interface.ts Adds auditor access checks.
packages/shared/src/constants/org-selector.constants.ts Defines limits and concurrency settings.
apps/lfx-one/src/server/services/org-role-grants.service.ts Integrates foundation organizations into grants.
apps/lfx-one/src/server/services/foundation-auditor-orgs.service.ts Discovers foundations and member organizations.
apps/lfx-one/src/app/shared/services/org-role-grants.service.ts Stores foundation-auditor personas.
apps/lfx-one/src/app/shared/services/account-context.service.ts Enables selector access for foundation auditors.
apps/lfx-one/src/app/shared/components/org-selector/org-selector.component.ts Renders the view-only persona.
apps/lfx-one/e2e/org-selector.spec.ts Tests foundation-auditor row presentation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}
};
const poolSize = Math.min(FOUNDATION_AUDITOR_MEMBERSHIP_FETCH_CONCURRENCY, safeFoundations.length);
await Promise.all(Array.from({ length: poolSize }, () => worker()));
Comment on lines +157 to +161
const membershipsByIndex: ProjectMembershipDoc[][] = new Array(safeFoundations.length);
let cursor = 0;
const worker = async (): Promise<void> => {
while (cursor < safeFoundations.length) {
const index = cursor++;

/** Foundation-lens enumeration (mirrors NavigationService's foundation query), paged to completion (fail-hard), then capped. */
private async enumerateFoundations(req: Request): Promise<AuditedFoundation[]> {
const projects = await fetchAllQueryResources<Project>(
// Direct/inherited grants always win — never override an existing resolved row.
if (resolved.has(entry.uid)) continue;
// Bound only the additive rows; never evict a base row to make room.
if (resolved.size >= cap) {
…FXV2-2750)

Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org>
…sters (LFXV2-2750)

Replaces the eager enumerate-everything design, which deep-paginated every
audited foundation's roster and hung the dropdown for minutes on a
broad-access caller.

The lookup is now foundation-scoped and strictly bounded: enumerate
foundations (a small set), batch access-check project:<uid>#auditor on the
user token, then pull only the first roster page per audited foundation via
a bounded-concurrency pool, capped and memoized per caller. Rows carry
roleSource so the client renders them view-only without a precomputed uid
set, and /api/orgs/me/role-grants reverts to grants-only.

Verified against prod data: project:<uid>#auditor resolves correctly
(50/50 foundations audited), M2M verify-then-elevate and token restore work,
and the flag-off path is a clean no-op.

Known blocker, documented in the service: the query-service index cannot
list a foundation's member orgs. b2b_org is not name-searchable, and
project_membership is only queryable by b2b_org_uid tag -- there is no
project_uid/project_slug tag to pivot foundation -> members. The feature
stays behind FOUNDATION_AUDITOR_ORG_SELECTOR_ENABLED (default off) until an
upstream indexer change lands.

Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 18, 2026 21:36
@manishdixitlfx

Copy link
Copy Markdown
Contributor Author

Status update: blocked upstream (LFXV2-2752)

Live-tested against prod data on 2026-07-18. The authorization half works; the data half is blocked by the query-service index.

What's proven working ✅

  • project:<uid>#auditor resolves correctly — 50/50 foundations audited for the test caller. The auditor relation on project (confirmed in lfx-v2-helm model.fga, cascades from parent) is the right contract; no FGA model change needed.
  • M2M verify-then-elevate: authorization decided on the user token, elevation scoped to display reads only, token restored in finally.
  • Batched/chunked access-checks, per-caller memoization, kill-switch, fail-soft fallback — all validated, ~1s.
  • Clean A/B with the flag off: dropdown loads instantly with unchanged grants-only behavior. No regression.

What's blocked ❌

There is no way to list a foundation's member orgs from the query service. Both directions verified empty against prod:

Approach Result
type=b2b_org&name=… (find orgs by name) returns 0 even for linux, while The Linux Foundation resolves fine by b2b_org_uid tag
type=project_membership by project_slug/project_uid (foundation → members) returns 0 across all 50 foundations
type=project_membership by b2b_org_uid: tag (org → memberships) works — but it's the inverse of what's needed

project_slug only works as a refinement alongside the b2b_org_uid tag (see org-membership-resolver.service.ts:143-145); there is no foundation-pivot tag.

Filed LFXV2-2752 for the upstream capability (a project_uid:/project_slug: tag on project_membership, or name-search on b2b_org). When that lands, only the roster query in foundation-auditor-orgs.service.ts changes — the rest of this branch is ready.

Design history (why the current shape)

  1. Eager, fully paginated — deep-paginated every audited foundation's roster; hung the dropdown for minutes (page 12+, query-service 500s) on a broad-access caller.
  2. Name-search driven — inverted to scale with the search term instead of access breadth; dead-ended on b2b_org not being name-searchable.
  3. Current — foundation-scoped, first roster page only, bounded concurrency, capped, memoized.

Recommendation

Keep this draft with FOUNDATION_AUDITOR_ORG_SELECTOR_ENABLED defaulting off until LFXV2-2752 lands. Flag-off is a verified no-op, so the branch is safe to carry.

Known limits (documented in the service)

  • Only the first roster page per foundation, so a very large foundation's membership would be truncated.
  • The sidebar visibility gate is unchanged, so a caller whose only access is a foundation-auditor grant (no direct org grant, no persona seed) still won't see the selector — per the agreed option-2 scope.
  • FoundationAuditorOrgsService has no unit spec (apps/lfx-one has no server test runner); the pure merge logic is covered in packages/shared.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Comment on lines +74 to +77
* LFXV2-2750 note: foundation-auditor rows are resolved per-search, so foundation-level auditor
* status is not knowable up front (there is no per-user auditor index on projects). A caller whose
* ONLY access is a foundation-auditor grant — no direct org grant and no persona seed — therefore
* won't see the selector. Tracked as a follow-up.
Comment on lines +93 to +97
public async findAuditedMemberOrgs(req: Request, username: string, searchTerm: string | undefined): Promise<FoundationAuditorOrgEntry[]> {
const term = searchTerm?.trim().toLowerCase() ?? '';
if (term.length < FOUNDATION_AUDITOR_SEARCH_MIN_TERM_LENGTH) {
logger.debug(req, 'find_audited_member_orgs', 'Skipped — search term below minimum length', { term_length: term.length });
return [];
Comment on lines +111 to +118
/** Resolve (and memoize) the caller's full audited member-org pool. */
private async resolveMemberOrgs(req: Request, username: string): Promise<FoundationAuditorOrgEntry[]> {
const memo = FoundationAuditorOrgsService.memberOrgsMemo.get(username);
if (memo && memo.expiresAt > Date.now()) {
return memo.orgs;
}

const foundations = await this.enumerateFoundations(req);
Comment on lines +84 to +86
// LFXV2-2750 — foundation-auditor rows are resolved per-search and carry their role source on the row
// itself (they're absent from the cached grants sets), so the row wins when present.
const persona = item.roleSource ?? this.resolvePersona(item.uid, writerSet, auditorSet, inheritedWriterSet, inheritedAuditorSet);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants