fix(identity): validate usernames and emails against every system they reach #172

Sammanfogat
supernaut sammanfogade 2 incheckningar från fix/identity-field-validation in i main 2026-08-02 19:54:30 +00:00
Ägare

Closes #151, closes #152. Part of the identity-validation epic.

The portal was more permissive than the intersection of the two systems it projects into, so a
value it accepted could be refused downstream. The worst case was silent: sign-up succeeded
completely, and the first sign-in to the git host then failed with a 500 — no error shown, nothing
logged, no metric moved.

Adds src/lib/identity-rules.ts as the single source of truth, read by sign-up, the account panel
and the client-side pattern attributes. Every rule is transcribed from upstream at the pinned
version and cited in the file and in its test, so an upgrade that changes a rule fails a test rather
than a user.

Rule From
leading lower-case letter, [a-z0-9._-] after; not UUID-shaped; not root identity provider 1.10.4
no [-._]{2,}, no trailing separator; 33 reserved names; .atom .gpg .keys .png .rss suffixes git host 16.0.1
40-character cap (the identity provider allows 64 — the tighter one binds) git host 16.0.1
email: trim, lower-case, 254 cap, WHATWG input type=email pattern identity provider 1.10.4

The duplicated ad-hoc email regex is gone from both modules.

Usernames are lower-cased visibly at the form, per the operator decision — the identity provider
does it silently anyway, so doing it visibly removes the divergence rather than hiding it, and keeps
the consent record and the provisioned account in agreement by construction.

Two deliberate refinements worth review:

  • The pattern attribute accepts either case. With scripting off the field cannot lower-case as
    you type, and a strict pattern would block Alice behind an unexplained browser message when both
    other layers would simply normalise it.
  • There are two username rules, not one. The intersection governs claiming a name. Addressing
    an existing account — the re-issue path — uses the identity provider's own looser rule, because
    applying the intersection there would lock out any account created before this change that holds a
    name the git host reserves. Those are precisely the people who need to recover.

#152 — the identity provider returns a clean 400 versus 409 conflicting_attributes, and that
distinction was being discarded into one generic failure. setPersonAttrs now returns
invalid / taken / error with the named attribute, and the account panel has matching copy in
both languages. 403/404 stay error deliberately: a denied write means a missing delegation, and
reporting that as bad input sends the user off correcting a correct address.

Verification

pnpm lint, pnpm check (0 errors), pnpm lang-check, pnpm test — 246 passing, up from 233.

Needs an operator check before this is fully effective

  • The "already in use" pre-check uses a raw search whose ACP could not be confirmed without touching
    production. It is three-valued and fails open: a refusal returns null, logs
    address-in-use pre-check unavailable, and the authoritative post-write check still runs. So it
    cannot break anything, but it may be inert until the grant is confirmed. That log line is the
    signal.
  • Organisation-name collision is only statically listed; catching one created later needs a git-host
    lookup at sign-up.
  • The consent-row audit in #151's definition of done needs production access. Going forward the
    mismatch cannot recur, since the consent row is written from the same normalised value.
Closes #151, closes #152. Part of the identity-validation epic. The portal was **more permissive than the intersection** of the two systems it projects into, so a value it accepted could be refused downstream. The worst case was silent: sign-up succeeded completely, and the first sign-in to the git host then failed with a 500 — no error shown, nothing logged, no metric moved. Adds `src/lib/identity-rules.ts` as the single source of truth, read by sign-up, the account panel and the client-side `pattern` attributes. Every rule is transcribed from upstream at the pinned version and cited in the file and in its test, so an upgrade that changes a rule fails a test rather than a user. | Rule | From | | --- | --- | | leading lower-case letter, `[a-z0-9._-]` after; not UUID-shaped; not `root` | identity provider 1.10.4 | | no `[-._]{2,}`, no trailing separator; 33 reserved names; `.atom .gpg .keys .png .rss` suffixes | git host 16.0.1 | | 40-character cap (the identity provider allows 64 — the tighter one binds) | git host 16.0.1 | | email: trim, lower-case, 254 cap, WHATWG `input type=email` pattern | identity provider 1.10.4 | The duplicated ad-hoc email regex is gone from both modules. **Usernames are lower-cased visibly at the form**, per the operator decision — the identity provider does it silently anyway, so doing it visibly removes the divergence rather than hiding it, and keeps the consent record and the provisioned account in agreement by construction. Two deliberate refinements worth review: - **The `pattern` attribute accepts either case.** With scripting off the field cannot lower-case as you type, and a strict pattern would block `Alice` behind an unexplained browser message when both other layers would simply normalise it. - **There are two username rules, not one.** The intersection governs *claiming* a name. Addressing an *existing* account — the re-issue path — uses the identity provider's own looser rule, because applying the intersection there would lock out any account created before this change that holds a name the git host reserves. Those are precisely the people who need to recover. **#152** — the identity provider returns a clean `400` versus `409 conflicting_attributes`, and that distinction was being discarded into one generic failure. `setPersonAttrs` now returns `invalid` / `taken` / `error` with the named attribute, and the account panel has matching copy in both languages. `403`/`404` stay `error` deliberately: a denied write means a missing delegation, and reporting that as bad input sends the user off correcting a correct address. ### Verification `pnpm lint`, `pnpm check` (0 errors), `pnpm lang-check`, `pnpm test` — **246 passing**, up from 233. ### Needs an operator check before this is fully effective - The "already in use" pre-check uses a raw search whose ACP could not be confirmed without touching production. It is **three-valued and fails open**: a refusal returns `null`, logs `address-in-use pre-check unavailable`, and the authoritative post-write check still runs. So it cannot break anything, but it may be inert until the grant is confirmed. That log line is the signal. - Organisation-name collision is only statically listed; catching one created later needs a git-host lookup at sign-up. - The consent-row audit in #151's definition of done needs production access. Going forward the mismatch cannot recur, since the consent row is written from the same normalised value.
supernaut lade till 2 incheckningar 2026-08-02 15:53:22 +00:00
The portal was the most permissive of the three systems a submitted username
and email are projected into, so a value it accepted could be refused
downstream. The worst case was silent: the identity manager accepts a name the
git host reserves, the account is created, the set-up email is sent, the
password is chosen, and the first sign-in to the git host 500s with nothing
shown, nothing logged and no metric moved.

