From c61948f276487a3eff324d6231fa1f39812f73b8 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Wed, 10 Jun 2026 20:11:43 +0100 Subject: [PATCH 01/13] docs(architecture): document username vs sub identity claims (LFXV2-2192) Add Identity Claims section to authentication.md covering the username/sub distinction, when to use each, server-side helpers, and the LFXV2-1962 migration. Update impersonation.md helpers table with Notes column and a cross-reference to the new section. Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 42 +++++++++++++++++++++ docs/architecture/backend/impersonation.md | 12 +++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index eb5091693..88d03aea0 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -78,6 +78,48 @@ export interface M2MTokenResponse { } ``` +## 🆔 Identity Claims: `username` vs `sub` + +Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing the wrong one breaks upstream lookups. They are **not** interchangeable. + +### What each one is + +| Claim | Example | Shape | Source claim(s) | +| ---------------------------------------------------- | ----------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| **`sub`** (Auth0 subject) | `auth0\|lguerra` | Provider-prefixed, opaque, globally unique per identity | `user.sub` | +| **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username` | + +- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). +- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and what upstream microservices index on. For example, the member-service `b2b_org_settings` index tags each doc with `member:` (the query-service matches on the `tags` param; the legacy `writers.username:` filter form matches nothing), and the caller's role is read from `data.members[].username` (legacy fallback: `data.writers[]` / `data.auditors[]`). Survey `creator_id` is likewise persisted as the bare username. + +### When to use which + +| Use case | Use | +| ------------------------------------------------------------------------------------- | ------------ | +| Calling an upstream microservice / query-service API that keys on the LF login handle | **username** | +| Persisting an author/owner/creator (`creator_id`, role grants, changelog viewer) | **username** | +| Analytics / observability user identity (DataDog RUM, OpenFeature targeting key) | **username** | +| Per-caller cache keys for user-scoped data | **username** | +| Anything that must match an Auth0 identity record exactly (rare, provider-specific) | **sub** | + +> **Default to `username`.** `sub` is being phased out of backend identity references — see the migration note below. + +### Server-side helpers (impersonation-aware) + +Read identity through the helpers in `apps/lfx-one/src/server/utils/auth-helper.ts`, never directly off `req.oidc.user`. They transparently return the **target** user's identity during impersonation and the session user's otherwise. + +| Helper | Returns | Status | +| --------------------------- | --------------------------------------------- | ------------------------------------------------------------ | +| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username | **Preferred** for all new identity references | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **Deprecated** — only for call sites whose upstream still wants the prefixed sub | +| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups | + +### Migration: `sub` → `username` (LFXV2-1962) + +Backend identity references are migrating from the Auth0 `sub` to the LFID `username`. As upstream handlers learn to accept the username, call sites flip from `getEffectiveSub` to `getEffectiveUsername`, and front-end identity references (DataDog RUM `id`, OpenFeature `targetingKey`, survey `creator_id`) use the `https://sso.linuxfoundation.org/claims/username` claim instead of `sub`. + +`getEffectiveSub` remains as a fallback for the migration window and is marked `@deprecated`. When adding new code, use `username` unless the specific upstream handler still requires the prefixed sub — and if so, note why inline. + ## 🏗 Server-Side Implementation ### Auth Context Injection diff --git a/docs/architecture/backend/impersonation.md b/docs/architecture/backend/impersonation.md index 8f600f6ec..24bfb2a25 100644 --- a/docs/architecture/backend/impersonation.md +++ b/docs/architecture/backend/impersonation.md @@ -158,11 +158,13 @@ This is the single choke point — every controller and service uses `req.bearer Many controllers and services read the user's email/username from `req.oidc.user` for server-side filtering (e.g., "get my meetings"). During impersonation, `req.oidc.user` is still the real user. Three helpers resolve the correct identity: -| Helper | Returns | -| --------------------------- | --------------------------------------------- | -| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | -| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname | -| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | +| Helper | Returns | Notes | +| --------------------------- | ----------------------------------------------- | ------------------------------------------------------------------- | +| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | Email-keyed lookups | +| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username | **Preferred** for identity references (LFID username, e.g. `lguerra`) | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **Deprecated** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); migrating to username | + +For the full `username` vs `sub` distinction and the LFXV2-1962 migration, see [`authentication.md`](./authentication.md#-identity-claims-username-vs-sub). These check `req.appSession['impersonationUser']` first, falling back to `req.oidc.user`. All controllers/services that filter by user identity use these helpers (meetings, events, committees, votes, surveys, mailing lists, documents, analytics, badges, persona detection). From ea7e4b34603a4dd07661eff3a02e4fb2ac840958 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Wed, 10 Jun 2026 20:32:37 +0100 Subject: [PATCH 02/13] docs(architecture): fix prettier table formatting (LFXV2-2192) Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 18 +++++++++--------- docs/architecture/backend/impersonation.md | 10 +++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index 88d03aea0..df0a03585 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -84,10 +84,10 @@ Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing ### What each one is -| Claim | Example | Shape | Source claim(s) | -| ---------------------------------------------------- | ----------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| **`sub`** (Auth0 subject) | `auth0\|lguerra` | Provider-prefixed, opaque, globally unique per identity | `user.sub` | -| **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username` | +| Claim | Example | Shape | Source claim(s) | +| ------------------------------ | ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| **`sub`** (Auth0 subject) | `auth0\|lguerra` | Provider-prefixed, opaque, globally unique per identity | `user.sub` | +| **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username` | - **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). - **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and what upstream microservices index on. For example, the member-service `b2b_org_settings` index tags each doc with `member:` (the query-service matches on the `tags` param; the legacy `writers.username:` filter form matches nothing), and the caller's role is read from `data.members[].username` (legacy fallback: `data.writers[]` / `data.auditors[]`). Survey `creator_id` is likewise persisted as the bare username. @@ -108,11 +108,11 @@ Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing Read identity through the helpers in `apps/lfx-one/src/server/utils/auth-helper.ts`, never directly off `req.oidc.user`. They transparently return the **target** user's identity during impersonation and the session user's otherwise. -| Helper | Returns | Status | -| --------------------------- | --------------------------------------------- | ------------------------------------------------------------ | -| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username | **Preferred** for all new identity references | -| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **Deprecated** — only for call sites whose upstream still wants the prefixed sub | -| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups | +| Helper | Returns | Status | +| --------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------- | +| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username | **Preferred** for all new identity references | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **Deprecated** — only for call sites whose upstream still wants the prefixed sub | +| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups | ### Migration: `sub` → `username` (LFXV2-1962) diff --git a/docs/architecture/backend/impersonation.md b/docs/architecture/backend/impersonation.md index 24bfb2a25..61240ba3c 100644 --- a/docs/architecture/backend/impersonation.md +++ b/docs/architecture/backend/impersonation.md @@ -158,11 +158,11 @@ This is the single choke point — every controller and service uses `req.bearer Many controllers and services read the user's email/username from `req.oidc.user` for server-side filtering (e.g., "get my meetings"). During impersonation, `req.oidc.user` is still the real user. Three helpers resolve the correct identity: -| Helper | Returns | Notes | -| --------------------------- | ----------------------------------------------- | ------------------------------------------------------------------- | -| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | Email-keyed lookups | -| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username | **Preferred** for identity references (LFID username, e.g. `lguerra`) | -| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **Deprecated** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); migrating to username | +| Helper | Returns | Notes | +| --------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- | +| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | Email-keyed lookups | +| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username | **Preferred** for identity references (LFID username, e.g. `lguerra`) | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **Deprecated** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); migrating to username | For the full `username` vs `sub` distinction and the LFXV2-1962 migration, see [`authentication.md`](./authentication.md#-identity-claims-username-vs-sub). From 01dae13955dfedcf6dd0e3e475d9da7e3fa5f05d Mon Sep 17 00:00:00 2001 From: Audi Young Date: Wed, 10 Jun 2026 20:41:41 +0100 Subject: [PATCH 03/13] docs(review): align identity claims doc with current code (LFXV2-2192) Correct three present-tense claims that outran the LFXV2-1962 migration: creator_id stores the sub (bare username is creator_username), front-end refs still read sub/preferred_username, and getEffectiveSub is not yet annotated @deprecated. Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index df0a03585..2ea478831 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -90,7 +90,7 @@ Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing | **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username` | - **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). -- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and what upstream microservices index on. For example, the member-service `b2b_org_settings` index tags each doc with `member:` (the query-service matches on the `tags` param; the legacy `writers.username:` filter form matches nothing), and the caller's role is read from `data.members[].username` (legacy fallback: `data.writers[]` / `data.auditors[]`). Survey `creator_id` is likewise persisted as the bare username. +- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and what upstream microservices index on. For example, the member-service `b2b_org_settings` index tags each doc with `member:` (the query-service matches on the `tags` param; the legacy `writers.username:` filter form matches nothing), and the caller's role is read from `data.members[].username` (legacy fallback: `data.writers[]` / `data.auditors[]`). On surveys the bare username is persisted as `creator_username`, while the sibling `creator_id` currently stores the `sub` (migrating to username under LFXV2-1962). ### When to use which @@ -116,9 +116,9 @@ Read identity through the helpers in `apps/lfx-one/src/server/utils/auth-helper. ### Migration: `sub` → `username` (LFXV2-1962) -Backend identity references are migrating from the Auth0 `sub` to the LFID `username`. As upstream handlers learn to accept the username, call sites flip from `getEffectiveSub` to `getEffectiveUsername`, and front-end identity references (DataDog RUM `id`, OpenFeature `targetingKey`, survey `creator_id`) use the `https://sso.linuxfoundation.org/claims/username` claim instead of `sub`. +Backend identity references are migrating from the Auth0 `sub` to the LFID `username`. As upstream handlers learn to accept the username, call sites flip from `getEffectiveSub` to `getEffectiveUsername`, and front-end identity references (DataDog RUM `id`, OpenFeature `targetingKey`, survey `creator_id`) will move to the `https://sso.linuxfoundation.org/claims/username` claim instead of `sub` (today they still read `sub` / `preferred_username`). -`getEffectiveSub` remains as a fallback for the migration window and is marked `@deprecated`. When adding new code, use `username` unless the specific upstream handler still requires the prefixed sub — and if so, note why inline. +`getEffectiveSub` remains as a fallback for the migration window and should be treated as deprecated (annotate it `@deprecated` in `auth-helper.ts` as the migration lands). When adding new code, use `username` unless the specific upstream handler still requires the prefixed sub — and if so, note why inline. ## 🏗 Server-Side Implementation From 0e979d32afbac5aefba69eb563218036a7e0fc34 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Wed, 10 Jun 2026 20:48:29 +0100 Subject: [PATCH 04/13] docs(review): clarify b2b_org_settings keys on prefixed sub (LFXV2-2192) Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index 2ea478831..0fa9cc46a 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -89,8 +89,8 @@ Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing | **`sub`** (Auth0 subject) | `auth0\|lguerra` | Provider-prefixed, opaque, globally unique per identity | `user.sub` | | **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username` | -- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). -- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and what upstream microservices index on. For example, the member-service `b2b_org_settings` index tags each doc with `member:` (the query-service matches on the `tags` param; the legacy `writers.username:` filter form matches nothing), and the caller's role is read from `data.members[].username` (legacy fallback: `data.writers[]` / `data.auditors[]`). On surveys the bare username is persisted as `creator_username`, while the sibling `creator_id` currently stores the `sub` (migrating to username under LFXV2-1962). +- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). Some upstream paths still key on the **prefixed `sub`** during the migration window: the member-service `b2b_org_settings` index tags each doc with `member:auth0|` (and `writers.username:auth0|`) and stores the caller's role under `data.writers[].username` in the same prefixed form, so org role lookups resolve identity via `getEffectiveSub` (see `org-identity.controller.ts` / `org-navigation.service.ts`) — the bare nickname form misses every row there. +- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. For example, on surveys the bare username is persisted as `creator_username`, while the sibling `creator_id` currently stores the `sub` (migrating to username under LFXV2-1962). ### When to use which From 1cc47fa66ccc6943fd08797ab8fe46a90fcd4786 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Fri, 12 Jun 2026 13:17:59 +0100 Subject: [PATCH 05/13] docs(architecture): explain id token vs access token in sub migration (LFXV2-2192) Add a subsection distinguishing the ID token (req.oidc.user) from the access token (forwarded upstream), since the username claim is namespaced differently in each and sub is the only shared key. Covers the two migration tracks, the impersonation namespace-bridging example, and the getUsernameFromAuth sub-fallback landmine. Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index 0fa9cc46a..4bd2a4fd0 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -92,6 +92,36 @@ Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing - **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). Some upstream paths still key on the **prefixed `sub`** during the migration window: the member-service `b2b_org_settings` index tags each doc with `member:auth0|` (and `writers.username:auth0|`) and stores the caller's role under `data.writers[].username` in the same prefixed form, so org role lookups resolve identity via `getEffectiveSub` (see `org-identity.controller.ts` / `org-navigation.service.ts`) — the bare nickname form misses every row there. - **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. For example, on surveys the bare username is persisted as `creator_username`, while the sibling `creator_id` currently stores the `sub` (migrating to username under LFXV2-1962). +### ID token vs access token — where the claims actually live + +Auth0 issues **two** JWTs per session, and they carry identity differently. This split is the central complication of the `sub` → `username` migration. + +| | **ID token** | **Access token** | +| -------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Lives on | `req.oidc.user` (typed as `User`) | `req.oidc.accessToken.access_token`, decoded via `decodeJwtPayload()` into `LfxAccessTokenClaims` | +| `sub` | `user.sub` | `claims.sub` | +| username claim | `https://sso.linuxfoundation.org/claims/username` (plus `nickname` / `username` / `preferred_username`) | `http://lfx.dev/claims/username` — **different namespace** | +| Consumed by | The BFF only (SSR, analytics, persistence, the `getEffective*` helpers) | Forwarded upstream as `Authorization: Bearer` to the Go microservices | + +Two consequences the migration depends on: + +- **`sub` is the only identifier present in both tokens under the same key.** The username claim is namespaced differently in each (`https://sso.linuxfoundation.org/...` in the ID token vs `http://lfx.dev/...` in the access token), so any code bridging the two must map between namespaces — they are not the same key. +- **Upstream microservices only ever see the access token.** `req.bearerToken` is the access token (refreshed in `auth.middleware.ts`, forwarded by `microservice-proxy.service.ts`); the ID token never leaves the BFF. Upstream authorizes and indexes off the access token's `sub` / `http://lfx.dev/claims/username` — never the value `getEffectiveUsername` returns. So the migration runs on two tracks: (1) **upstream matching** — what the Go services key on, driven by the access token's claims and Auth0 token config, largely outside this repo; and (2) **BFF-side identity** — the `getEffectiveSub` → `getEffectiveUsername` call-site flips and the front-end claim swap, which read the ID token and are fully in this repo. + +**Worked example — impersonation bridges the namespaces by hand.** Impersonation discards the target's ID token and rebuilds identity entirely from the exchanged **access token**, copying its `http://lfx.dev/claims/username` into every ID-token username slot so both namespaces resolve to the same handle (`server.ts`): + +```ts +Object.assign(auth.user, { + sub: targetClaims.sub, + username: targetClaims['http://lfx.dev/claims/username'] || '', + 'https://sso.linuxfoundation.org/claims/username': targetClaims['http://lfx.dev/claims/username'] || '', + nickname: targetClaims['http://lfx.dev/claims/username'] || '', + // ... +}); +``` + +> **Migration landmine.** `getUsernameFromAuth()` in `auth-helper.ts` is misleadingly named: for Authelia tokens it returns `preferred_username`, but for normal Auth0 tokens it falls back to `getEffectiveSub(req)` — i.e. it returns the prefixed `sub`, not a username. It is a concrete `sub`-as-username site to fix under LFXV2-1962. + ### When to use which | Use case | Use | From 9f6008cfe257d0ab012030ab74d0c1f0552f0ba5 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Fri, 12 Jun 2026 13:35:33 +0100 Subject: [PATCH 06/13] docs(architecture): soften absolute access-token claim per review (LFXV2-2192) Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index 4bd2a4fd0..bcd5c0d1a 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -106,7 +106,7 @@ Auth0 issues **two** JWTs per session, and they carry identity differently. This Two consequences the migration depends on: - **`sub` is the only identifier present in both tokens under the same key.** The username claim is namespaced differently in each (`https://sso.linuxfoundation.org/...` in the ID token vs `http://lfx.dev/...` in the access token), so any code bridging the two must map between namespaces — they are not the same key. -- **Upstream microservices only ever see the access token.** `req.bearerToken` is the access token (refreshed in `auth.middleware.ts`, forwarded by `microservice-proxy.service.ts`); the ID token never leaves the BFF. Upstream authorizes and indexes off the access token's `sub` / `http://lfx.dev/claims/username` — never the value `getEffectiveUsername` returns. So the migration runs on two tracks: (1) **upstream matching** — what the Go services key on, driven by the access token's claims and Auth0 token config, largely outside this repo; and (2) **BFF-side identity** — the `getEffectiveSub` → `getEffectiveUsername` call-site flips and the front-end claim swap, which read the ID token and are fully in this repo. +- **The access token is the only _token_ the BFF forwards upstream; the ID token never leaves the BFF.** `req.bearerToken` is the access token (refreshed in `auth.middleware.ts`, forwarded by `microservice-proxy.service.ts`). Upstream authorizes and matches claim-based identity off the access token's `sub` / `http://lfx.dev/claims/username` — never off the ID token, and never off the value `getEffectiveUsername` returns. (Upstream _can_ still see a username outside the JWT when the BFF passes one explicitly in a request param or payload — e.g. the bare `creator_username` persisted on surveys, or a NATS RPC payload — so any LFID handle a Go service needs must be sent on the wire deliberately, not assumed present in the token claims.) So the migration runs on two tracks: (1) **upstream matching** — what the Go services key on, driven by the access token's claims and Auth0 token config, largely outside this repo; and (2) **BFF-side identity** — the `getEffectiveSub` → `getEffectiveUsername` call-site flips and the front-end claim swap, which read the ID token and are fully in this repo. **Worked example — impersonation bridges the namespaces by hand.** Impersonation discards the target's ID token and rebuilds identity entirely from the exchanged **access token**, copying its `http://lfx.dev/claims/username` into every ID-token username slot so both namespaces resolve to the same handle (`server.ts`): From 769b3d8977a4586a05af821eb816c3e954f0ba69 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Fri, 12 Jun 2026 19:20:12 +0100 Subject: [PATCH 07/13] docs(architecture): align identity docs with LFXV2-2122 merge (LFXV2-2192) Update authentication, impersonation, and feature-flags docs to reflect post-#912 state: username targetingKey, migrated call sites, and remaining getEffectiveSub usage. Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 20 ++++++++++++-------- docs/architecture/backend/impersonation.md | 4 ++-- docs/architecture/frontend/feature-flags.md | 20 +++++++++++--------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index bcd5c0d1a..5fc6527de 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -89,8 +89,8 @@ Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing | **`sub`** (Auth0 subject) | `auth0\|lguerra` | Provider-prefixed, opaque, globally unique per identity | `user.sub` | | **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username` | -- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). Some upstream paths still key on the **prefixed `sub`** during the migration window: the member-service `b2b_org_settings` index tags each doc with `member:auth0|` (and `writers.username:auth0|`) and stores the caller's role under `data.writers[].username` in the same prefixed form, so org role lookups resolve identity via `getEffectiveSub` (see `org-identity.controller.ts` / `org-navigation.service.ts`) — the bare nickname form misses every row there. -- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. For example, on surveys the bare username is persisted as `creator_username`, while the sibling `creator_id` currently stores the `sub` (migrating to username under LFXV2-1962). +- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). A few call sites still pass the prefixed `sub` upstream during the migration window — e.g. `badges.controller.ts` resolves verified emails via the auth-service using `getEffectiveSub(req)`. +- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. Org role grants (`org-identity.controller.ts`, `org-navigation.service.ts`, `org-role-grants.service.ts`) query `b2b_org_settings` with `tags: ['member:${username}']` where `username` comes from `getEffectiveUsername(req)`. On surveys, `creator_username` holds the bare nickname and `creator_id` is set from the `https://sso.linuxfoundation.org/claims/username` claim (LFXV2-2122). ### ID token vs access token — where the claims actually live @@ -120,7 +120,7 @@ Object.assign(auth.user, { }); ``` -> **Migration landmine.** `getUsernameFromAuth()` in `auth-helper.ts` is misleadingly named: for Authelia tokens it returns `preferred_username`, but for normal Auth0 tokens it falls back to `getEffectiveSub(req)` — i.e. it returns the prefixed `sub`, not a username. It is a concrete `sub`-as-username site to fix under LFXV2-1962. +> **`getUsernameFromAuth()` naming.** For Authelia tokens it returns `preferred_username`; for Auth0 tokens it falls back to `getEffectiveUsername(req)` (LFXV2-2122). The name is still easy to misread — prefer `getEffectiveUsername` directly when you need the LFID handle. ### When to use which @@ -140,15 +140,19 @@ Read identity through the helpers in `apps/lfx-one/src/server/utils/auth-helper. | Helper | Returns | Status | | --------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------- | -| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username | **Preferred** for all new identity references | -| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **Deprecated** — only for call sites whose upstream still wants the prefixed sub | +| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for all new identity references | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — only for call sites whose upstream still wants the prefixed sub | | `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups | -### Migration: `sub` → `username` (LFXV2-1962) +### Migration: `sub` → `username` (LFXV2-1962 / LFXV2-2122) -Backend identity references are migrating from the Auth0 `sub` to the LFID `username`. As upstream handlers learn to accept the username, call sites flip from `getEffectiveSub` to `getEffectiveUsername`, and front-end identity references (DataDog RUM `id`, OpenFeature `targetingKey`, survey `creator_id`) will move to the `https://sso.linuxfoundation.org/claims/username` claim instead of `sub` (today they still read `sub` / `preferred_username`). +Backend identity references are migrating from the Auth0 `sub` to the LFID `username`. **LFXV2-2122** (merged in [#912](https://github.com/linuxfoundation/lfx-self-serve/pull/912)) flipped the first wave in this repo: -`getEffectiveSub` remains as a fallback for the migration window and should be treated as deprecated (annotate it `@deprecated` in `auth-helper.ts` as the migration lands). When adding new code, use `username` unless the specific upstream handler still requires the prefixed sub — and if so, note why inline. +- **Front-end (ID token):** DataDog RUM `id`, OpenFeature `targetingKey`, and survey `creator_id` now read `https://sso.linuxfoundation.org/claims/username` (OpenFeature no longer falls back to `sub` — existing LaunchDarkly rules keyed on sub values need updating before deploy). +- **BFF call sites:** `getEffectiveSub` → `getEffectiveUsername` in changelog, copilot, org-identity, org-navigation, org-lens-access, and org-membership cache keys; `project.service.ts` uses `resolveEmailToUsername` (not `resolveEmailToSub`) for permission and user-info lookups against the plain-LFID `b2b_org_settings` index. +- **`getEffectiveSub`** is annotated `@deprecated` in `auth-helper.ts` and remains only where upstream still requires the prefixed sub (e.g. badges email lookup via auth-service). + +When adding new code, use `username` unless the specific upstream handler still requires the prefixed sub — and if so, note why inline. ## 🏗 Server-Side Implementation diff --git a/docs/architecture/backend/impersonation.md b/docs/architecture/backend/impersonation.md index 61240ba3c..dcae3b3dc 100644 --- a/docs/architecture/backend/impersonation.md +++ b/docs/architecture/backend/impersonation.md @@ -161,8 +161,8 @@ Many controllers and services read the user's email/username from `req.oidc.user | Helper | Returns | Notes | | --------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- | | `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | Email-keyed lookups | -| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username | **Preferred** for identity references (LFID username, e.g. `lguerra`) | -| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **Deprecated** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); migrating to username | +| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for identity references (LFID username, e.g. `lguerra`) | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); rare remaining sites | For the full `username` vs `sub` distinction and the LFXV2-1962 migration, see [`authentication.md`](./authentication.md#-identity-claims-username-vs-sub). diff --git a/docs/architecture/frontend/feature-flags.md b/docs/architecture/frontend/feature-flags.md index 6e9f2f7bf..713903efc 100644 --- a/docs/architecture/frontend/feature-flags.md +++ b/docs/architecture/frontend/feature-flags.md @@ -154,7 +154,7 @@ export class FeatureFlagService { kind: 'user', name: user.name || '', email: user.email || '', - targetingKey: user.preferred_username || user.username || user.sub || '', + targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username'], }; await OpenFeature.setContext(userContext); @@ -752,16 +752,18 @@ const userContext: EvaluationContext = { kind: 'user', name: user.name || '', email: user.email || '', - targetingKey: user.preferred_username || user.username || user.sub || '', + targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username'], }; ``` +> **Identity (LFXV2-2122).** `targetingKey` uses the LFID username claim chain above — not Auth0 `sub`. LaunchDarkly rules keyed on legacy `sub` values stop matching after deploy; update them to LFID usernames. See [Authentication — Identity Claims](../backend/authentication.md#-identity-claims-username-vs-sub). + **Context Structure:** -| Field | Type | Required | Description | -| ----------------- | ------ | -------- | ----------------------------------- | -| `kind` | string | Yes | Always 'user' for user contexts | -| `targetingKey` | string | Yes | Unique identifier for the user | +| Field | Type | Required | Description | +| ----------------- | ------ | -------- | ------------------------------------------------------------------- | +| `kind` | string | Yes | Always 'user' for user contexts | +| `targetingKey` | string | Yes | LFID username (`preferred_username` → `username` → SSO username claim) | | `name` | string | No | Display name for LaunchDarkly UI | | `email` | string | No | Email for targeting rules | | Custom attributes | any | No | Additional attributes for targeting | @@ -778,7 +780,7 @@ const userContext: EvaluationContext = { ```typescript const userContext: EvaluationContext = { kind: 'user', - targetingKey: user.sub, + targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username'], name: user.name, email: user.email, // Custom attributes for targeting @@ -1448,8 +1450,8 @@ See [Runtime Configuration Troubleshooting](../../runtime-configuration.md#troub 1. **Missing Targeting Key:** ```typescript - // targetingKey is required for user identification - targetingKey: user.preferred_username || user.username || user.sub || ''; + // targetingKey is required — LFID username, not Auth0 sub + targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username']; ``` 2. **Incorrect Attribute Names:** From 948ffcd4794b74dfe12fe4b03ec95827bfab77b3 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Fri, 12 Jun 2026 19:24:09 +0100 Subject: [PATCH 08/13] style(docs): fix prettier formatting on identity docs (LFXV2-2192) Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 10 +++++----- docs/architecture/backend/impersonation.md | 10 +++++----- docs/architecture/frontend/feature-flags.md | 12 ++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index 5fc6527de..eeae9d752 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -138,11 +138,11 @@ Object.assign(auth.user, { Read identity through the helpers in `apps/lfx-one/src/server/utils/auth-helper.ts`, never directly off `req.oidc.user`. They transparently return the **target** user's identity during impersonation and the session user's otherwise. -| Helper | Returns | Status | -| --------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------- | -| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for all new identity references | -| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — only for call sites whose upstream still wants the prefixed sub | -| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups | +| Helper | Returns | Status | +| --------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for all new identity references | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — only for call sites whose upstream still wants the prefixed sub | +| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups | ### Migration: `sub` → `username` (LFXV2-1962 / LFXV2-2122) diff --git a/docs/architecture/backend/impersonation.md b/docs/architecture/backend/impersonation.md index dcae3b3dc..af1e6af01 100644 --- a/docs/architecture/backend/impersonation.md +++ b/docs/architecture/backend/impersonation.md @@ -158,11 +158,11 @@ This is the single choke point — every controller and service uses `req.bearer Many controllers and services read the user's email/username from `req.oidc.user` for server-side filtering (e.g., "get my meetings"). During impersonation, `req.oidc.user` is still the real user. Three helpers resolve the correct identity: -| Helper | Returns | Notes | -| --------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- | -| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | Email-keyed lookups | -| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for identity references (LFID username, e.g. `lguerra`) | -| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); rare remaining sites | +| Helper | Returns | Notes | +| --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | Email-keyed lookups | +| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for identity references (LFID username, e.g. `lguerra`) | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); rare remaining sites | For the full `username` vs `sub` distinction and the LFXV2-1962 migration, see [`authentication.md`](./authentication.md#-identity-claims-username-vs-sub). diff --git a/docs/architecture/frontend/feature-flags.md b/docs/architecture/frontend/feature-flags.md index 713903efc..e10e09c48 100644 --- a/docs/architecture/frontend/feature-flags.md +++ b/docs/architecture/frontend/feature-flags.md @@ -760,13 +760,13 @@ const userContext: EvaluationContext = { **Context Structure:** -| Field | Type | Required | Description | -| ----------------- | ------ | -------- | ------------------------------------------------------------------- | -| `kind` | string | Yes | Always 'user' for user contexts | +| Field | Type | Required | Description | +| ----------------- | ------ | -------- | ---------------------------------------------------------------------- | +| `kind` | string | Yes | Always 'user' for user contexts | | `targetingKey` | string | Yes | LFID username (`preferred_username` → `username` → SSO username claim) | -| `name` | string | No | Display name for LaunchDarkly UI | -| `email` | string | No | Email for targeting rules | -| Custom attributes | any | No | Additional attributes for targeting | +| `name` | string | No | Display name for LaunchDarkly UI | +| `email` | string | No | Email for targeting rules | +| Custom attributes | any | No | Additional attributes for targeting | **Targeting Use Cases:** From 126d354beedfdf761bfe13864402dba877f8fec3 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Fri, 12 Jun 2026 19:44:51 +0100 Subject: [PATCH 09/13] docs(architecture): add preferred_username to username source claims (LFXV2-2192) Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index eeae9d752..acc521478 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -84,10 +84,10 @@ Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing ### What each one is -| Claim | Example | Shape | Source claim(s) | -| ------------------------------ | ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| **`sub`** (Auth0 subject) | `auth0\|lguerra` | Provider-prefixed, opaque, globally unique per identity | `user.sub` | -| **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username` | +| Claim | Example | Shape | Source claim(s) | +| ------------------------------ | ---------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| **`sub`** (Auth0 subject) | `auth0\|lguerra` | Provider-prefixed, opaque, globally unique per identity | `user.sub` | +| **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username`, `user.preferred_username` | - **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). A few call sites still pass the prefixed `sub` upstream during the migration window — e.g. `badges.controller.ts` resolves verified emails via the auth-service using `getEffectiveSub(req)`. - **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. Org role grants (`org-identity.controller.ts`, `org-navigation.service.ts`, `org-role-grants.service.ts`) query `b2b_org_settings` with `tags: ['member:${username}']` where `username` comes from `getEffectiveUsername(req)`. On surveys, `creator_username` holds the bare nickname and `creator_id` is set from the `https://sso.linuxfoundation.org/claims/username` claim (LFXV2-2122). From 64502e2d79cb120d4f1f0b0bbbf39ee01d025277 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Mon, 15 Jun 2026 13:39:28 +0100 Subject: [PATCH 10/13] docs(architecture): remove ticket refs from identity docs (LFXV2-2192) Signed-off-by: Audi Young Co-authored-by: Cursor --- docs/architecture/backend/authentication.md | 8 ++++---- docs/architecture/backend/impersonation.md | 2 +- docs/architecture/frontend/feature-flags.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index acc521478..45a45334b 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -90,7 +90,7 @@ Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing | **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username`, `user.preferred_username` | - **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). A few call sites still pass the prefixed `sub` upstream during the migration window — e.g. `badges.controller.ts` resolves verified emails via the auth-service using `getEffectiveSub(req)`. -- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. Org role grants (`org-identity.controller.ts`, `org-navigation.service.ts`, `org-role-grants.service.ts`) query `b2b_org_settings` with `tags: ['member:${username}']` where `username` comes from `getEffectiveUsername(req)`. On surveys, `creator_username` holds the bare nickname and `creator_id` is set from the `https://sso.linuxfoundation.org/claims/username` claim (LFXV2-2122). +- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. Org role grants (`org-identity.controller.ts`, `org-navigation.service.ts`, `org-role-grants.service.ts`) query `b2b_org_settings` with `tags: ['member:${username}']` where `username` comes from `getEffectiveUsername(req)`. On surveys, `creator_username` holds the bare nickname and `creator_id` is set from the `https://sso.linuxfoundation.org/claims/username` claim. ### ID token vs access token — where the claims actually live @@ -120,7 +120,7 @@ Object.assign(auth.user, { }); ``` -> **`getUsernameFromAuth()` naming.** For Authelia tokens it returns `preferred_username`; for Auth0 tokens it falls back to `getEffectiveUsername(req)` (LFXV2-2122). The name is still easy to misread — prefer `getEffectiveUsername` directly when you need the LFID handle. +> **`getUsernameFromAuth()` naming.** For Authelia tokens it returns `preferred_username`; for Auth0 tokens it falls back to `getEffectiveUsername(req)`. The name is still easy to misread — prefer `getEffectiveUsername` directly when you need the LFID handle. ### When to use which @@ -144,9 +144,9 @@ Read identity through the helpers in `apps/lfx-one/src/server/utils/auth-helper. | `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — only for call sites whose upstream still wants the prefixed sub | | `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups | -### Migration: `sub` → `username` (LFXV2-1962 / LFXV2-2122) +### Migration: `sub` → `username` -Backend identity references are migrating from the Auth0 `sub` to the LFID `username`. **LFXV2-2122** (merged in [#912](https://github.com/linuxfoundation/lfx-self-serve/pull/912)) flipped the first wave in this repo: +Backend identity references are migrating from the Auth0 `sub` to the LFID `username`. The first wave landed in [#912](https://github.com/linuxfoundation/lfx-self-serve/pull/912): - **Front-end (ID token):** DataDog RUM `id`, OpenFeature `targetingKey`, and survey `creator_id` now read `https://sso.linuxfoundation.org/claims/username` (OpenFeature no longer falls back to `sub` — existing LaunchDarkly rules keyed on sub values need updating before deploy). - **BFF call sites:** `getEffectiveSub` → `getEffectiveUsername` in changelog, copilot, org-identity, org-navigation, org-lens-access, and org-membership cache keys; `project.service.ts` uses `resolveEmailToUsername` (not `resolveEmailToSub`) for permission and user-info lookups against the plain-LFID `b2b_org_settings` index. diff --git a/docs/architecture/backend/impersonation.md b/docs/architecture/backend/impersonation.md index af1e6af01..a95978dd5 100644 --- a/docs/architecture/backend/impersonation.md +++ b/docs/architecture/backend/impersonation.md @@ -164,7 +164,7 @@ Many controllers and services read the user's email/username from `req.oidc.user | `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for identity references (LFID username, e.g. `lguerra`) | | `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); rare remaining sites | -For the full `username` vs `sub` distinction and the LFXV2-1962 migration, see [`authentication.md`](./authentication.md#-identity-claims-username-vs-sub). +For the full `username` vs `sub` distinction and the `sub` → `username` migration, see [`authentication.md`](./authentication.md#-identity-claims-username-vs-sub). These check `req.appSession['impersonationUser']` first, falling back to `req.oidc.user`. All controllers/services that filter by user identity use these helpers (meetings, events, committees, votes, surveys, mailing lists, documents, analytics, badges, persona detection). diff --git a/docs/architecture/frontend/feature-flags.md b/docs/architecture/frontend/feature-flags.md index e10e09c48..264cf2f3e 100644 --- a/docs/architecture/frontend/feature-flags.md +++ b/docs/architecture/frontend/feature-flags.md @@ -756,7 +756,7 @@ const userContext: EvaluationContext = { }; ``` -> **Identity (LFXV2-2122).** `targetingKey` uses the LFID username claim chain above — not Auth0 `sub`. LaunchDarkly rules keyed on legacy `sub` values stop matching after deploy; update them to LFID usernames. See [Authentication — Identity Claims](../backend/authentication.md#-identity-claims-username-vs-sub). +> **Identity.** `targetingKey` uses the LFID username claim chain above — not Auth0 `sub`. LaunchDarkly rules keyed on legacy `sub` values stop matching after deploy; update them to LFID usernames. See [Authentication — Identity Claims](../backend/authentication.md#-identity-claims-username-vs-sub). **Context Structure:** From e36959ffc5a470f14978ea9cc53ef5ca2ffc1419 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Mon, 15 Jun 2026 13:44:47 +0100 Subject: [PATCH 11/13] docs(architecture): drop PR link from identity migration (LFXV2-2192) Signed-off-by: Audi Young Co-authored-by: Cursor --- docs/architecture/backend/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index 45a45334b..f2daf10be 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -146,7 +146,7 @@ Read identity through the helpers in `apps/lfx-one/src/server/utils/auth-helper. ### Migration: `sub` → `username` -Backend identity references are migrating from the Auth0 `sub` to the LFID `username`. The first wave landed in [#912](https://github.com/linuxfoundation/lfx-self-serve/pull/912): +Backend identity references are migrating from the Auth0 `sub` to the LFID `username`. In this repo, the first wave: - **Front-end (ID token):** DataDog RUM `id`, OpenFeature `targetingKey`, and survey `creator_id` now read `https://sso.linuxfoundation.org/claims/username` (OpenFeature no longer falls back to `sub` — existing LaunchDarkly rules keyed on sub values need updating before deploy). - **BFF call sites:** `getEffectiveSub` → `getEffectiveUsername` in changelog, copilot, org-identity, org-navigation, org-lens-access, and org-membership cache keys; `project.service.ts` uses `resolveEmailToUsername` (not `resolveEmailToSub`) for permission and user-info lookups against the plain-LFID `b2b_org_settings` index. From dbd425b3e3e1e20bad049d1a01fb22349252be70 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Mon, 15 Jun 2026 21:34:45 +0100 Subject: [PATCH 12/13] docs(architecture): address review feedback on identity claims (LFXV2-2192) Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 25 +++++++++------------ 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index f2daf10be..744b6300f 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -89,7 +89,7 @@ Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing | **`sub`** (Auth0 subject) | `auth0\|lguerra` | Provider-prefixed, opaque, globally unique per identity | `user.sub` | | **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username`, `user.preferred_username` | -- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse or display it raw (strip the prefix with `stripAuthPrefix` if you must show it). A few call sites still pass the prefixed `sub` upstream during the migration window — e.g. `badges.controller.ts` resolves verified emails via the auth-service using `getEffectiveSub(req)`. +- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse it, and never display it as if it were a username. Stripping the connection prefix is misleading: the bare value only coincidentally matches the LFID handle today and is not guaranteed to, so a stripped `sub` is not a substitute for `username`. A few call sites still pass the prefixed `sub` upstream during the migration window — e.g. `badges.controller.ts` resolves verified emails via the auth-service using `getEffectiveSub(req)`. - **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. Org role grants (`org-identity.controller.ts`, `org-navigation.service.ts`, `org-role-grants.service.ts`) query `b2b_org_settings` with `tags: ['member:${username}']` where `username` comes from `getEffectiveUsername(req)`. On surveys, `creator_username` holds the bare nickname and `creator_id` is set from the `https://sso.linuxfoundation.org/claims/username` claim. ### ID token vs access token — where the claims actually live @@ -103,10 +103,7 @@ Auth0 issues **two** JWTs per session, and they carry identity differently. This | username claim | `https://sso.linuxfoundation.org/claims/username` (plus `nickname` / `username` / `preferred_username`) | `http://lfx.dev/claims/username` — **different namespace** | | Consumed by | The BFF only (SSR, analytics, persistence, the `getEffective*` helpers) | Forwarded upstream as `Authorization: Bearer` to the Go microservices | -Two consequences the migration depends on: - -- **`sub` is the only identifier present in both tokens under the same key.** The username claim is namespaced differently in each (`https://sso.linuxfoundation.org/...` in the ID token vs `http://lfx.dev/...` in the access token), so any code bridging the two must map between namespaces — they are not the same key. -- **The access token is the only _token_ the BFF forwards upstream; the ID token never leaves the BFF.** `req.bearerToken` is the access token (refreshed in `auth.middleware.ts`, forwarded by `microservice-proxy.service.ts`). Upstream authorizes and matches claim-based identity off the access token's `sub` / `http://lfx.dev/claims/username` — never off the ID token, and never off the value `getEffectiveUsername` returns. (Upstream _can_ still see a username outside the JWT when the BFF passes one explicitly in a request param or payload — e.g. the bare `creator_username` persisted on surveys, or a NATS RPC payload — so any LFID handle a Go service needs must be sent on the wire deliberately, not assumed present in the token claims.) So the migration runs on two tracks: (1) **upstream matching** — what the Go services key on, driven by the access token's claims and Auth0 token config, largely outside this repo; and (2) **BFF-side identity** — the `getEffectiveSub` → `getEffectiveUsername` call-site flips and the front-end claim swap, which read the ID token and are fully in this repo. +Note: `sub` is the only identifier present in both tokens under the same key. The username claim is namespaced differently in each (`https://sso.linuxfoundation.org/...` in the ID token vs `http://lfx.dev/...` in the access token), so any code bridging the two must map between namespaces — they are not the same key. **Worked example — impersonation bridges the namespaces by hand.** Impersonation discards the target's ID token and rebuilds identity entirely from the exchanged **access token**, copying its `http://lfx.dev/claims/username` into every ID-token username slot so both namespaces resolve to the same handle (`server.ts`): @@ -124,15 +121,15 @@ Object.assign(auth.user, { ### When to use which -| Use case | Use | -| ------------------------------------------------------------------------------------- | ------------ | -| Calling an upstream microservice / query-service API that keys on the LF login handle | **username** | -| Persisting an author/owner/creator (`creator_id`, role grants, changelog viewer) | **username** | -| Analytics / observability user identity (DataDog RUM, OpenFeature targeting key) | **username** | -| Per-caller cache keys for user-scoped data | **username** | -| Anything that must match an Auth0 identity record exactly (rare, provider-specific) | **sub** | +| Use case | Use | +| ---------------------------------------------------------------------------------------------------------------- | ------------ | +| Calling an upstream microservice / query-service API that keys on the LF login handle | **username** | +| Persisting an author/owner/creator (`creator_id`, role grants, changelog viewer) | **username** | +| Analytics / observability user identity (DataDog RUM, OpenFeature targeting key) | **username** | +| Per-caller cache keys for user-scoped data | **username** | +| Resolving verified emails via the auth-service, which still indexes on the prefixed sub (`badges.controller.ts`) | **sub** | -> **Default to `username`.** `sub` is being phased out of backend identity references — see the migration note below. +> **Default to `username`.** `sub` has been phased out of backend identity references — see the migration note below. ### Server-side helpers (impersonation-aware) @@ -146,7 +143,7 @@ Read identity through the helpers in `apps/lfx-one/src/server/utils/auth-helper. ### Migration: `sub` → `username` -Backend identity references are migrating from the Auth0 `sub` to the LFID `username`. In this repo, the first wave: +Backend identity references have migrated from the Auth0 `sub` to the LFID `username`. In this repo: - **Front-end (ID token):** DataDog RUM `id`, OpenFeature `targetingKey`, and survey `creator_id` now read `https://sso.linuxfoundation.org/claims/username` (OpenFeature no longer falls back to `sub` — existing LaunchDarkly rules keyed on sub values need updating before deploy). - **BFF call sites:** `getEffectiveSub` → `getEffectiveUsername` in changelog, copilot, org-identity, org-navigation, org-lens-access, and org-membership cache keys; `project.service.ts` uses `resolveEmailToUsername` (not `resolveEmailToSub`) for permission and user-info lookups against the plain-LFID `b2b_org_settings` index. From 8019e78f8906110a59fe96462f193dc7106c3a20 Mon Sep 17 00:00:00 2001 From: Audi Young Date: Tue, 16 Jun 2026 13:42:22 +0100 Subject: [PATCH 13/13] docs(architecture): align identity docs with code (LFXV2-2192) Verified authentication.md and impersonation.md against auth0-terraform, lfx-v2-auth-service, member/query/persona services, lfx-v2-helm (Heimdall), and this repo. Correct the impersonation profile-controller claim: it resolves identity via getUsernameFromAuth -> getEffectiveUsername (impersonation-aware), so most profile operations act on the target user. Password change/reset remain the real-user exception via the management-token flow. Signed-off-by: Audi Young --- docs/architecture/backend/authentication.md | 32 +++++++++---------- docs/architecture/backend/impersonation.md | 34 ++++++++++++--------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md index 744b6300f..9d62578df 100644 --- a/docs/architecture/backend/authentication.md +++ b/docs/architecture/backend/authentication.md @@ -89,7 +89,7 @@ Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing | **`sub`** (Auth0 subject) | `auth0\|lguerra` | Provider-prefixed, opaque, globally unique per identity | `user.sub` | | **`username`** (LFID username) | `lguerra` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username`, `user.preferred_username` | -- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse it, and never display it as if it were a username. Stripping the connection prefix is misleading: the bare value only coincidentally matches the LFID handle today and is not guaranteed to, so a stripped `sub` is not a substitute for `username`. A few call sites still pass the prefixed `sub` upstream during the migration window — e.g. `badges.controller.ts` resolves verified emails via the auth-service using `getEffectiveSub(req)`. +- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse it, and never display it as if it were a username. Stripping the connection prefix is misleading: the bare value only coincidentally matches the LFID handle today and is not guaranteed to, so a stripped `sub` is not a substitute for `username`. One call site still passes the prefixed `sub` — `badges.controller.ts` resolves verified emails via the auth-service using `getEffectiveSub(req)` — but that auth-service lookup also accepts a username or email, so no upstream actually requires the prefixed `sub` today. - **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. Org role grants (`org-identity.controller.ts`, `org-navigation.service.ts`, `org-role-grants.service.ts`) query `b2b_org_settings` with `tags: ['member:${username}']` where `username` comes from `getEffectiveUsername(req)`. On surveys, `creator_username` holds the bare nickname and `creator_id` is set from the `https://sso.linuxfoundation.org/claims/username` claim. ### ID token vs access token — where the claims actually live @@ -121,25 +121,24 @@ Object.assign(auth.user, { ### When to use which -| Use case | Use | -| ---------------------------------------------------------------------------------------------------------------- | ------------ | -| Calling an upstream microservice / query-service API that keys on the LF login handle | **username** | -| Persisting an author/owner/creator (`creator_id`, role grants, changelog viewer) | **username** | -| Analytics / observability user identity (DataDog RUM, OpenFeature targeting key) | **username** | -| Per-caller cache keys for user-scoped data | **username** | -| Resolving verified emails via the auth-service, which still indexes on the prefixed sub (`badges.controller.ts`) | **sub** | +| Use case | Use | +| ------------------------------------------------------------------------------------- | ------------ | +| Calling an upstream microservice / query-service API that keys on the LF login handle | **username** | +| Persisting an author/owner/creator (`creator_id`, role grants, changelog viewer) | **username** | +| Analytics / observability user identity (DataDog RUM, OpenFeature targeting key) | **username** | +| Per-caller cache keys for user-scoped data | **username** | -> **Default to `username`.** `sub` has been phased out of backend identity references — see the migration note below. +> **Default to `username`.** `sub` has been phased out of backend identity references and no upstream currently requires it — see the migration note below. ### Server-side helpers (impersonation-aware) Read identity through the helpers in `apps/lfx-one/src/server/utils/auth-helper.ts`, never directly off `req.oidc.user`. They transparently return the **target** user's identity during impersonation and the session user's otherwise. -| Helper | Returns | Status | -| --------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | -| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for all new identity references | -| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — only for call sites whose upstream still wants the prefixed sub | -| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups | +| Helper | Returns | Status | +| --------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for all new identity references | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — its lone remaining caller (badges email lookup) passes `sub` incidentally; no upstream requires it | +| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups | ### Migration: `sub` → `username` @@ -147,9 +146,10 @@ Backend identity references have migrated from the Auth0 `sub` to the LFID `user - **Front-end (ID token):** DataDog RUM `id`, OpenFeature `targetingKey`, and survey `creator_id` now read `https://sso.linuxfoundation.org/claims/username` (OpenFeature no longer falls back to `sub` — existing LaunchDarkly rules keyed on sub values need updating before deploy). - **BFF call sites:** `getEffectiveSub` → `getEffectiveUsername` in changelog, copilot, org-identity, org-navigation, org-lens-access, and org-membership cache keys; `project.service.ts` uses `resolveEmailToUsername` (not `resolveEmailToSub`) for permission and user-info lookups against the plain-LFID `b2b_org_settings` index. -- **`getEffectiveSub`** is annotated `@deprecated` in `auth-helper.ts` and remains only where upstream still requires the prefixed sub (e.g. badges email lookup via auth-service). +- **Upstream matching:** the platform authorization proxy (Heimdall) derives the FGA principal from the access token's username attribute (falling back to `client_id@clients` for M2M), not `sub` — so the Go microservices authorize on the LFID handle. +- **`getEffectiveSub`** is annotated `@deprecated` in `auth-helper.ts`; its only remaining caller is the badges email lookup, which passes `sub` to the auth-service incidentally (that lookup also accepts a username or email) — a cleanup candidate, not an upstream requirement. -When adding new code, use `username` unless the specific upstream handler still requires the prefixed sub — and if so, note why inline. +When adding new code, use `username`. No upstream currently requires the prefixed `sub`; if you ever hit one that does, use `getEffectiveSub` and note why inline. ## 🏗 Server-Side Implementation diff --git a/docs/architecture/backend/impersonation.md b/docs/architecture/backend/impersonation.md index a95978dd5..045ef94af 100644 --- a/docs/architecture/backend/impersonation.md +++ b/docs/architecture/backend/impersonation.md @@ -15,13 +15,16 @@ Impersonation uses Auth0's Custom Token Exchange (CTE) feature (RFC 8693) to obt The Auth0 side is managed in the `auth0-terraform` repo: - **CTE Action** (`lfx_impersonation_token_exchange.js`) — validates the requestor, looks up the target user via Management API, and calls `api.authentication.setUserById()` to issue a new token -- **`can_impersonate` claim** — added to LFX v2 access tokens via `custom_claims.js` for users matching an email allow-list +- **`can_impersonate` claim** — added to LFX v2 access tokens via `src/actions/custom_claims.js` for authorized impersonators (see Authorization below) - **Token Exchange Profile** — maps the LFX v2 API `subject_token_type` to the impersonation CTE action - **Auth Service Client** — the "LFX V2 Auth Service" client has `token_exchange` enabled with `allow_any_profile_of_type: ["custom_authentication"]` ### Authorization -Only users whose email matches the allow-list regex in the Auth0 action receive the `can_impersonate` claim. The allow-list is maintained in `src/includes/impersonation.js` in the `auth0-terraform` repo. +The `can_impersonate` claim is granted in `src/actions/custom_claims.js` (`auth0-terraform`), and the authorization rule differs by tenant: + +- **Production** — the user's per-client group list in `app_metadata` (`groups-`) must include `lfx-self-serve-allowed-impersonators`. +- **Dev tenant** (`linuxfoundation-dev`) — any verified `@linuxfoundation.org` or `@contractor.linuxfoundation.org` email qualifies, since Auth0 groups are non-trivial to set up there. ### Token Exchange Flow @@ -158,29 +161,30 @@ This is the single choke point — every controller and service uses `req.bearer Many controllers and services read the user's email/username from `req.oidc.user` for server-side filtering (e.g., "get my meetings"). During impersonation, `req.oidc.user` is still the real user. Three helpers resolve the correct identity: -| Helper | Returns | Notes | -| --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | -| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | Email-keyed lookups | -| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for identity references (LFID username, e.g. `lguerra`) | -| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); rare remaining sites | +| Helper | Returns | Notes | +| --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | Email-keyed lookups | +| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for identity references (LFID username, e.g. `lguerra`) | +| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — Auth0 sub (prefixed, e.g. `auth0\|lguerra`); one incidental caller (badges email lookup) | For the full `username` vs `sub` distinction and the `sub` → `username` migration, see [`authentication.md`](./authentication.md#-identity-claims-username-vs-sub). These check `req.appSession['impersonationUser']` first, falling back to `req.oidc.user`. All controllers/services that filter by user identity use these helpers (meetings, events, committees, votes, surveys, mailing lists, documents, analytics, badges, persona detection). -**Exception:** The profile controller always uses `req.oidc.user` directly because profile operations (change password, update metadata, link identities) must act on the real user's account. +**Profile controller — partially impersonation-aware.** The profile controller resolves identity through `getUsernameFromAuth()`, which falls back to the impersonation-aware `getEffectiveUsername`. So most profile operations (get/update metadata, identities, work experiences, project affiliations, email verify/link) follow the **effective** identity and act on the **target** user during impersonation. The exception is password change and reset: they go through the separate `/api/profile/auth/start` management-token flow, which identifies the user by the management token itself (tied to the real user's re-authenticated session) and is not impersonation-aware. ### 6. SSR Handler **`apps/lfx-one/src/server/server.ts`** -During SSR, the handler: +During SSR, the handler runs in this order: -1. Populates `auth.canImpersonate` by decoding the `can_impersonate` claim from the access token -2. When impersonation is active, overrides `auth.user` with the target user's claims (sub, email, username, name, picture) -3. Sets `auth.impersonating = true` and `auth.impersonator` with the real user's identity +1. Builds `auth.user` from the OIDC session (initially the real user) +2. Runs persona detection (`resolvePersonaForSsr`) +3. Populates `auth.canImpersonate` by decoding the `can_impersonate` claim from the access token +4. When an active impersonation session exists, overrides `auth.user` with the target user's claims (sub, email, username, name, picture) and sets `auth.impersonating = true` + `auth.impersonator` -Persona detection (`resolvePersonaForSsr`) runs after the override, so it resolves the target user's persona. +Note that persona detection (step 2) resolves the **target** user's persona even though it runs **before** the `auth.user` override (step 4). It does so not because of ordering but because `resolvePersonaForSsr` reads identity through the `getEffective*` helpers, which consult `req.appSession['impersonationUser']` directly — independent of `auth.user`. ### 7. Frontend @@ -248,7 +252,7 @@ impersonation_stopped: Impersonation session ended ## Limitations -1. **Profile operations are not impersonated** — the profile controller always operates on the real user's account. Viewing another user's profile page during impersonation shows the real user's profile data, not the target's. +1. **Most profile operations follow the impersonated identity** — the profile controller resolves the user via `getUsernameFromAuth()` → `getEffectiveUsername` (impersonation-aware), so viewing and updating profile metadata, identities, work experiences, and project affiliations during impersonation acts on the **target** user, not the real user. Password change and reset are the exception: they use the management-token profile-auth flow tied to the real user's re-authenticated session. 2. **Write operations use the target's identity** — creating meetings, committees, or votes while impersonating will attribute them to the target user (via the bearer token). The `created_by_name` field on committees is an exception (uses the real user's name). @@ -262,7 +266,7 @@ impersonation_stopped: Impersonation session ended ## Future Work -- **Read-only profile viewing** — allow impersonators to view (but not modify) the target user's profile page +- **Read-only profile during impersonation** — profile reads already follow the target user, but writes (metadata, identity linking) do too; gate profile writes so impersonators can view the target's profile without modifying the account - **Impersonation audit dashboard** — a dedicated UI for reviewing impersonation logs - **Session duration controls** — configurable max impersonation duration, auto-expiry notifications - **Impersonation notifications** — optionally notify the target user when they are being impersonated