Skip to content

User Identity: Official Name vs Preferred Name

A user account carries two kinds of name, with different owners.

Official name (IdP-owned on SSO accounts)

User.first_name / User.last_name are the institutional record. On every SSO login, SocialAccountAdapter.pre_social_login (campuscore_app/apps/auth/allauth_adapter.py) re-syncs them (and the email) from the IdP's claims, so a local edit to these fields on an SSO-linked account would silently revert at the next sign-in. PATCH /api/account therefore rejects first/last-name edits from SSO-linked accounts with a 403, and the settings form renders them read-only with a "Managed by your institution." hint.

A local account (no SSO identity linked - in practice a createsuperuser account) may edit its official name, but only until an SSO login auto-links it: the adapter connects a first-time SSO identity to an existing account by IdP-asserted email and immediately resyncs the name. The preferred name is the field a user can rely on across that transition.

The 403 keys off the field being present (non-null) in the PATCH body, not off the value differing from what is stored: null means "not editing this field", and any non-null name from a locked account - even one equal to the stored value - rejects the whole request. The settings form sends null for locked fields, so a well-behaved client never hits this.

Preferred name (user-owned)

UserProfile.preferred_name (campuscore_app/apps/auth/models/sso_models.py) is set, changed, and cleared only by its user, via PATCH /api/account; an empty string means "unset". Django admin shows it read-only on the user profile, so support can see it but never write it; profile rows cannot be deleted there either, because the bootstrap and account APIs read the profile unconditionally. No SSO code path writes it: the adapter's save lists only User columns, and no signal re-saves the profile on user save. There is no IdP claim mapping for it - deliberately, so the sync conflict this field exists to escape cannot be reintroduced.

How SSO linkage is detected

is_name_managed_by_sso(user) (campuscore_app/apps/auth/services/identity_service.py) is the one home for the rule: an allauth SocialAccount row exists exactly for SSO-provisioned or auto-linked accounts, so its existence answers "does an IdP own this account's name" - including for accounts provisioned before the rule existed.

Which surfaces use which name

The bootstrap payload (CurrentUser in campuscore_app/apps/main_app/apis/bootstrap_api.py) carries preferred_name plus the server-decided can_edit_official_name capability; the account settings form is driven entirely by these two fields. Any user-facing surface that starts displaying the signed-in user's name should prefer preferred_name and fall back to the official name. That rule has two executable forms in campuscore_app/apps/auth/services/identity_service.py, sharing one preferred-name lookup: display_name(user) falls back to the full official name, and salutation_name(user) falls back to the first name only. The agent's system prompt is a consumer of display_name: the ## User Identity block in the per-request dynamic context uses it, so the assistant addresses people by their preferred name (see agent-retrieval.md). The chat home's greeting is a consumer of salutation_name: GET /api/bootstrap composes greeting ("How can I help you today, Addie?") from it through chat_greeting in campuscore_app/apps/main_app/services/chat/greeting.py, unless an admin stored their own welcome message, which is shown verbatim. A greeting wants one word where the prompt wants the full name, which is why the two forms exist rather than one. Admin surfaces (workspace user list, folder-sharing picker, /cc_admin/), audit records, and user search deliberately stay on the official name, so staff always match the institution's directory.