История коммитов

.
refactor(profile): ask permissions instead of the rights number
Fifty-odd comparisons against users.rights become twelve permissions the profile declares.
The one that repeated ten times — 'rights < 7 hides an account awaiting confirmation' — is
users.unconfirmed.view now, and the rest name what they were actually about: editing
somebody else's profile, changing their password, resetting their settings, reading the
notes the staff keep, reading an address its owner hides, and the bans.

'Is my number bigger than theirs' was the other half of every one of those checks, and it
is the level of the roles now, next to the permission. The guards and the buttons leading
to them ask the same question at last: the Edit button appeared for a supervisor and for
an administrator strictly above the target, while the guard behind it let any holder edit
a peer, and the Delete button asked for a number the screen it leads to no longer checks.

The ban gets a shape. Which section a ban may be applied to came from matching the number
of the section against the number in users.rights — 3 was the forum because 3 was 3 — and
that mapping is now one table in BanAccess, from the kind of ban to the permission it
needs. A kind nobody declared is refused rather than allowed; the numbers 12, 14 and 16
belonged to access levels nobody documented, no form has offered them for years, and
users.ban.downloads is not declared because nothing would ever check it. How long a ban
may last follows the same permissions: a day for whoever may only ban one section, a
month for the general ban, longer for whoever may also lift one.

'Position on the Site' leaves the profile editor. It wrote users.rights directly, and
since the roles arrived that column is computed from them — the radio buttons were
writing to a mirror. The block is a link to /admin/users/{id}/roles, which is where roles
are handed out.

What is left in the module for later: the entries of the admin club count as activity by
the number, because who may enter the club is the guestbook's own check and becomes a
permission when that module is converted, and the reads of the profile owner's own rights
— the label under the nickname, the staff smilies of an old post, 'no karma for the
administration' — which need the column until stage 6 finds it a replacement.

An installation that has already run auth:apply-default-permissions needs it once more
with --force.
.
refactor(forum): ask permissions instead of the rights number
Seventy-odd comparisons against users.rights become eight permissions the forum declares.
What used to read as 'rights === 3 || rights >= 6' now says forum.topic.moderate, what read
as 'rights >= 7' says forum.deleted.view, forum.curators.manage or forum.poll.voters.view
depending on which of the three it meant, and the two 'rights === 9' checks say
forum.topic.destroy and forum.post.destroy.

The curator finally has a shape. The old code gave a curator the number of a forum moderator
for the length of a request, which made them a moderator of the whole forum for anything
that looked at the number afterwards. A curator is now allowed forum.topic.moderate for one
subject — the topic they were appointed to — through a voter of the module. That second
argument of can() existed from the start for exactly this.

Two of the defaults sharpen on purpose. Moderating the forum goes to the forum moderator,
the super moderator and the administrator, not to whoever had a number above zero: the
downloads and the library moderators were never forum staff, and roles are the point at
which those stop being the same job. They keep the staff smilies, which is what the number
really bought them there.

'Topics by the period' showed the deleted ones to the supervisor alone while every other
listing showed them from administrator up; it asks the same forum.deleted.view now.

Posting into a closed topic and posting into a deleted one were one condition and are two
now, each asking the permission it is about.

What is left in the module for later: the checks bound to the mod_forum config, which move
when the config keys become permissions of the guest and the user roles, and the reads of
the author's own rights — the staff smilies of an old post, the label under a nickname —
which need the column until stage 6 finds it a replacement.

An installation that has already run auth:apply-default-permissions needs it once more with
--force.
.
fix(auth): collect the voters and permissions declared by modules
The tags of the three extension points came from instanceof rules in the core services file,
and such a rule reaches the services declared in that same file. A module implementing
PermissionProviderInterface or AccessVoterInterface compiled fine and was never asked
anything: its permissions never reached the role editor, so nobody could grant them, and a
voter of its own would never have voted.

They are registered on the container builder now, before anything is loaded, which is what
makes them apply to the modules as well. Two tests hold the line: one that a permission
declared by a module is collected, one that nothing carries a tag twice — the same tag
applied by both mechanisms would have every voter vote twice.

