The Persona Service is a microservice in the LFX Self-Service platform (also known as LFX v2). It aggregates a user's involvement across Linux Foundation projects and foundations into a single, UI-friendly response.
The primary consumer is the LFX Self-Service UI. The service answers the question: "Which projects is this user connected to, and through what kinds of engagement?" — so the UI can personalize navigation, landing views, and feature surfacing without making many parallel upstream calls on every page load.
Personas are not authorization. The name is deliberate: personas describe how to present relevant context to a user, not whether they may access something. Access control remains with OpenFGA and the access-check layer.
This is not a REST resource API. The main contract is a NATS request/reply endpoint. HTTP is limited to Kubernetes health probes (/livez, /readyz). See ARCHITECTURE.md for the full design rationale.
A user may have multiple personas across different projects and foundations. The service returns one entry per project, with one or more detection objects describing why that project is relevant.
| Property | Value |
|---|---|
| Subject | lfx.personas-api.get |
| Queue group | lfx.personas-api.queue |
| Pattern | Request/reply (caller publishes a request; service replies on the inbox) |
| Recommended timeout | 5 seconds (caller-side; not enforced server-side) |
The service fans out to all enabled data sources in parallel. Upstream HTTP clients use a 10-second timeout per request. Partial failures from individual sources are logged and skipped; the response still includes results from sources that succeeded. Hard failures (malformed request, validation error) return a top-level error field with an empty projects array.
{
"username": "jdoe",
"email": "jdoe@example.com"
}| Field | Required | Notes |
|---|---|---|
username |
No* | Auth0 nickname / LFX username. May be empty for accounts without a username yet. Sources that match on username are skipped when empty. Must contain only [a-zA-Z0-9_-] when provided. |
email |
Yes | Primary email, normalized to lowercase. Used as the primary identity signal for email-based lookups. |
* email is strictly required; username is optional but unlocks additional matching legs.
{
"projects": [
{
"project_uid": "a1b2c3d4-...",
"project_slug": "my-project",
"detections": [
{
"source": "board_member",
"extra": {
"committee_uid": "...",
"committee_name": "TAC",
"committee_member_uid": "...",
"role": "Chair",
"voting_status": "Voting Rep",
"organization": {
"id": "0014100000Te2ovAAB",
"name": "The Linux Foundation",
"website": "http://linuxfoundation.org"
}
}
},
{
"source": "cdp_roles",
"extra": {
"contributionCount": 42,
"roles": [
{
"id": "...",
"role": "Maintainer",
"startDate": "2024-01-01T00:00:00Z",
"endDate": null,
"repoUrl": "https://github.com/org/repo",
"repoFileUrl": "..."
}
]
}
},
{ "source": "mailing_list" }
]
}
],
"error": null
}projects is always present. It is [] when no matches are found.
| Token | Meaning |
|---|---|
board_member |
Member of a committee with category Board |
executive_director |
Executive Director of the project |
cdp_roles |
CDP project affiliation (roles, contribution count) |
cdp_activity |
CDP/Snowflake activity signal (reserved; not yet implemented) |
writer |
Project writer (access-control membership) |
auditor |
Project auditor (access-control membership) |
committee_member |
Member of any committee, including Board (community engagement signal) |
mailing_list |
Subscribed to a project mailing list |
meeting_attendance |
Invited to or attended a project meeting |
A single project may appear once with multiple detections. The same source token may appear more than once when the user matches that source multiple times (for example, two Board committees under the same project produce two board_member detections with different extra values).
The UI is responsible for interpreting detection data — for example, reading cdp_roles.extra.roles[] to decide whether to show a "Maintainer" label. The service passes CDP role data through without filtering or interpretation.
{
"projects": [],
"error": {
"code": "validation_error",
"message": "email is required"
}
}| Code | When |
|---|---|
invalid_request |
Request body is not valid JSON |
validation_error |
Missing email or invalid username characters |
nats req lfx.personas-api.get \
'{"username":"jdoe","email":"jdoe@example.com"}' \
--timeout 5s// imports: "fmt", "log", "time", github.com/nats-io/nats.go
nc, err := nats.Connect("nats://localhost:4222")
if err != nil {
log.Fatal(err)
}
defer nc.Close()
req := []byte(`{"username":"jdoe","email":"jdoe@example.com"}`)
msg, err := nc.Request("lfx.personas-api.get", req, 5*time.Second)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(msg.Data))| Endpoint | Purpose |
|---|---|
GET /livez |
Liveness — process is running |
GET /readyz |
Readiness — NATS connection is healthy |
Default listen port: 8080 (override with -p).
Personas are navigation-centric views derived from detections. The service does not return a "persona": "board_member" field; instead, the UI maps detection sources (and sometimes extra payload details) to the persona experience it should show.
Below is how each persona is determined and what data backs it.
Intent: Surface projects where the user sits on a Board-category committee — typically the highest-touch governance entry point in the UI.
How it is calculated:
- Query the Query Service for
committee_memberresources withcommittee_category:Board, using two parallel legs:- Email leg:
type=committee_member,tags_all=committee_category:Board,email:<email> - Username leg (skipped when username is empty):
type=committee_member,tags_all=committee_category:Board,filters=username:<username>
- Email leg:
- De-duplicate results by
committee_memberrecord ID. - Apply a local exact post-filter on username-leg results (case-insensitive equality on
data.username) because Query Servicefiltersterm clauses can be overly broad. - For each match, emit a
board_memberdetection with committee context inextra:committee_uid,committee_name,committee_member_uidrole,voting_statusorganization(id,name,website)
Notes:
data.emailon committee member records is the reliable identity signal;data.usernameis often empty in production.- A user on two Board committees under the same project produces two
board_memberdetections. - Role and voting status are surfaced for display only; they are not permission signals.
Intent: Surface projects where the user is the designated Executive Director.
How it is calculated:
- Skip entirely when
usernameis empty. - Query
project_settingsresources:type=project_settings,filters=executive_director.username:<username>. - Post-filter locally for exact case-insensitive match on
data.executive_director.username. - Resolve
project_slugvia the project service NATS endpointlfx.projects-api.get_slug. - Emit an
executive_directordetection per matching project (noextrafields).
Data dependency: The executive_director field on project_settings must be populated (synced from v1 Salesforce via the v1 sync helper and indexed into OpenSearch). See ARCHITECTURE.md for the upstream prerequisites.
Intent: The default engagement view — any project the user has a meaningful connection to, regardless of governance role. Community membership is a navigation hint, not an access gate.
Community is not a single detection token. It is the union of six engagement sources. The service merges all matches by project_uid and lists every matching source in detections[].
Snowflake-backed activity aggregation for projects where the user has recorded contributions but may lack a CDP affiliation entry. The cdp_activity source token is defined in the contract but not yet wired in the service. When implemented, it will share the CDP member-resolution flow and NATS KV cache described below.
How it is calculated:
- Resolve the CDP member ID:
POST /v1/members/resolvewith{ "lfids": [username], "emails": [email] }. A 404 means no CDP profile — this source returns empty. - Fetch affiliations:
GET /v1/members/{memberId}/project-affiliations. - Cache both steps in the NATS KV bucket
persona-cache(bucket TTL is configured at deploy time; stale-while-revalidate after 10 minutes in application code). - Resolve slugs to v2 UIDs via project service NATS endpoint
lfx.projects-api.slug_to_uid(parallel per slug). - Skip
nonlf_*slugs and affiliations that fail UID resolution. - Emit
cdp_roleswithextra.contributionCountandextra.roles[]passed through from CDP.
Requires: Full CDP credential set (see Configuration). Autodegrades when CDP is not configured.
How it is calculated:
- Skip when
usernameis empty. - Two parallel Query Service legs against
project_settings:filters=writers.username:<username>filters=auditors.username:<username>
- Post-filter each leg against the relevant array (
data.writersordata.auditors) for exact case-insensitive username match. - A project where the user is both writer and auditor receives both detection tokens on one project entry.
- Resolve
project_slugvialfx.projects-api.get_slug.
How it is calculated:
- Query all
committee_memberresources (nocommittee_category:Boardfilter), using the same dual email/username leg pattern as Board Member. - De-duplicate by record ID; post-filter username leg results.
- Emit
committee_memberwithextracontainingcommittee_uid,committee_name,committee_member_uid, androle.
Board-category members appear in both board_member (from the Board-only query) and committee_member (from the all-committee query). The UI can use board_member for the governance persona and committee_member for general community engagement.
How it is calculated:
- Two parallel Query Service legs against
groupsio_member:- Email leg:
type=groupsio_member,tags_all=email:<email> - Username leg (skipped when empty):
type=groupsio_member,filters=username:<username>with local post-filter
- Email leg:
- De-duplicate by record ID.
- Read
project_uidandproject_slugfrom the enriched indexed record. - Emit
mailing_listdetection (noextraby default).
Subscription is a navigation hint only, not a permission signal.
How it is calculated:
- Two parallel Query Service legs against
v1_past_meeting_participant:- Email leg:
tags_all=email:<email> - Username leg (skipped when empty):
tags_all=username:<username>
- Email leg:
- Both legs use tag lookups (exact match) — no post-filter needed.
- De-duplicate by record ID.
- Include both invited and attended records (
is_invited/is_attendedare not filtered — any engagement counts). - Read
project_uidandproject_slugfrom the enriched indexed record. - Emit
meeting_attendancedetection.
| UI persona | Detection signal(s) |
|---|---|
| Board Member | board_member |
| Executive Director | executive_director |
| Community (default) | Any of: cdp_roles, cdp_activity, writer, auditor, committee_member, mailing_list, meeting_attendance |
Board-category members also receive committee_member detections alongside board_member. The UI should treat board_member as the governance signal and not infer absence of Board membership from committee_member alone.
A user may qualify for multiple personas on the same project. The UI chooses which view to prioritize based on product rules (typically Board Member and ED take precedence over Community).
- Go 1.25+
- NATS server (local or cluster)
- Optionally: Query Service (direct URL or LFX API gateway), CDP API credentials
# Install dependencies and generate Goa code
make setup
make apigen
# Build and run locally
export NATS_URL=nats://localhost:4222
export QUERY_SERVICE_URL=http://localhost:8081 # or use LFX_BASE_URL + LFX_AUDIENCE
make run
make debug # alternative: same as make run with -dThe server listens on :8080 for health checks and subscribes to lfx.personas-api.get on NATS.
| Target | Description |
|---|---|
make setup |
go mod download and tidy |
make setup-dev |
Install golangci-lint |
make apigen |
Regenerate Goa HTTP/health code from cmd/server/design/ |
make build |
Build binary to bin/lfx-v2-persona-service |
make run |
Build and run |
make debug |
Build and run with debug logging (-d) |
make test |
Run tests with race detector and coverage |
make lint |
Run golangci-lint |
make fmt |
Format Go source |
make check |
Format check + lint + license header check |
lfx-v2-persona-service/
├── cmd/
│ └── server/ Entry point, Goa design, HTTP server, NATS wiring
├── internal/
│ ├── service/ Persona handler and per-source query logic
│ ├── infrastructure/
│ │ ├── cdp/ CDP API client, Auth0 token provider, NATS KV cache
│ │ ├── query/ Query Service HTTP client
│ │ └── nats/ NATS connection, subscriptions, KV store
│ └── domain/ Models and port interfaces
├── pkg/ Shared logging, errors, and constants
├── gen/ Generated Goa code (do not edit by hand)
└── charts/ Helm chart for Kubernetes deployment
After changing the Goa design in cmd/server/design/persona.go, run make apigen.
The service autodegrades when optional credential groups are missing. For local work you typically need only:
export NATS_URL=nats://localhost:4222
export QUERY_SERVICE_URL=http://query-service:8080This enables Board Member, Executive Director, writer/auditor, committee member, mailing list, and meeting attendance sources. CDP (cdp_roles) is disabled until CDP credentials are provided.
| Variable | Required | Notes |
|---|---|---|
NATS_URL |
No | NATS server URL. Defaults to nats://localhost:4222 when unset; the service starts without requiring this to be explicitly set. |
QUERY_SERVICE_URL |
One of* | Direct Query Service base URL (no auth) |
LFX_BASE_URL |
One of* | LFX API gateway URL (requires Auth0 + LFX_AUDIENCE) |
LFX_AUDIENCE |
With gateway | Auth0 audience for gateway access |
AUTH0_ISSUER_BASE_URL |
CDP / gateway | Auth0 tenant base URL |
AUTH0_CLIENT_ID |
CDP / gateway | LFX One M2M application client ID |
AUTH0_M2M_PRIVATE_BASE64_KEY |
CDP / gateway | Base64 RSA private key for client assertion JWT |
CDP_AUDIENCE |
CDP | Auth0 audience for CDP API |
CDP_BASE_URL |
CDP | CDP API base URL |
NATS_TIMEOUT |
No | NATS connection timeout for dial/connect, passed to nats.Timeout() (default 10s). Does not control caller request/reply timeouts. |
NATS_MAX_RECONNECT |
No | Max reconnect attempts (default 3) |
NATS_RECONNECT_WAIT |
No | Wait between reconnects (default 2s) |
* Either QUERY_SERVICE_URL or LFX_BASE_URL (+ LFX_AUDIENCE and Auth0 credentials) must be set for Query Service sources to activate.
CDP credential group: All five CDP/Auth0 variables must be present to enable cdp_roles. If any are missing, the service logs a warning and continues without CDP.
The service does not create the persona-cache JetStream KV bucket — it connects to an existing bucket via js.KeyValue(). In Kubernetes deployments the Helm chart creates the bucket with a 24-hour entry TTL (charts/lfx-v2-persona-service/values.yaml). The application writes values with PutString and does not set per-entry TTL itself.
| Data | Backend | Expiry |
|---|---|---|
CDP memberId |
NATS KV (persona-cache) |
Bucket TTL (24h when deployed via Helm) |
| CDP affiliations | NATS KV (persona-cache) |
Bucket TTL (24h when deployed via Helm) |
| Auth0 M2M token | In-process | Expiry − 5 min |
Query Service lookups are not cached.
Stale-while-revalidate (application-level): entries younger than 10 minutes are served as-is; older entries are returned immediately while a background refresh runs. This 10-minute threshold is independent of the bucket TTL.
make testSource-specific logic and username validation have unit tests under internal/service/.
The Helm chart lives in charts/lfx-v2-persona-service/. Images are published to ghcr.io/linuxfoundation/lfx-v2-persona-service/server. ArgoCD configuration is in lfx-v2-argocd.
- ARCHITECTURE.md — Full design spec, data flow diagram, caching strategy, and upstream dependencies
- SECURITY.md — Security policy