Skip to content

Refuse on the API what demo mode refuses in the interface - #831

Merged
blaipr merged 2 commits into
mainfrom
fix/the-api-honours-demo-mode
Aug 20, 2026
Merged

blaipr merged 2 commits into
mainfrom
fix/the-api-honours-demo-mode

Conversation

@blaipr

@blaipr blaipr commented Aug 20, 2026

Copy link
Copy Markdown
Member

The gap

Demo mode makes an instance refuse to change or copy itself. The web enforces it in five config
actions and in UserForm:

web API
ConfigBackup/FileBackupController · DownloadBackupDbController refuses —
ConfigImport/ImportController refuses —
ConfigEncryption/SaveController · RefreshController refuses —
UserForm — edit / delete / change password of the demo account refuses —
config/backup ran it
config/export ran it
users/{id} PUT / DELETE ran it

grep -rn isDemoEnabled src/Infrastructure/Adapter/In/Api/ returned nothing: the API surface did
not mention demo mode anywhere.

Why this one matters more than a missing guard usually would

A demo deployment is the one place where the caller is meant to hold administrator credentials —
they are published so people can try the thing. The ACL therefore stops nobody, and the demo guard
is the whole boundary. Sign in as the demo admin, mint a token, call the API, and it did the backup,
the export or the user change the interface had just refused one click earlier. A visitor who
changed the demo admin's password ended the demo for everyone who came after.

The change

One shared denyOnDemo() on the API ControllerBase rather than four copies, plus a
denyOnDemoUser(int $id) that narrows it to the published account. Wired into config/backup,
config/export, and the user edit and delete paths — the complete set: there is no API
password-change endpoint for users, so the web's third isDemo() call site has no second door.

The demo account's id moves from a private constant in UserForm to User::DEMO_ADMIN_ID, so the
two doors compare against the same value instead of each holding a copy.

The user refusals are deliberately narrow. Every other user on a demo stays editable and removable —
trying that is most of the point of running a demo.

Test

DemoModeTest (5 tests, real ApiTestCase dispatch with a real token, demo mode written into the
config the API actually reads). Each refusal asserts both the error and the state — the row is
unchanged, no dump or export file is written — and is paired with the same call succeeding on an
ordinary user, so none of them can be satisfied by an endpoint that simply stopped working.

Mutation-checked: removing the guard fails exactly the four refusals, at the assertion that says the
API refused, and leaves the control passing.

OK (3994 tests, 36797 assertions)   unit
OK (981 tests, 2917 assertions)     integration

PHPStan level 6 and PHPCS clean.

Also

CLAUDE.md gains "The same rule, asked at the other door" in the defects section. This is the
fourth finding from that lens — after the custom-field masking, the password-policy lifetime on edit,
and the search-paging clamp — and it had no entry, while the pattern it describes keeps producing.

blaipr added 2 commits August 20, 2026 20:32
Demo mode makes an instance refuse to change or copy itself. The web enforces
it in five config actions and in UserForm; nothing on the API surface mentioned
demo mode anywhere.

That gap matters more than a missing guard usually would, because a demo
deployment is the one place where the caller is *meant* to hold administrator
credentials — they are published so people can try it. The ACL therefore stops
nobody: sign in as the demo admin, mint a token, and the API ran the backup, the
export, or the user change the interface had just refused. A visitor who changed
the demo admin's password ended the demo for everyone after them.

Four endpoints gain the guard, through one shared method on the API
ControllerBase rather than four copies: config/backup, config/export, and the
user edit and delete paths. The demo account's id moves from a private constant
in UserForm to User::DEMO_ADMIN_ID, so the two doors compare against the same
value.

The refusal is narrowed to that one account on the user endpoints — every other
user on a demo stays editable and removable, which is most of what there is to
try — and DemoModeTest pairs each refusal with the same call succeeding on an
ordinary user, so none of them can be satisfied by an endpoint that simply
stopped working. Removing the guard fails exactly the four refusals and leaves
the control passing.
@blaipr
blaipr merged commit c4be68c into main Aug 20, 2026
8 checks passed
@blaipr
blaipr deleted the fix/the-api-honours-demo-mode branch August 20, 2026 18:44
blaipr added a commit that referenced this pull request Aug 23, 2026
Every way of getting an installation out of a demo refuses — the backup and both
of its downloads, the config import, the encryption save and refresh, and the
REST export since #831 — except the web export, both halves of it.

A demo publishes its administrator's credentials by design, so the ACL in front
of these stops nobody and the demo check is the whole boundary. The export holds
the same installation the backup is guarded for: every account's encrypted secret
and its key, and, when no export password is given, the name, login, URL and
notes of every account in the clear.

#831 fixed the REST door by comparing it against the five web config actions that
had the guard. The web export was not among those five, so comparing against them
could not reveal it: the gap was in the list being compared to.

Both halves are guarded, because guarding only the creation leaves an export made
before the instance became a demo fetchable, which is a way around it.

The download refusal asserts an empty body rather than a message, which is what
the backup downloads beside it already answer on a demo: these actions are typed
CALLBACK, so a refusal that is not a callable renders as nothing. That blank page
is a pre-existing wart the three share, verified against the untouched
downloadBackupApp rather than assumed, and left alone. What the test pins is that
the export is not handed over.

CLAUDE.md corrects #841, which said any signed-in user could delete another
user's notification on the grounds that Acl returns true for the notification
actions unconditionally. NOTIFICATION_VIEW, NOTIFICATION_SEARCH and
NOTIFICATION_CHECK are in that list; NOTIFICATION_DELETE is not in the switch at
all and falls through to the deny, so only isAdminApp ever reached those methods.
The ownership check remains right as defence in depth, but it was not the
reachable hole it was described as.
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.

1 participant