Written by reading the top of the section and stopping, which is exactly
the failure the list exists to prevent. The fourteen it missed were the
tail: several boundary fixes, the 502 behind a proxy, the recovery code
spent twice.
The near-identical limited-role entries are one line naming the surfaces
rather than six lines saying the same thing, so the list stays scannable.
Same reason as the entry above it. Someone running one installation of
their own has no seats to cap and no provisioning to seed, so three
environment variables they will never set read as noise in the one section
of the file that is supposed to be nothing but things they must do.
The variables and projectsend:status still exist and still work. They are
documented where the people who need them will look.
CHANGELOG.md ships inside the zip and renders in the app, so it is read
mostly by people running ProjectSend themselves. An entry about seats a
host sells you, and screens that were absent on an edition the reader is
not running, is not news to them -- it is somebody else's product notes in
their release notes.
The feature is unchanged; only its account of itself moves.
Twenty-five entries of two or three sentences each is a reference, not a
summary. Somebody deciding whether to upgrade reads the first screen and
stops. The paragraph that was there named four things out of twenty-five
and buried the rest.
One line each, grouped, no jargon. The detail below is unchanged --
it is what you read once the list has told you which entry you want.
An installation brought over from v1 before the migration tool learned to
relabel carries $2a$ or $2b$ digests in users.password. All three bcrypt
labels name the same algorithm and password_verify() reads any of them,
but Laravel's hasher asks password_get_info() first, gets "unknown", and
throws before it looks at the password -- so the login form answers 500
for every migrated account while accounts created in v2 sign in fine.
Relabelling the stored digest is the whole fix. Four bytes change; salt
and digest are the same, so nobody resets anything and there is no mail to
send. Guarded on password_get_info() reading bcrypt afterwards, so a
truncated row is left visibly broken rather than quietly rewritten to no
effect.
$2x$ is left alone on purpose -- it asks for the pre-2011 handling of
bytes above 127, so relabelling it would lock out anybody whose password
is not plain ASCII.
The 500 itself is asserted, not just the repair, so nobody removes the
migration later on the grounds that bcrypt is bcrypt.
Reported by @pabloalvarez44 in #1706.
Asked for by the platform side, and the reason is better than
convenience. Their reconciler's rule is that it observes an end state and
never sends an instruction. `docker exec … php -r '…'` to reach a public
method is an instruction with the caller's argv in it, however harmless
the argv, and it would have been the first crack in that rule. A named
command is an observation, the same kind of thing as reading a directory
size.
`--json` for a machine, plain lines for a person. Nothing here is a
secret or a credential: every field is already visible to any signed-in
administrator, which is what makes it safe to read from outside the
container.
The counts come from SeatAllowance — the code that refuses the account
past the limit — rather than from a second query that agrees with it
today. Two counts that merely agree diverge eventually, over an inactive
account or a soft-deleted one, and the divergence reads as a billing
fault rather than a counting one.
Unlimited is emitted as null, with a test saying so, because the failure
if a reader takes it for zero is a customer on the most expensive plan
whose instance refuses to create a single client. The platform side
independently landed the same care on the emitting end, omitting the
variable rather than sending it empty.
It also answers the question that started all of this. Diagnosing why a
tenant ignored its bucket meant reaching into a container and calling
app() by hand; `projectsend:status` now says which capabilities the
edition grants, which is where that hunt began.
The last of the three. Enforcement is a database setting defaulting to
'none', and on a managed installation the only writers are whoever
administers it and the boot that creates them — so a policy meant to be
on from the start had nowhere to be written. A control plane calling in
afterwards leaves a window between the first account existing and the
policy covering it, and the first account is the one with every
permission.
The entrypoint already seeds an account from the environment. This seeds
the policy one line above it, so the administrator is born under the rule
rather than ahead of it. There is a test for exactly that ordering,
because the ordering is the whole point.
Seeded, never overridden. A value that won on every boot would take the
setting away from the person it belongs to — somebody who tightened it
would find it loosened again by a restart. So it writes only when nothing
has ever been stored, the same shape as `projectsend:admin --if-none`.
Two things that would have been easy to get wrong, both pinned:
'none' is the enum's own default, so Settings::get() cannot tell "stored
as none" from "never stored". Asking the accessor would have overwritten
an administrator who deliberately chose it. The command asks the table.
And it reads config rather than env() directly. `config:cache` stops .env
being read at all, which is how TRUSTED_PROXIES came to have no effect on
any web request while looking correct in the file.
Deliberately not a general PROJECTSEND_SETTING_<KEY> mechanism. Every
setting reachable from outside is one whose value depends on where you
look, and the blast radius of getting that wrong is the settings table.
One named key per setting that needs it.
The three new variables are documented in config/projectsend.php and not
in .env.example or the Docker Hub overview. Those two are written for
somebody running one installation for themselves, and a seat cap is not
a thing they have — FILES_WEB_SERVER_READABLE is in .env.example because
a self-hoster on cPanel genuinely meets that problem.
Opening user management on cloud (623ad68) left a managed tenant able to
create staff accounts without limit. This is the other half, and the two
belong in the same release.
max_clients and max_staff_users are numbers the platform sells and does
not enforce — grep finds them only being passed to screens. The
application is the only process that can count against them, so it
accepts the number from the environment and refuses to exceed it. That is
not the same as inventing a plan tier, which is what config/api.php
declines to do when it will not key a rate limit off billing: nothing
here knows what a plan is.
## One definition
staffUsed() and clientUsed() are public and are what the guards read. A
control plane showing "2 of 3 used" from its own query, beside an
application refusing the fourth from a different one, disagrees
eventually — over an inactive account, or a deleted one — and the
disagreement reads as a billing fault rather than a counting one.
## What counts, and the consequences somebody has to explain
An inactive staff account occupies its seat. Excluding it would make
deactivation a way around the cap rather than a way to revoke access,
since reactivating is one click. The cost is an awkward incentive —
deactivating is the safe removal and keeps paying, deleting frees the
seat and asks what happens to the files — and it is better explained than
hidden.
A client awaiting approval does not. Self-registration is open to
strangers, and counting a pending request would let anybody exhaust a
paid limit from the outside, turning a pricing tier into an availability
control. The seat is spent at approval, which is where the guard sits.
A soft-deleted account frees its seat, though not its address —
AvailableEmailRule holds that until erasure. So a seat can be free while
re-adding the same person is still refused, which is the address rule
rather than this one.
## Eight doors, eight tests
There is no single User::create() to guard. StaffAccounts::create()
covers both staff controllers, but a promotion takes a staff seat without
creating anything, a demotion takes a client seat, ClientProvisioning
serves registration and LDAP and social sign-in alike, and approval turns
an uncounted request into a counted client.
A cap is only a cap if every door asks, so there is a test per door and
each was verified to fail without its guard — eight red, with the two
"must not change" cases green either way. DownloadAllowance's shape for
DownloadAllowance's reason: the failure mode is one of them quietly not
asking, invisible from everywhere except the door that forgot.
projectsend:admin is deliberately uncapped and has a test saying so. It
is the recovery path, and anyone who can run it can also edit the
environment the cap comes from.
A managed installation's staff accounts were expected to arrive from
outside it, so users.manage was Community-only and /users, /roles and
their API twins answered 404 there. The platform side spent a long
document designing its way around that gate; opening it is cheaper than
routing around it, and more honest about where the knowledge sits.
The division that settles it is the one managed storage already uses. We
do not manage a tenant's files from outside — a bucket is provisioned, a
scoped credential handed over, and what goes in it is the tenant's
business. Seats are the same kind of thing. A platform knows how many
staff accounts it sold; it does not know whether Alice should be an
Account Manager, and it certainly does not know where her files go when
she leaves. Capacity is the platform's, occupancy is the tenant's, and
the cap belongs in an environment variable rather than in a closed
screen.
The capability stays in front of the routes rather than being deleted.
It is currently true in both editions, but it is the seam an edition
difference has to travel through, and removing it would mean inventing
one again later.
Seven test files asserted the old rule, which is the tests doing their
job. Most flip. Two needed a different example instead: EnsureCapability
and AbilityCapability were both using users.manage to stand for
"Community-only", so they now use storage.configure and manage_updates —
keys that still are.
Two rationales half-expired and say so rather than being quietly
rewritten. CommentAuthors gave two reasons for being a setting rather
than a permission; the first was that roles are uneditable on cloud,
which stopped being true here, and the second — that `Everyone` includes
anonymous visitors, who have no role to hold a key — was always the
stronger and is now the whole of it.
The seat cap this makes necessary is the next commit, not this one. On
its own this change lets a managed tenant create staff accounts without
limit, which is why the two belong in the same release.
The linter has had this since it was written; the suite, which is the
expensive one, never got it. Two pushes landing together ran two full
suites to the end, and the earlier one was checking a subset of what the
later one checks.
Keyed on the ref, so main and a branch never cancel each other, and a
branch with an open pull request does not fight itself — push and
pull_request arrive under two different refs.
One consequence worth stating rather than discovering: on main an
intermediate commit can end up with no run of its own when two pushes
land close together. That is the right trade when what is being verified
is the state of the branch, but it is not free — a bisect or a release
audit that needs a particular commit's own green tick needs that commit
pushed on its own.
The condition was on the step. A skipped step has still had a machine
allocated for it, and Actions bills per job that runs — so `issue_comment`
firing on every comment in the repository meant every "merged, thank you"
on a pull request, and every comment on an ordinary issue, started a
runner to decide it had nothing to do.
Of the last forty runs, twelve were exactly that. Yesterday's twenty
merges each drew a comment, and each comment drew a runner.
Moved up to the job, where a false condition means no runner at all, and
narrowed with `issue.pull_request` so comments on plain issues stop
qualifying too. GitHub cannot filter `issue_comment` by body at the `on:`
level, so the job is the only place this decision can be made — which is
worth the comment beside it, because the obvious tidy-up is to push it
back down to the step it guards.
Behaviour is unchanged: the same two comment bodies still trigger a
check, and every pull_request_target still does.
Three things went stale, all of them describing the image rather than
selling it, which is the half of that page people act on.
The tag table's worked example was 2.1.0/2.1. The "what is in the image"
paragraph said one queue worker; there are two now, and the second is
there so that building a large zip cannot hold up every notification
email behind it — worth a sentence, since somebody counting processes in
`docker top` would otherwise wonder. And the storage line said local disk
or S3, which stopped being the whole list when Google Cloud Storage
arrived.
FILES_WEB_SERVER_READABLE is new in 2.2.0 and deliberately not in the
environment table. It exists for hosts where nginx and PHP run as
different users, which cPanel and Plesk do; in this image they are the
same user in the same container, so listing it would invite people to set
something that buys them nothing.
Cloud instances are sold seats rather than administering them, so the
tenant's own /users screens stay closed — capability:users.manage is
already Community-only — and a control plane creates, deactivates and
password-resets staff from outside. This is the key that plane gates on.
Only the declaration lives here, the same division StorageManaged and
Branding already use. Everything behind it is a module in the private
cloud-modules package.
Declared before that module exists, deliberately. A capability added
after a release is invisible to every image built from one, and that is
not hypothetical: StorageManaged landed 36 commits after v2.1.0 and has
never shipped, so a fleet with buckets provisioned, credentials scoped
and eight environment variables in place still writes every upload to
local disk — because the gate is here and the gate never left. Declaring
this one now is refusing to make the same mistake twice.
The seat *number* deliberately does not live here. There are no billing
or plan tiers in this application to key off, which is the reason
config/api.php gives for not inventing an installation-level rate limit,
and it holds for the same reason: the number lives where the plans do.
This capability says only who is in charge.
ModuleBoundaryTest grows the other half of its own rule. It filtered on
`api/v1/`, so a package claiming a route anywhere else passed — not
because that was sanctioned, but because nothing was looking, and
/platform/v1 is about to be somewhere else. What it polices now is
machine surfaces, the roots something other than a browser authenticates
to, with api/v1/modules and platform/v1 as the two sanctioned prefixes.
Written twice, because the first version was wrong in a useful way: it
policed every route and immediately caught community-modules' Custom
Assets screens. Those are a module doing exactly what a module is for,
through the host's session and capability middleware in plain sight, and
listing them would be the hardcoded URI list the test above it explains
it is avoiding. Web screens are not the boundary; trusted perimeters are.
Verified by making it fail: a package controller on platform/v2 is caught
and named.
Zip building moved onto its own queue, which a manual install's worker
has to be told about. update.sh repairs the service file and Docker is
unaffected, so the population left is somebody upgrading by hand who
skipped the release note — and for them the failure is the worst shape
available. Email keeps going out perfectly. Zip downloads never finish.
Nothing in any log says why, because nothing went wrong: the jobs sit on
a queue nobody is reading. The person who missed it has no reason to
suspect anything, so the notice has to go looking for them.
The application cannot see its own worker processes, only whether work
gets done, so the question is asked from the other end: was a build
requested that no worker ever picked up? That needs a record of when a
build *started*, which is what the new zip_downloads.started_at column
is — stamped before any of the work, so it says a worker had the row,
not that the row succeeded.
Two conditions, because either alone cries wolf. A build has waited past
five minutes and was never started, *and* no other build is in hand. The
second matters because one worker builds one archive at a time: a queue
behind a large build is a healthy queue, and its waiting rows look
exactly like abandoned ones until you notice something running. "In
hand" is bounded by the job's own timeout, so a worker that died holding
a build stops counting as alive an hour later.
The banner sits beside the stale-code one, on every staff page rather
than the dashboard alone, gated on view_system_info for the reason that
one already argues: a background worker not picking work up is a fact
about the machine, not a feature of an edition. It names the fix rather
than the symptom — "your worker command needs --queue=default,zips" —
because somebody reading that downloads are not being processed still
has to work out what to do about it.
Eight tests, covering both halves of the discrimination rather than just
the happy one: a queue waiting behind a live build stays quiet, and a
build held by a worker that died does not.
Translated into all sixteen locales in the same commit, since a release
is close and a banner nobody can read is worse than none.
Checked on screen as well as in assertions, with a real stalled row on
the dev stack: the banner renders, wraps, and reads correctly.
Everything merged today landed in English, which is the deliberate trade:
a feature never waits on a language nobody in the room speaks. This is
the pass that settles up.
Fourteen keys, sixteen locales, 224 entries. Appended rather than sorted
in, matching how the previous passes left these files, so the diff is
additions and one trailing comma per catalogue and nothing else.
One of the fourteen was a bug rather than a gap. The scoped
expired-files note was written with a `’` escape in the TSX, so the
scanner read the raw source and the runtime read the interpreted string:
two different keys for one sentence, and a catalogue entry for either one
would never have matched the other. The apostrophe is now a literal
character, which is what every other string in these files does.
Checked mechanically — every :placeholder survives, no plural pipe count
moved, `projectsend:erase-account` is intact in the two messages that
name it — and then read on screen, because a file that parses is not
evidence that a sentence fits its button. Settings -> Descargas renders
its label, its paragraph and its help text in Spanish with no overflow.
Orphans left alone at 250. The ten that touch today's subjects were
checked one by one and every one is a false positive of the kind the
skill warns about: `Expired files` is WIDGET_LABELS data, `Uploader` is a
role name from the database, `Page Expired` is laravel-lang's.
Nobody hands out reach they do not hold either
Two resolutions against branches that landed first. #1678 and this one
each add a constructor property and an import to StaffAccounts, so both
are kept. And #1702's merge note called this one exactly: its
"converting an account to staff cannot hand out clients either" case
promoted a stranger client, which #1702 now refuses at 404 before
validation runs. Pointed at a client the actor holds, as that note
proposed, so the request reaches the assigned_clients rule the case is
actually about.
Delete an account and dispose of its content in one transaction
Resolved the conflict with #1678 the way that PR's merge note predicted:
the erasure stamp goes inside the new transaction, so a deletion that
rolls back cannot leave a live account carrying a date on which it would
be erased.
Finalise each chunked upload once, under a per-session lock
Resolved a trivial conflict in ChunkedUploadsTest: this branch and
d7e639b both append tests to the end of the file, so both are kept.
Let a deleted account's email address come back into use
Closes#1648, and with it the last open item of #1647's audit of unique
indexes on soft-deleting tables.
Resolved a trivial conflict in both ClientsControllers: this branch and
today's e7b5b6a each add a constructor property at the same line, so both
are kept. Nothing else overlapped.
Two things found by checking #1696 and #1699 -- open branches carrying
the same fixes I wrote this morning -- against what I actually shipped.
**topClientsByStorage was scoped with the wrong question.** 4b8220a
narrowed it with StaffLibraryScope::files(), which is right for the two
widgets that name files and wrong for the one that names clients: a
stranger client's upload can sit legitimately inside a scoped viewer's
library, shared with a group one of their own clients belongs to. So the
file was theirs to read and the uploader's name was not theirs to see.
Measured: "Stranger Client Ltd", on nobody's roster, ranked on a scoped
dashboard. assignableClientIds is what the widget is actually asking, and
it is what #1699 used. Their version was right and mine was not.
**The client guard is one method now, not eight copies.** #1696 wrote it
as a private guardTarget() rather than repeating viewer-resolve plus
abort at each site, which is better, and this is a change whose whole
argument is that a rule stated in many places drifts. Behaviour is
identical; the eight sites now read as one rule.
The published document reorders a 404 below a 422 on one path. Scramble
reads abort_unless out of a method body but not out of a helper it calls,
so the 404 now comes from route model binding instead of from the inline
abort -- same response, different position. #1701's body names this trap;
worth knowing it costs ordering and not content.
Credit where it is due: both come from denkfabrik-li's #1696 and #1699,
which were open while I was writing the same fixes. Those two are closed
against this and against e7b5b6a, 4b8220a and 67e9204.
Closing the one thing 4b8220a left open, and the reason it was left: the
expired-files widget reads StaffLibraryScope::files(), and
File::scopeVisibleToClient ends in notExpired(), so a client-scoped
viewer sees only their own expired uploads and never a client's.
Widening that would mean a library query that keeps expired rows, and
scopeVisibleToClient is the single source of truth for client file
access -- the highest-stakes function to go changing for a dashboard
widget. So the boundary stays where it is and the widget stops
overstating itself.
That matters more here than on the two widgets beside it. "Largest
files" showing the largest files somebody can see is still true from
where they stand; a warning about what is due to be deleted, quietly
narrower than it looks, reads as "nothing to worry about" on behalf of
files it never looked at. So this one gets a `scoped` flag from the
server, a title of "Your expired files", a line saying clients' files
are not listed, and an empty state that says none of *your* uploads have
expired rather than that nothing has.
Retitled at the call site rather than in WIDGET_LABELS, because the same
widget means two different things to two viewers and only the server
knows which one is looking.
Checked in a browser for both, not just in the assertions: the scoped
dashboard renders "Your expired files / Files you uploaded. Your
clients' files are not listed here. / None of your uploads have
expired.", with no console errors, and an unscoped administrator's is
unchanged.
Splitting zip builds onto their own queue (92a132d) left manual installs
carrying the one job the release note has to do, and the failure it
produces is the worst shape available: a worker still watching only
`default` sends every email cheerfully and finishes no zip downloads,
with nothing in any log to say why. An upgrade note is a poor place to
put that, because it is read on a laptop and needed on a server.
update.sh already finds projectsend-worker.service, so it now reads the
unit's ExecStart and offers to add --queue=default,zips, keeping a copy
of the original beside it. Before the restart, so the worker comes back
on the command it is going to keep.
Only the unambiguous case is rewritten: a queue:work line with no
--queue at all, which consumes `default` and nothing else. A unit that
already names its queues is somebody's deliberate arrangement, possibly
with a second worker for zips, so that one is described rather than
edited — and one that already includes zips is silently left alone.
Exercised against five unit shapes rather than reasoned about: the plain
command is rewritten and backed up, a declined prompt leaves it untouched
with a warning, a unit already naming zips is a no-op, a custom queue list
without zips warns instead of editing, and a unit that is not queue:work
at all is ignored. The sed itself would double-append if it ran twice;
it cannot, because the --queue= guard above it returns first, and both
read the same first ExecStart line.
The last piece of the #1687 follow-up. BuildZipDownloadJob allows itself
an hour, every shipped topology runs exactly one worker, and everything
shares the default queue -- so one large archive delayed every
notification email queued behind it. The size cap and the
one-build-per-person rule bounded that in July; they did not remove it.
onQueue('zips') in the constructor rather than at the dispatch site, so a
second caller cannot forget it. Both images grow a worker for it:
compose.yaml gains worker-zips, supervisord gains [program:queue-zips],
and the existing worker in each narrows to --queue=default. --tries=1
there matches the job, which records its own failure rather than being
retried.
The part that needs care is the manual install. A worker whose command
still says plain `queue:work` consumes `default` only, so it would send
email happily and never finish a single zip, with nothing in any log
saying why. INSTALL.md's unit now reads --queue=default,zips -- one
worker watching both, which is right for most installations -- and says
what happens if you leave it off, with the two-worker split offered for
anyone who would rather keep the two kinds of work apart. CHANGELOG
carries it as an upgrade note, since it is something to do rather than
something that was done.
Verified in the dev stack rather than only in a test: dispatched a build
and watched worker-zips take it while the default worker stayed idle.
This overturns something #1701 decided, so it should say so. That PR
closed the membership hole and left GroupsController::update and
destroy installation-wide on purpose, on the grounds that managing the
group object is a different question from managing who is in it.
What decides it is a measurement that was not in front of that decision.
An assignment to a group is how its members reach a file, so deleting a
group revokes that access for every member. Measured before this guard,
with a client-scoped role holding the group permissions:
stranger client can read the shared file true
PATCH /groups/{stranger group} 302, renamed
DELETE /groups/{stranger group} 302, group gone
stranger client can read the shared file false
So a staff member who may not add somebody to a group out of their reach
could delete it out from under the people already in it. That is not a
gentler version of the membership rule, it is a harder one, and the two
sitting on opposite sides of the same boundary was the odd part.
StaffLibraryScope::allowsGroupChange is the reach half of
allowsGroupMembership on its own, since no client appears in this
question -- one predicate, two callers, rather than a second statement of
it. Both surfaces take it, at 404, matching the membership guards.
A group that shares nothing beyond the actor's library still passes, so a
group they created or one holding their own clients stays theirs, and
unscoped staff are unaffected by construction.
The API document moves a 404 above a 422 on two paths. Both already
documented the 404 -- route model binding produced one -- and Scramble
orders responses by where they appear in the method, so the guard landing
before the validate() call is the whole of the change.
A full parallel run failed once and passed on retry while I was doing the
#1703 follow-up. A flake is worse than a steady failure: it trains you to
re-run rather than look, and it quietly weakens every green run reported
beside it.
Upload parts are real files under storage_path('app/uploads-tmp/{session_id}'),
not a faked disk. Every parallel worker gets its own database, so session
ids restart at 1 in each of them, and two workers writing parts land in
the same directory. On top of that ChunkedUploadsTest's afterEach deleted
the whole tree rather than its own share, for everybody. Six test files
write parts, so this was reachable without anything I added.
The same collision exists inside one worker: RefreshDatabase rolls back,
so ids restart at 1 for every test, and a run that died before its
cleanup leaves parts sitting under the id the next test is about to
claim.
LocalPartStore now reads its root from config, defaulting to exactly
where it always was -- an installation with UPLOAD_PARTS_PATH unset
behaves identically. Tests\TestCase points it at a per-worker directory
and empties that directory per test, which closes the cross-worker, the
cross-run and the intra-worker versions together. ChunkedUploadsTest's
cleanup and its two directory assertions read the configured root rather
than the hardcoded path, so they can no longer reach into a neighbour.
Verified with eight consecutive parallel runs, green, and by watching the
per-worker directories appear separately (w1, w2, w4 … w14) rather than
one shared tree. The isolation itself cannot be asserted from inside a
single test; what a test can pin is the mechanism it rests on, so one
does: parts go where the configured root says.
Follow-up to #1703, which made `exists:folders,id` mean what its ten
readers already assumed. Two things it named and deliberately left.
**The chunked upload is two requests.** store()'s rule only ever sees the
first: POST /uploads records the resolved folder on the UploadSession and
complete() reads it back from the session rather than from the caller, so
deleting the folder while the bytes are in flight still files the
assembled file into it -- the same orphan state #1703 removes, reached by
a door a validation rule cannot watch. complete() now re-resolves through
Folder::query() and files at the root when the folder has gone.
Root rather than a refusal, because the two moments cost different
things. At store() nothing has been sent, so refusing is free and honest,
which is the call #1703 made. Here the bytes are already uploaded, and
discarding somebody's finished transfer over a folder that vanished
underneath them is the harsher of the two surprises. The file lands
somewhere they can see it and move it.
**The refusal now explains itself.** "The selected folder id is invalid"
says nothing when the answer is that the folder has been deleted -- and
that is the usual way to meet this rule, since a live id picked from a
list is how anybody gets here. It matters most on the chunked path, the
one place #1703 makes a previously-working request fail. A small
ValidationRule object carries the message, which keeps the single
definition Rules::folderId() exists for: a messages() array would have to
be repeated at all ten call sites, and rules meaning different things in
ten places is what went wrong in the first place.
One note for whoever writes the next test here. Upload parts live in
storage_path('app/uploads-tmp/{session_id}'), which is a real shared
directory rather than a faked disk, and each parallel worker's database
restarts session ids at 1 -- so two files writing parts on two workers
collide, and ChunkedUploadsTest's afterEach deletes the whole tree for
everybody. Six test files write parts today. These two cases live in
ChunkedUploadsTest rather than beside the rest of their subject so this
change does not add a seventh racer; the underlying isolation problem
predates it and is worth its own fix.
The other half of the sweep. ClientsController and its API twin checked
`abort_unless($client->isClient(), 404)` and nothing else -- a type
check, not a boundary, which is the phrase #1701 used about the group
membership routes for exactly the same reason.
Measured before the fix, with a client-scoped role holding the client
permissions:
GET /clients every client on the installation, name + email
GET /clients/{stranger} 200
PATCH /clients/{stranger} 302, name actually changed
DELETE /clients/{stranger} 302, client gone
The tell was one route over. ClientFilesController::index already draws
this line with StaffLibraryScope::canAssignClient and calls it "the same
boundary StaffLibraryScope enforces everywhere else in the library". Its
neighbours in the same family did not.
So the predicate is not new here. What is new is StaffLibraryScope::clients(),
the listing half of canAssignClient, so a screen narrows by the rule its
own buttons are guarded with instead of restating it -- restating it is
how this went wrong, and how the last four of these went wrong.
Eight actions take it: edit, update, destroy and the two-factor reset on
both surfaces, plus both listings. Answering 404 rather than 403, since a
client outside the roster should not be distinguishable from one that is
not there -- matching the isClient() guard already above it.
Account requests stay installation-wide on purpose: a self-registered
client who has not been approved belongs to nobody yet, so there is no
roster to narrow by and narrowing would empty the screen.
The published API document is unchanged -- both routes already documented
the 404 that the type check produced.
The sweep after #1685 turned up the same leak two widgets further down
the same controller. largestFiles() and expiredFiles() already take the
viewer -- to decide whether their rows get links -- but queried with a
bare File::query(), so a client-scoped staff member's dashboard named
files belonging to clients they hold nothing of.
The note above largestFiles() says a link that 403s is accepted rather
than adding per-row scope checks. That reasoning is about the link. A row
that should not be there at all is a different problem, and the name is
the part that leaks: "Q3 delinquent accounts" says plenty without ever
being downloadable. Scoping the query is also cheaper than the per-row
check that note declined -- StaffLibraryScope builds a scoped user's
query once per request.
Reachable in the default configuration, unlike the last few of these: the
Client Manager role ships client-scoped and holds view_statistics.
topClientsByStorage() goes with them; it names clients rather than files,
which is the thing MembershipRequest::approvableBy and ActivityLogScope
already exist to keep inside a roster.
counters() and transferSeries() stay installation-wide, and now say so.
A total carries no names -- "417 files" tells a scoped viewer nothing
about whose they are -- and if that ever stops being the line, both move
together.
One consequence worth stating rather than discovering: scopeVisibleToClient
ends in notExpired(), so a scoped viewer's expired-files widget now lists
only their own expired uploads, not a client's. Safe, and under-inclusive
-- telling them about a file auto-delete is about to take needs a library
query that keeps expired rows, which is a boundary to decide rather than
to invent inside a leak fix.