Skip to content

Floater mode: tightened superuser gate#19232

Open
snipe wants to merge 9 commits into
developfrom
_floater-mode-superuser-gate
Open

Floater mode: tightened superuser gate#19232
snipe wants to merge 9 commits into
developfrom
_floater-mode-superuser-gate

Conversation

@snipe

@snipe snipe commented Jun 25, 2026

Copy link
Copy Markdown
Member

This cleans up a few rough edges around how the company-assignment field on the user edit form behaves when Full Multiple Company Support is enabled.

What changed

  • The "Company" field on the user edit form always renders consistently now — same label, same column layout, regardless of whether the actor can edit it. When the actor can't manage companies, the row shows the target's current companies (or "no companies assigned") as read-only labels and an info note explaining why.
  • Added clearer help text when the actor's role / company assignments prevent them from making changes, so they don't have to guess why the field is locked.
  • Added a helper User::canGrantFloaterStatus() that consolidates the floater-mode rules into one place. The web form, API, bulk-edit, CSV importer, and Blade help text all consult the same method, so the rules stay consistent across entry points.
  • Tightened up a small inconsistency where list visibility and per-target edit access for users didn't always line up - they're now both governed by the same CompanyableScope check.
  • The company dropdown now reads its selected state from the company_user pivot exclusively. The legacy users.company_id scalar can lag behind the pivot (LDAP sync, observer order, etc.) so falling back to it sometimes showed stale selections.

FMCS ON + Floater ON, user.edit - form

Screenshot 2026-06-25 at 8 10 59 PM

FMCS ON + Floater ON, user.edit - error

Screenshot 2026-06-25 at 8 08 43 PM

FMCS ON + Floater OFF, user.edit

Screenshot 2026-06-25 at 8 10 38 PM

What to test

  • FMCS off: editing users behaves exactly as before.
  • FMCS on, floater mode off: a users.edit holder can manage companies on users they can see; the read-only path renders the target's current companies for users they can't.
  • FMCS on, floater mode on: dropdown behavior remains intact for actors who can grant floater status; others see a heads-up next to the field.
  • Editing an admin or super admin as a non-admin shows the read-only company display + the existing "you can't edit privileged users" explanation.
  • User import + bulk edit still work; clearing companies via either path behaves consistently with the single-user form.

@snipe snipe changed the title Floater mode: tightened superuser gate WIP: Floater mode: tightened superuser gate Jun 25, 2026
@snipe snipe changed the title WIP: Floater mode: tightened superuser gate Floater mode: tightened superuser gate Jun 25, 2026
@codacy-production

codacy-production Bot commented Jun 25, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 medium

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
Complexity 1 medium

View in Codacy

🟢 Metrics 19 complexity

Metric Results
Complexity 19

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@uberbrady

Copy link
Copy Markdown
Member

Here’s the use-case I want to support:

I work at a company that has a bunch of sub-companies, and I’m in HR, in the “on-boarding” department. I have to on-board people across any of the associated sub-companies. So we have floater mode enabled, and I am listed as a floater. I only have user.create but not user.edit. That’s another department.

I think I should be able to create a user who is also a floater - that’s well within the type of responsibilities my company might want me to have, right?

So I think a Floater who is not a super-user should be able to create or edit users who are also floaters if they have that edit or create permission.

@uberbrady uberbrady left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks great! I have one comment I left as a standalone thing, and one question here that I’m curious about (and am worried might be a bug).

But this is very nice, succinct code to do a lot of important work - I think that’s great!

Comment thread app/Models/Company.php
// per-target path, which left actors able to see users they
// couldn't then edit. One check, one query, same logic as the list.
if ($companyable instanceof User) {
return User::where('users.id', $companyable->id)->exists();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn’t this be looking at $companyable->companies()->where(‘id’,Auth::user()->id)->exists() or something like that?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I’d look at the toSql() of that before trusting me directly on that :P )

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for going through the model query rather than an explicit pivot intersection is to avoid duplicating the visibility logic.

User::where('users.id', $id)->exists() runs through CompanyableScope, which already knows about every edge case that governs whether the actor can see this target in the user list:

  • companied actor, companied target -> pivot intersection
  • empty-pivot actor + floater mode off -> can see other empty-pivot targets only
  • empty-pivot actor + floater mode on -> can see everyone
  • companied actor + floater mode -> can also see null-pivot targets
  • superuser short-circuits (skips the scope entirely)
  • the null_company_is_floater setting changes a couple of these rules in non-obvious ways

A direct pivot intersection like the suggestion would only cover the first row. The bug we just fixed (#19187) was specifically that the per-target check was doing its own narrower intersection, so actors could see users in the list via the scope's null-pivot branch but then got 403'd at the edit screen. The two checks had drifted apart. By having both list visibility and per-target access go through the same scope, they can't drift again, and a future tweak to floater-mode semantics doesn't require remembering to update this logic branch too.

It's also the same number of queries (one indexed EXISTS either way), and the scope's query is the same shape the listing already runs, so it's not introducing a new query pattern.

@snipe

snipe commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

@uberbrady I just pushed up some more changes that loosens the gates for your use-case. Now under floater mode, you can grant floater status if you're a superuser OR you yourself are uncompanied. The only blocked case is the actual escalation: a companied non-superuser trying to elevate someone to floater. The floater-HR workflow you described now works as expected.

@snipe

snipe commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

I should also note, these gates deliberately don't cover "trusted sources" like LDAP, SCIM, cli tools, etc

@uberbrady uberbrady left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking great! I think if we can fix that Auth::check() thing that I mentioned in the code, I'll feel better. Thanks for taking this on - I think it's really going to help a lot of people using a lot of different workflows. I had a couple of bits where I wasn't sure about a few things - if you could see if those are real concerns, or let me know that the things I'm worried about might actually belong elsewhere, then that would be great. Can't wait to get this one going!

Comment thread app/Http/Requests/SaveUserRequest.php Outdated
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
if (! auth()->check() || auth()->user()->canGrantFloaterStatus()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this say "if the user isn't logged-in" OR they can grant floater status, then pass them along? I'm assuming it's because of CLI usage, where we don't necessarily have a user ID we can refer to.

That first clause feels confusing. Yes, via the CLI, you're automatically "assumed" to be a superuser (for the most part). But I would hope there could be a clearer way of determining whether or not this is CLI, which might be clearer to read and understand, and safer. (There might be some contexts where a not-logged-in user, in the future, might be able to do some things - like acceptances or something)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like there's a app()->runningInConsole() thing you can do in Laravel - would it be okay if we switched over to that instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but PHPUnit runs in CLI too, so a bare app()->runningInConsole() would short-circuit the gate during every test (including the existing tests that expect the rejection). I think the way Laravel wants "actual console, not the test runner" is app()->runningInConsole() && ! app()->runningUnitTests().

Comment thread app/Models/Company.php
Comment thread app/Models/Company.php
Comment thread app/Models/User.php Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this only true when floater mode is enabled? In non-floater mode, null company stuff can only be checked out to null-company people.

Comment thread app/Models/User.php Outdated
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.

2 participants