Found while converting the forum, on the two permissions the admin module declared one
commit ago.
.
refactor(admin): gate the panel by permissions, not by the number
Every check of the panel now names what it is about. The gate of the panel asks
admin.access, the gate of what changes the site as a whole asks admin.settings.manage,
destroying a forum section with its content and emptying the hidden lists ask permissions
of their own, and the address of a visitor is shown to whoever holds users.origin.view.

The sign-in screen of the panel asks the same admin.access, about the account that just
proved who it is rather than about the visitor of the request: nobody is signed in at that
point, so the roles of that account are resolved on the spot.

Deleting an account keeps its rule — nobody deletes somebody standing above them — but the
standing is now the level of the roles rather than a comparison of two numbers, the same
rule the role editor already applies.

One nuance is dropped on purpose. The lists of hidden topics and posts used to compare the
visitor's number against the author's, so an author with more of it kept their address
hidden. It now takes users.origin.view like everywhere else: the permission is granted from
super-moderator up, which is a narrower audience than the old rights >= 3, and the check
cost a role lookup per row.

UserRights, the enum of the magic numbers, is gone with the last of its callers.
.
refactor(system): ask permissions instead of the rights number
The first of the call sites the roles were built for. The core no longer compares against
users.rights: the debug details and the developer panel ask system.debug.view, the antiflood
delay asks system.antiflood.relaxed, and the exact page a visitor is on is shown to whoever
holds users.origin.view.

Two of these change who sees what, on purpose. Stack traces used to reach anybody with a
number above zero and the panel only administrators; both now answer the same permission,
granted by default to the administrator alone — a moderator has no business reading a stack
trace. And 'is an administrator around', which the adaptive antiflood mode asks, now means
'holds a role that was granted', the same definition the staff list uses.

An upgraded site needs its built-in roles to carry these keys, or a moderator would quietly
lose what the number used to give them. DefaultPermissions is that matrix, written as the
checks are converted and read by two callers that must not disagree: the seeder creating
the roles of a fresh installation, and auth:apply-default-permissions bringing an existing
one up to the same set. The command only ever adds, and is guarded like the other data
migrations — a site that revoked a default on purpose should not have it handed back by an
absent-minded second run. After an update that declares new permissions, run it with
--force.
.
feat(admin): manage roles and hand them out
Adds the two screens the role model was missing: /admin/roles, where a role carries the
permission keys the code checks, and /admin/users/{id}/roles, where an account is given a
role — with an end date, for moderation that is meant to run out on its own.

The matrix is grouped by module and lists what the installed modules declare. Keys nothing
declares — a module switched off, a pattern such as forum.* — are shown apart and carried
over untouched on save: dropping what the editor could not draw would quietly undo what a
site had granted. Keys the catalogue does not know are refused, so a request made by hand
cannot invent a permission.

The level is what keeps the editor from being a way up. Somebody who may edit roles hands
out what they hold, never what stands above them: a role above their own level is listed
but not opened, a level above their own is refused, and the roles of an account outranking
them are out of reach. Without that, admin.roles.manage — which the seeder grants to the
admin role — would read as 'any administrator may make themselves supervisor'.

A role at supervisor level is allowed everything by SuperAdminVoter, so no matrix is drawn
for it and a save leaves its permissions alone. The screen says as much instead of showing
checkboxes that nothing reads, and the rule itself now lives in one place,
SystemRole::grantsEverything(), which the voter and the panel both ask.

/admin/staff groups by role instead of comparing against rights >= 1, and the sidebar
counts the accounts holding a granted role. Somebody holding two roles appears under both,
which is the point of roles and something one number could not express.

users.rights is recomputed after every change, so the checks still comparing against it go
on working while they are converted module by module.

The new strings arrive translated into Russian. Regenerating the templates also picks up
the strings of the two previous commits, which never ran the scanner: the names of the
built-in roles, the permission labels, and the leftovers of the CSRF and validator work.
.
feat(auth): convert access levels into roles
Gives every member of staff the role their users.rights value stood for, and keeps
that number in step with the roles from now on.

