fix: a caller cannot assign a profile past their own - #874
Merged
blaipr merged 2 commits intoAug 24, 2026
Conversation
`isAdminApp` and `isAdminAcc` are gated on the caller already holding them, in the web form and in the API's user endpoints alike. The profile a user is pointed at was not gated at all, at either door — and a profile is where the other thirty permissions live. Assigning one hands the user everything on it, so "may manage users" was "may hold any permission in the installation" in two steps: create or edit a user, point them at the administrator profile, sign in as them. The codebase already answers this question for the other door. `ProfileData::constrainedTo()` strips a *written* profile down to what the writer holds, and the API's `ProfileBase` applies it — because otherwise "may manage profiles" was "may become an administrator". The same escalation was still open next to it, by referencing a profile instead of writing one. `grantsBeyond()` is that question asked of a referenced profile, in terms of the existing one, so a permission added later is covered without either being revisited. Placed in `UserProfileService` and called from the web's `UserSaveBase` and the API's `UserBase`, rather than in `UserService::create()` — that is also the path the installer and LDAP auto-provisioning take, where there is no signed-in caller to constrain against. Two things the tests settled: A profile that does not exist is let through rather than refused. The integration suite caught the first version changing what a bad id reports — "Profile not found" instead of the error the foreign key produces. A profile that is not there grants nothing, so there is nothing for this guard to say about it. An application administrator's profile is not even read, which is what the `never()` in that test asserts. Checked by making the comparison always false: the refusal test fails and the two that pin what must still work do not.
PHPStan resolved the bare NoSuchItemException in UserSaveBase's new docblock against the controller's own namespace, where there is no such class, so the @throws was not a Throwable. The other three exceptions on the same docblock were already imported.
blaipr
deleted the
fix/an-api-caller-cannot-assign-a-profile-past-their-own
branch
August 24, 2026 00:36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
isAdminAppandisAdminAccare gated on the caller already holding them, in the webform and in the API's user endpoints alike. The profile a user is pointed at was not
gated at all, at either door — and a profile is where the other thirty permissions live.
Assigning one hands the user everything on it, so "may manage users" was "may hold any
permission in the installation" in two steps: create or edit a user, point them at the
administrator profile, sign in as them.
The codebase already answers this question for the other door.
ProfileData::constrainedTo()strips a written profile down to what the writer holds, and the API's
ProfileBaseapplies it — because otherwise "may manage profiles" was "may become an administrator".
The same escalation was still open next to it, by referencing a profile instead of
writing one.
grantsBeyond()is that question asked of a referenced profile, in terms of the existingone, so a permission added later is covered without either being revisited.
Placed in
UserProfileServiceand called from the web'sUserSaveBaseand the API'sUserBase, rather than inUserService::create()— that is also the path the installerand LDAP auto-provisioning take, where there is no signed-in caller to constrain against.
Two things the tests settled:
A profile that does not exist is let through rather than refused. The integration suite
caught the first version changing what a bad id reports — "Profile not found" instead of
the error the foreign key produces. A profile that is not there grants nothing, so there
is nothing for this guard to say about it.
An application administrator's profile is not even read, which is what the
never()inthat test asserts. Checked by making the comparison always false: the refusal test fails
and the two that pin what must still work do not.