Adds src/lib/identity-rules.ts as the single source of truth, holding the
INTERSECTION of all three systems, with each rule citing the upstream file it
was read from at a pinned version (Kanidm 1.10.4, Forgejo 16.0.1). Sign-up, the
email-change path, the resend path, the account panel and the client-side
`pattern`/`maxlength` attributes all read from it, so no rule is written twice
and the browser and the server cannot drift apart. The email regex that was
copied into two modules is now defined once.

Username, the intersection: lower-case, leading letter, at most 40 characters,
no doubled or trailing separator, none of the git host's reserved names or
reserved suffixes, not `root`, not anything UUID-shaped, and not a name Gitborg
itself already holds. Email keeps trim, lower-case and the 254 cap but takes the
identity manager's own WHATWG rule, which is the strictest of the three — it
refuses `användare@example.se`, worth knowing on a Swedish service.

The username is lower-cased VISIBLY in the field as the user types. The identity
manager lower-cases silently already; doing it in the open removes the
divergence rather than hiding it, and keeps the consent record — keyed on the
submitted spelling — in agreement with the provisioned account by construction.
Scripting off changes nothing but the timing: the `pattern` accepts either case
and the endpoint lower-cases authoritatively.

Each failure now gets its own message in both languages instead of one "check
your details" covering seven different faults, three of which are rules only the
git host has and no user could guess.

The resend path deliberately keeps the LOOKUP rule (the identity manager's own,
looser) rather than the sign-up intersection: an account created before this
change may hold a name sign-up would now refuse, and those are exactly the
people who need to recover it.

There is a test per downstream rule, each naming the system it protects and
citing its upstream source, so an upgrade that changes a rule fails a test
instead of a user.

Closes #151.
fix(account): say which identity write failed and why
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m16s
6ae1d66caf
`setPersonAttrs` collapsed every non-OK response into `{ ok: false, reason:
"error" }`, so "that address is malformed" and "that address already belongs to
another account" both rendered as "something went wrong on our side" — pointing
the user at us instead of at their input. Gitborg Auth draws the distinction
cleanly and even names the conflicting attribute; we were throwing it away.

It now returns a discriminated result — `invalid` / `taken` / `error`, plus the
attribute the conflict named — classified on the status codes Gitborg Auth
actually uses (409 for a uniqueness conflict, 400 for a refused value,
everything else ours). A denied write stays "error" on purpose: a missing
delegation on the service account must never be reported as bad input, or the
user is sent off correcting an address that was already correct. The account
panel gains matching copy in both languages, for the name and the address.

Two follow-on fixes:

Confirming an address consumes the single-use token BEFORE the write, which is
right against replay but means a rejected value burns the link. The common
rejection is now caught before a token is minted, by asking whether the address
is already in use. That check is three-valued — the service account may not be
allowed to search, and it must never be the reason a legitimate change is
refused, so "could not tell" proceeds and the write stays the authority. When a
write is rejected after the fact, the user is told plainly that the address
cannot be used and the link is spent, rather than being left to retry into the
same wall forever.

Sign-up logged only a thrown network error, never a refusal, so an entire class
of rejected input produced no log line and never raised the provisioning alert —
the same blind spot the alert exists to close. Every non-OK response on that
path is now logged, under the prefix the alert already matches.

Closes #152.
supernaut tvångsskickade fix/identity-field-validation från 6ae1d66caf
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m16s
till ad9929e841
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m23s
2026-08-02 19:52:07 +00:00
Jämför
supernaut sammanfogade incheckning eb01176110 till main 2026-08-02 19:54:30 +00:00
supernaut tog bort grenen fix/identity-field-validation 2026-08-02 19:54:30 +00:00
supernaut refererade denna ändringsförfrågan från en incheckning 2026-08-02 20:10:19 +00:00
supernaut refererade denna ändringsförfrågan från en incheckning 2026-08-02 20:10:54 +00:00
Logga in för att delta i denna konversation.
Inga granskare
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Förfallodatumet är ogiltigt eller utanför gränserna. Använd formatet "åååå-mm-dd".

Inget förfallodatum satt.

Beroenden

Inga beroenden satta

Referens
bitborg/bitborg-web!172
Ingen beskrivning angiven.