Skip to content

Give members opaque usernames - #29

Merged
JeroenDeDauw merged 3 commits into
masterfrom
feature/opaque-member-usernames
Sep 2, 2026
Merged

Give members opaque usernames#29
JeroenDeDauw merged 3 commits into
masterfrom
feature/opaque-member-usernames

Conversation

@JeroenDeDauw

@JeroenDeDauw JeroenDeDauw commented Sep 1, 2026

Copy link
Copy Markdown
Member

TL;DR: we jumped through a lot of hoops to hide usernames since they contained emails which need to stay private. We even have some open issues and PRs since there is a large amount of places to block access to if we want usernames to be inaccessible to Members. This PR is the simpler approach of avoiding emails in usernames so we don't need all that other stuff. Users get a random name instead.


Member accounts were named after the address they were admitted with, so every enumeration defence in
this extension worked around that name, and each release found another place where MediaWiki prints
one.

Members now get an opaque name: Member plus six characters drawn with random_int from a 32-symbol
alphabet, checked for availability against the primary database rather than a replica, and redrawn on
a collision. Names are never reused, since a removed member's account stays. The address lives only in
the account's confirmed email and in the roster, which is already how the extension looks members up:
a code login for a known address lands in the account the roster names, and an address the roster has
never seen gets an account of its own.

Where to look hardest: MediaWikiUsernameMinter (the primary read and the retry), the two wrappers in
RegistrationHandler::nameTheMembersSingleSignOnAdmits(), the shape check in
SsoAuthorizationHandler::admitANewAccount(), and the two selections in OpaqueNameUpdate.

Single sign-on

The PluggableAuth plugin settles on the username before this extension is consulted. OpenIDConnect
gives a say over both the address and the name, in that order within one login: the extension wraps
OpenIDConnect_EmailProcessor to learn the address the plugin resolved — not always in the token
payload a processor is handed, since the plugin falls back to the userinfo endpoint — and
OpenIDConnect_PreferredUsernameProcessor to mint an opaque name when the allowlist admits that
address. Processors the wiki configured itself run first and are kept; a login the allowlist does not
admit keeps the plugin's name.

Other plugins offer no such hook, so a new allowlisted login arriving through one is refused rather
than provisioned under an identifying name: the member sees PluggableAuth's authorization-failed page
and the MemberAccess log channel says why. Members who already have an account are admitted by the
roster as before, whatever the plugin. Holding single sign-on to the allowlist therefore requires
OpenIDConnect; the README states this under platform requirements, with the settings that bypass the
hook (UseRandomUsernames, a per-provider processor, MigrateUsersByUserName). MigrateUsersByEmail
does not bypass it — it attaches the login to an existing account, which the roster then judges — and
is what joins the code-route account and the single sign-on login of one address.

Removed

The @ in $wgInvalidUsernameCharacters, the $wgUserrightsInterwikiDelimiter flip, and the rename
step of member removal — removal is now: forget the roster row, invalidate the address, invalidate the
sessions. RemovalResult::ReservedNameTaken, RemovalFailed and the reserved_name_taken and
removal_failed REST error codes went with it; the admin panel, their only consumer, no longer
receives them.

Kept as defence in depth

The reader-group revocations and provenance rule, the password veto and password-reset filtering, the
blocked special pages and API modules, and the newusers, block and renameuser log restrictions —
the last because the migration renames through RenameuserSQL, whose log entry names the old username.

Migration

