boostorg / boostorg/website-v2

Task: Username validations

Open
#2,521 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
HTML
Stars
18
Forks
28
Avg merge
2d 12h
Merged PRs (30d)
77

Description

## Background

The v3 profile edit flow (#2447) lets users set a **Username**. In the data model this is the existing `User.display_name` field, relabeled to "Username" in `UserProfileForm` (`users/forms.py`). During refinement the question came up: should Username behave like a free-form display name (no uniqueness), or a claimed identity (enforce uniqueness)? And what precautions do we need given the side effects this field has on other parts of the system?

This ticket captures the policy decisions from the stakeholder and scopes the safeguards we need before the Username field ships to users.

### Input from Rob

- **Uniqueness is enforced.** Username is not just a display name; two accounts cannot hold the same one.
- **There must be an override / protected-name mechanism.** Some names should not be claimable by just anyone, e.g. a notable, now-deceased Boost founder (Beman Dawes was the example given). We do not want a user claiming or impersonating that identity.
- **Flag authors / maintainers.** These are the people most likely to be spoofed, so their names should be protected from being claimed by other accounts.

### Options discussed

- Protect notable identities either by (a) seeding/creating those accounts and setting their Username, or (b) maintaining a reserved-name list. A list also gives us a single place for general blocking and future AI moderation.
- Block common evasions of a reserved/taken name: case changes, letter-to-number substitutions (`numb3rs`), separators between words, and similar permutations.

---

## Why Username needs special handling (current code)

`display_name` is not cosmetic. It is wired into identity and attribution:

- **Contributor identity matching.** `UserManager.find_contributor()` matches on `display_name__iexact` (`users/models.py`). Renaming can silently re-link or unlink a user from historical contribution records.
- **Git commit author override.** With `is_commit_author_name_overridden` set, the Username value "globally replaces your git commit author name" (per the form help text and model field). This is the direct impersonation vector: a user could rename to a maintainer's name and have commits attributed under it.
- **Displayed across the site.** Profile cards, the homepage "Meet Boost Core" section, and the public profile view (#2492) all read this name.

Current state of uniqueness: `UserProfileForm.clean_username()` already rejects a name that matches another user's `display_name__iexact`. This is **form-level only**, so it has a TOCTOU race and no database guarantee, and it does not cover confusable permutations or reserved names.

Note: profile routing is currently pk-based (`users//`), so Username is not part of a URL slug today. If that changes, rename/redirect handling would need to be added here.

---

## Requirements / Acceptance Criteria

### Uniqueness
- [ ] Case-insensitive uniqueness is enforced at the **database level** (unique constraint on a normalized/canonical form), not just in the form, to close the race.
- [ ] The existing `clean_username` check stays as the friendly first-line validation.

### Reserved / protected names
- [ ] A configurable, admin-managed reserved-name list blocks claiming of protected identities (notable / deceased founders, and any name we want to hold back).
- [ ] Author / maintainer names are auto-protected: a name belonging to a flagged author/maintainer cannot be claimed by a different account.
- [ ] Decide and implement the protection mechanism: reserved-name table (recommended, doubles as a general blocklist) vs. pre-seeded protected accounts.

### Confusable / permutation guards
- [ ] Compute a canonical/normalized form of the Username and use it for both uniqueness and reserved-name comparison.
- [ ] Normalization folds common evasions: case, leetspeak substitutions (`3`→`e`, `4`→`a`, `1`→`l/i`, `0`→`o`, ...), separators/whitespace (space, `.`, `-`, `_`), Unicode homoglyphs/confusables, and zero-width characters.

### Change safeguards (rate limiting + history)
- [ ] Rate-limit Username changes (e.g. N changes per rolling window) to prevent churn abuse and impersonation flip-flopping.
- [ ] Keep a history of previous Usernames for audit and to prevent rapid reclaim of a just-released name.

### Cross-model effects on rename
- [ ] Define and implement rename behaviour with respect to `find_contributor()` matching and the commit-author override, so a rename does not silently re-attribute or break historical contribution links.
- [ ] Audit every read of `display_name` (`profile_cards`, homepage, public profile, admin filters) for staleness/caching after a rename.
- [ ] Decide whether enabling the commit-author override should be blocked when the Username collides with a flagged author/maintainer.

---

## Out of scope
- Full AI moderation of Username content (the reserved/blocklist here should be reusable by the bio moderation pipeline in #2489, but the AI layer is that ticket).
- Contributor de-duplication / record merging (covered by spike #2497).
- Public profile URL / slug redesign.

## Open questions

### 1. Reserved-name table vs. seeded protected accounts, or both?

Two facts from the current code shape this:

- `find_contributor()` auto-creates `User` rows during commit and library imports. Many identities we want to protect already exist as unclaimed contributor records holding that name.
- Authorship is already modelled: `Library.authors`, `LibraryVersion.authors`, `LibraryVersion.maintainers` are M2M to `users.User`. Auto-protection is a query, not a new flag.

| Option | Pros | Cons |
| --- | --- | --- |
| A. Reserved-name table only | One place to look when a name is refused; reasons are recorded; works for names with no account; reusable by #2489 | Second source of truth, maintained by hand, decays over time |
| B. Seeded protected accounts only | No new model, one code path, reuses plain uniqueness | Cannot record a reason; blocking a slur by creating a fake account is the wrong shape; adds placeholder accounts, which is the #2497 problem |
| C. Both, table as authority | Covers all three cases (real person with history, real person with no account, name nobody should hold); `allowed_user` lets the real owner claim it | Most machinery; two protection sources to explain |

**Proposal: C.** The derived author/maintainer set covers most names with no manual upkeep. The table covers the rest. Include `allowed_user` from the start, otherwise the first "that is my actual name" request needs a migration.

Follow-on: who administers the list, and what is the appeals path? The refusal message should say.

---

### 2. Rate-limit numbers and window

The risk is impersonation flip-flopping, not load. Redis is already the default cache backend, and there is no rate-limit library in the project. Two settings need choosing together:

**Change frequency (per account):**

| Option | Pros | Cons |
| --- | --- | --- |
| 1 per 30 days | Simple, near-eliminates churn | A typo needs a support ticket; an attacker only needs one change anyway |
| 3 per 30 days | Covers typo, rethink, real name change; flip-flopping hits a wall | Attacker still gets 3 moves, so it only works with a reclaim cooldown |
| 5+ or none | No friction | History table becomes decorative |

**Reclaim cooldown (before someone else can take a released name):**

| Option | Pros | Cons |
| --- | --- | --- |
| None | Namespace stays usable | Enables hand-off between colluding accounts |
| 30 days | Matches the change window, explains as one policy | Slight namespace cost |
| Permanent or 12 months | Strongest, safest for names tied to attributed commits | Burns namespace on dead accounts; an undo after a week reads as a bug |

**Proposal:** 3 changes per rolling 30 days, 30-day reclaim cooldown, and no cooldown when the *same* account reclaims a name it previously held. That carve-out is what removes most of the support load. Enforce from the `UsernameHistory` table, not a cache counter, since we need the rows for audit and a Redis flush would reset a counter.

Sub-question: should accounts with `is_commit_author_name_overridden` on get a stricter limit, given that flag carries the real blast radius?

---

### 3. Confusable normalization: advisory or hard block?

Two different cases are bundled here:

- **Exact canonical collision:** normalized forms match, e.g. `B3man.Dawes` and `Beman Dawes`. Deterministic and explainable.
- **Near collision:** edit distance 1 or 2, or a similarity score. A judgement call.

| Option | Pros | Cons |
| --- | --- | --- |
| Hard-block both | Nothing slips through | Guaranteed false positives; short names collide with everything; user cannot tell "taken" from "looks like something taken" |
| Advisory for both | No false-positive cost | A warning does not stop an attacker, and the attacker is the only reason the rule exists |
| Hard-block exact, flag near | Deterministic case enforced deterministically, fuzzy case goes to a human; flags feed #2489 | Needs a review queue and someone working it |

**Proposal: split.** Hard-block exact canonical collisions and reserved names. Treat near collisions as a flag, and gate the *consequence* instead of the name: allow the name, but block or review before `is_commit_author_name_overridden` can be enabled when the canonical form is near an author or maintainer. The ambiguous case then does not block normal profile edits, and the one path that enables impersonation stays closed.

Caveat: normalization is lossy. Folding leetspeak, separators and homoglyphs together means `l0-b0` and `LoBo` become the same name. That is the intended trade, but it should be decided rather than discovered. The error message should name the value that was collided with.

---

## Additional questions this raised

### 4. How do we reach a database-level unique constraint from current data?

`User.display_name` is `CharField(max_length=255, blank=True, null=True)`, and migration `0017_populate_users_display_name` backfilled it with `CONCAT(first_name, ' ', last_name)`. Users with no names got a single space, so a large block of rows likely share `" "`, plus genuine duplicates among imported contributors. A unique constraint will fail on that data.

Likely path: normalize blank and whitespace-only values back to NULL, since Postgres treats NULLs as distinct, then decide between deduplicating (overlaps #2497) or suffixing the real duplicates. Needs a data audit to size.

### 5. Does uniqueness cover unclaimed contributor accounts?

If it covers every row, an imported placeholder can block a real user. If it covers only claimed accounts, an unclaimed record holding a maintainer's name protects nobody.

Middle path: enforce across all rows, but let the `allowed_user` mechanism from Q1 hand the record over to the real person. Same problem as #2497 from the other side, so both should agree.

### 6. What character set and length is a Username?

`User.display_name` is 255 characters and accepts anything, which is a display name's policy. (`BaseUser` declares the same field at 100 and `User` redeclares it at 255, worth tidying.) The answer changes how hard Q3 is: restricted ASCII makes homoglyph folding nearly unnecessary, full Unicode makes it mandatory and never finished. A shorter cap also removes layout problems in profile cards and the homepage section. If we restrict the charset, we need a plan for existing names that do not conform.

### 7. Should attribution follow a rename, or freeze at import?

`CommitAuthor.name` stores the name as imported from git. `CommitAuthor.display_name` is a **property** that returns `user.display_name` whenever the linked user has `is_commit_author_name_overridden` set. `mailing_list/models.py` does the same for list posts.

So attribution resolves live on every read. There is no cache to bust and no re-import: saving a new Username retroactively re-attributes every commit and mailing-list post linked to that user, sitewide, immediately. That is the impersonation vector, and it is why validation has to run before the override takes effect.

Options: keep it live (status quo, matches what users expect from the control, leaves the vector open); snapshot the resolved name at import so history is frozen (safest, but a legitimate rename no longer fixes old attributions, which is a reason people want the field); or keep it live but re-verify when the override is toggled on.

Separately, `display_name` is read in `users/profile_cards.py`, `ak/homepage.py`, the public profile view (#2492) and `core/admin_filters.py`. Ordinary reads, but worth checking for fragment or queryset caching before we call the rename path safe.

## Related
- #2447 Profile edit (where the Username field lives)
- #2458 Edit profile: bio and tagline
- #2489 Bio moderation (candidate reuse of the reserved/blocklist)
- #2492 User profile (public view)
- #2497 Spike: duplicate user records & mismatched contributor identity

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with UserProfileForm.clean_username() and UserManager.find_contributor() in users/forms.py and users/models.py, then inspect migration 0017_populate_users_display_name. Trace CommitAuthor.display_name and the corresponding mailing_list/models.py behavior before resolving the open policy questions. Done means the acceptance criteria are implemented with coverage for uniqueness, protected names, renames, history, and attribution safeguards.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend, databases, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.