Only accounts above zero get a row: the role everybody has is applied without
one, so a site with a hundred thousand accounts comes out of this with a handful
of rows rather than a hundred thousand.

users.rights does not go away yet. Three hundred checks across the modules and
the templates still compare against it, and they cannot all be rewritten in one
commit without leaving the site broken in between — so the number stays, computed
from the roles instead of being the source of truth. Granting a role updates it,
and every one of those checks goes on working while they are converted one module
at a time.

The undocumented values are the reason this is not a straight lookup. The column
is a tinyint and checks like `rights >= 1` gave 1, 2 and 8 meaning too, so
accounts holding them exist. Each is mapped to the closest role below it, which
never grants more than the account had, and every one of them is listed in the
report for somebody to look at.

The numbers are written to a file before anything else happens. The mirror
recomputes the column from the roles afterwards, so 8 becomes 7 and the original
is otherwise unrecoverable.

Running again leaves accounts that already have roles alone: somebody may have
arranged them by hand after the first run, and a second run must not undo that.
--reset redoes them anyway, for when that is what is wanted.
.
feat(auth): add roles and permissions
Replaces one number with a model that can express more than one thing at a time:
roles carrying permission keys, and a chain of voters deciding what those keys
mean for a given request.

users.rights could only say how much authority somebody had, on a single scale.
A forum moderator and a downloads moderator were 3 and 4, and nothing could
describe someone who was both. Every check was a comparison against a magic
number, so what a role could do was spread across three hundred call sites
rather than written down anywhere.

The built-in roles keep the numeric value they stand for, so the old column can
go on answering while the call sites move over. Only the 'user' role is applied
without a row per account, which is what keeps user_roles small on a site with a
hundred thousand of them: the table holds the exceptions, not the rule.

Three voters arrive with it. Roles allow; a ban denies, and a Deny outranks an
Allow, which is how a ban now beats an administrator — the previous code got the
same effect by zeroing the number in memory, an approach that only worked while
everything read that one number. Supervisor level allows everything, so a
mistake in the permission matrix cannot lock a site out of its own admin panel.

Who a visitor is and what they may do are resolved separately, in that order.
Guests go through it too: anonymous visitors have a role, and it is what will
decide whether the forum and the library are open without signing in.

can() finally answers truthfully and is available to templates.

Existing installations need auth:upgrade-schema before this code serves a
request: the roles are read on every one, including a guest's.
.
feat(admin): show how many accounts still use the old password hash
Accounts move off the old scheme as their owners sign in, so what is left is the
people who have not come back. Without somewhere to see that number, support for
the old scheme eventually gets removed on the assumption that it is empty — and
the accounts it was still holding up become unreachable.

Shown as a Security section on the system check page, as a count against the
total. It is informational, not a fault: nobody has to do anything about it, and
resetting those passwords is exactly what this design avoids.

The query lives next to the verifier that recognises a single value, since the
two share the same knowledge and are meant to be deleted together.
.
feat(auth): throttle sign-in attempts and stop generating guessable codes
Guessing at the sign-in form was limited by users.failed_login: a counter on the
account, which made it possible to lock any account out by typing a wrong
password for it three times. It also counted nothing at all when the guesser
walked through logins that do not exist.

Attempts are now counted in the cache under two keys at once — the login being
tried and the address trying it — so neither rotating addresses against one login
nor walking through logins from one address gets around the limit, and failing on
somebody's behalf runs the guesser's own address out first.

Two thresholds, because they answer different problems: a verification code after
three failures stops a script while barely inconveniencing somebody who mistyped,
and a wait after ten stops a script that solves verification codes. The wait grows
with each further failure, so a run that keeps going becomes slower rather than
merely postponed. A refused attempt does not extend the wait, or hammering the
form would keep the real owner locked out indefinitely.

The confirmation codes for registration and for an e-mail change were built with
uniqid(), which is derived from the clock: knowing roughly when an account was
created narrows the possibilities to something a script can walk through. Same
for the CSRF token, where a guessable value defeats the whole check. All three now
come from the same random generator as every other secret here.

users.failed_login is no longer read or written; the column goes with the rest of
the legacy ones.