A LoadExtensionSchemaUpdates callback, run by every update.php, renames roster members whose name
the extension did not mint (single sign-on members carry their provider's name, with no @ in it) and
reader-group accounts with no roster row whose name holds an @; a reader-group account with neither
is left alone, since a wiki may point the group at one it already had. Members cannot edit, so the
rename touches the account, its actor row, its log entries and any block on it; the old name survives
only in the restricted rename log, and nothing reverses it. It runs as a MemberAccess system user,
taking over an existing account of that name and reserving it from then on; it logs by user id, and on
failure by exception class alone, since a database error message carries the statement and with it
the name. A second run finds nothing. User: pages keep their old titles and core logs both names at
debug level; the README's update.php paragraph says both.

Verification

846 tests, up from 763 on master; phpcs and phpstan clean; every new test seen failing against a
mutation of the code it covers. In a browser on MediaWiki 1.43: a first login minted Member LWDOHG
with the address confirmed and the reader group, the same address returned to it, update.php
renamed two legacy accounts keeping address, confirmation and group, and Special:ListUsers stayed
refused.

Known limitation

memberaccess_member.mam_email has no unique index. Two first logins for one address in flight — two
browser tabs — each get an account and a roster row, as does an address admitted by code that later
arrives by single sign-on without MigrateUsersByEmail. The index alone would leave the losing
account roster-less and refused at every later login; fixing it also needs the ignore() dropped
from the roster insert and a dedupe step in the updater.

Also omitted: a configurable name prefix, forcing MigrateUsersByUserName off from code, and moving
User: pages in the migration.

Related

Follows-up to #23 and
#25. Issues
#22,
#24 and
#26 stay open: each closes one place that
prints a username, now defence in depth rather than the only defence.

AI-authored — Claude Code, Opus 5 (max) implementation and review fixes, Fable 5 (max) design and review; detailed spec from @JeroenDeDauw, no revisions; diff not yet human-reviewed; full suite, phpcs and phpstan locally against MediaWiki 1.43, every new test seen failing against a mutation of the code it covers, login and migration clicked through in a browser, CI green.

Production notes

Design and the fix list by Fable 5 (max); implementation and the fixes by Opus 5 (max) in delegated sessions; a reviewer-only pr-review pass (Opus 5 (max)) between the two, whose nine findings are the fixes. The description went through a blind judge in a fresh context. Browser verification ran on a standalone MediaWiki 1.43, not on this environment's dev wikis.

@codecov-commenter

codecov-commenter commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.75510% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.74%. Comparing base (d05e3ee) to head (a3fb236).

Files with missing lines Patch % Lines
src/MemberAccessExtension.php 36.84% 24 Missing ⚠️
src/Persistence/OpaqueNameUpdate.php 92.59% 6 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master      #29      +/-   ##
============================================
- Coverage     86.77%   86.74%   -0.03%     
- Complexity      594      647      +53     
============================================
  Files            67       71       +4     
  Lines          2072     2226     +154     
============================================
+ Hits           1798     1931     +133     
- Misses          274      295      +21     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JeroenDeDauw
JeroenDeDauw force-pushed the feature/opaque-member-usernames branch from f8079db to 529a5de Compare September 1, 2026 18:24
Member accounts were named after the address they were admitted with, so every enumeration defence in
this extension worked around that name, and each release found another place where MediaWiki prints
one.

Members now get an opaque name: `Member` plus six characters drawn with `random_int` from a 32-symbol
alphabet, checked for availability against the primary database rather than a replica, and redrawn on
a collision. Names are never reused, since a removed member's account stays. The address lives only in
the account's confirmed email and in the roster, which is already how the extension looks members up:
a code login for a known address lands in the account the roster names, and an address the roster has
never seen gets an account of its own.

Where to look hardest: `MediaWikiUsernameMinter` (the primary read and the retry), the two wrappers in
`RegistrationHandler::nameTheMembersSingleSignOnAdmits()`, the shape check in
`SsoAuthorizationHandler::admitANewAccount()`, and the two selections in `OpaqueNameUpdate`.

### Single sign-on

The PluggableAuth plugin settles on the username before this extension is consulted. OpenIDConnect
gives a say over both the address and the name, in that order within one login: the extension wraps
`OpenIDConnect_EmailProcessor` to learn the address the plugin resolved — not always in the token
payload a processor is handed, since the plugin falls back to the userinfo endpoint — and
`OpenIDConnect_PreferredUsernameProcessor` to mint an opaque name when the allowlist admits that
address. Processors the wiki configured itself run first and are kept; a login the allowlist does not
admit keeps the plugin's name.

Other plugins offer no such hook, so a new allowlisted login arriving through one is refused rather
than provisioned under an identifying name: the member sees PluggableAuth's authorization-failed page
and the `MemberAccess` log channel says why. Members who already have an account are admitted by the
roster as before, whatever the plugin. Holding single sign-on to the allowlist therefore requires
OpenIDConnect; the README states this under platform requirements, with the settings that bypass the
hook (`UseRandomUsernames`, a per-provider processor, `MigrateUsersByUserName`). `MigrateUsersByEmail`
does not bypass it — it attaches the login to an existing account, which the roster then judges — and
is what joins the code-route account and the single sign-on login of one address.

### Removed

The `@` in `$wgInvalidUsernameCharacters`, the `$wgUserrightsInterwikiDelimiter` flip, and the rename
step of member removal — removal is now: forget the roster row, invalidate the address, invalidate the
sessions. `RemovalResult::ReservedNameTaken`, `RemovalFailed` and the `reserved_name_taken` and
`removal_failed` REST error codes went with it; the admin panel, their only consumer, no longer
receives them.

### Kept as defence in depth

The reader-group revocations and provenance rule, the password veto and password-reset filtering, the
blocked special pages and API modules, and the `newusers`, `block` and `renameuser` log restrictions —
the last because the migration renames through `RenameuserSQL`, whose log entry names the old username.

### Migration

A `LoadExtensionSchemaUpdates` callback, run by every `update.php`, renames roster members whose name
the extension did not mint (single sign-on members carry their provider's name, with no `@` in it) and
reader-group accounts with no roster row whose name holds an `@`; a reader-group account with neither
is left alone, since a wiki may point the group at one it already had. Members cannot edit, so the
rename touches the account, its actor row, its log entries and any block on it; the old name survives
only in the restricted rename log, and nothing reverses it. It runs as a `MemberAccess` system user,
taking over an existing account of that name and reserving it from then on; it logs by user id, and on
failure by exception class alone, since a database error message carries the statement and with it
the name. A second run finds nothing. `User:` pages keep their old titles and core logs both names at
debug level; the README's `update.php` paragraph says both.

### Verification

846 tests, up from 763 on master; phpcs and phpstan clean; every new test seen failing against a
mutation of the code it covers. In a browser on MediaWiki 1.43: a first login minted `Member LWDOHG`
with the address confirmed and the reader group, the same address returned to it, `update.php`
renamed two legacy accounts keeping address, confirmation and group, and `Special:ListUsers` stayed
refused.

### Known limitation

`memberaccess_member.mam_email` has no unique index. Two first logins for one address in flight — two
browser tabs — each get an account and a roster row, as does an address admitted by code that later
arrives by single sign-on without `MigrateUsersByEmail`. The index alone would leave the losing
account roster-less and refused at every later login; fixing it also needs the `ignore()` dropped
from the roster insert and a dedupe step in the updater.

Also omitted: a configurable name prefix, forcing `MigrateUsersByUserName` off from code, and moving
`User:` pages in the migration.

### Related

Follows-up to #23 and
#25. Issues
#22,
#24 and
#26 stay open: each closes one place that
prints a username, now defence in depth rather than the only defence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JeroenDeDauw and others added 2 commits September 2, 2026 01:00
Core's RenameuserSQL::rename() opens an ATOMIC_CANCELABLE section that stays
open when a failure escapes it. The update's catch-and-continue then ran the
remaining renames on a connection holding that dangling section: depending on
the failure class they all failed with DBTransactionStateError, or were
reported renamed and rolled back wholesale when update.php's shutdown commit
refused the still-open section. Either way the catch's documented purpose --
one account's failure leaves the rest to be renamed -- held only for failures
thrown before the section opens, which is all the existing failure tests
exercised (the RenameUserSQL hook fires in the constructor).

Cancelling an outer section of our own in the catch discards the dangling
inner one and restores the connection, the same shape MediaWikiMemberRemover
already uses. The regression test fails inside the section through the
RenameUserPreRename hook, which core fires right after opening it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The graceful paths around mintUsername() caught RuntimeException, but
random_int() throws Random\RandomException, which is no RuntimeException. A
failing random source therefore skipped both refusals: a code login was
answered with MediaWiki's error page instead of memberaccess-auth-failed, and
on single sign-on OpenIDConnect catches the exception and puts its string on
the error screen for the visitor -- the outcome the catch exists to prevent.

Both boundaries now catch Exception, per the conventions on catch blocks
considering the whole call chain, and the minter interface documents the wider
contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants