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). Because that name can be one word, the block's rule reads a person named by it, in full or by first name, as the user unless the user marks another person, such as "my friend"; agent-retrieval.md owns the rule. 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, analytics usage table, /cc_admin/) and audit records deliberately stay on the official name, so staff always match the institution's directory. The folder-sharing picker and user search are the exception: people_directory.as_person labels a chip with the preferred name, falling back to the official name and then the email, because the chip is how the sharer recognises the person. The official-name rule's executable form is official_name(user) in identity_service.py: the official full name, else the username, else the email. The workspace user list, the analytics usage table and the artifact API's created_by_name field all label an account through it, so an account is never nameless and is called the same thing wherever it appears. The user list shows the email under the name only when the two differ.