One Endpoint, Two Privileges: Separating User and Group Status
This document records the discussion, repair, and final authorization design for upstream issue minio/minio#21478 and SILO PR #73.
Status on 2026-08-26: SILO PR #73 was merged as
2e2377d1c, preserving the signed-off repair commit58735ee38. All eight reported checks passed. Upstream issue #21478 and PR #21482 remain open, butminio/miniois archived and read-only, so no further issue comment or merge can be made there.
Group follow-up on 2026-08-28: final release review found the same fixed-action defect inset-group-status. Signed-off server commitd98250110now selectsadmin:EnableGrouporadmin:DisableGroupfrom the requested target state and adds a real four-way IAM authorization test. Local verification and independent review are complete; push, remote CI, merge, tag, and delivery remain pending.
Scope: authorize enabling and disabling a user with their respective existing Admin Actions. Do not change the route, status values, account storage, replication record, or client API.
Security property: possessingadmin:DisableUsermust not grant the ability to enable an account, and possessingadmin:EnableUsermust not grant the ability to disable one.
Release boundary: merge, tag, release package, container image, deployment, and production verification remain separate gates.
Too Long; Didn’t Read (TL;DR)
SILO exposes both admin:EnableUser and admin:DisableUser, but the shared set-user-status handler historically authorized every request with admin:EnableUser. A policy that granted only admin:DisableUser therefore could not disable an account. The workaround was to grant admin:EnableUser as well, which destroyed the least-privilege boundary that the two action names promised.
The selected repair derives exactly one required action from the requested target state before authorization:
| Requested status | Required action |
|---|---|
enabled |
admin:EnableUser |
disabled |
admin:DisableUser |
| invalid or unknown | admin:EnableUser, preserving the previous authorization-before-validation default |
The handler then calls validateAdminReq once. A four-way IAM test proves both positive operations and both denied cross-action operations. This is intentionally stricter than preserving the accidental historical behavior in which an Enable-only policy could also disable users.
The same rule now applies to group status:
| Requested group status | Required action |
|---|---|
enabled |
admin:EnableGroup |
disabled |
admin:DisableGroup |
| invalid or unknown | admin:EnableGroup, preserving the previous authorization-before-validation default |
Before the follow-up, an EnableGroup-only principal could disable a group, while a DisableGroup-only principal received AccessDenied for that exact operation. The group repair uses the same one-selector, one-authorization design rather than treating the two actions as aliases.
The reported defect
The Admin API uses one route for both state transitions:
Before the repair, the handler checked one fixed action before reading the requested status:
The later call to SetUserStatus correctly received either enabled or disabled, but authorization had already treated both as Enable operations. admin:DisableUser existed in the policy vocabulary and documentation while being ineffective for this endpoint on its own.
Issue #21478 supplied the practical counterexample: an operator wanted a policy that could disable accounts during an incident without being able to restore them. A policy containing admin:DisableUser received AccessDenied; adding admin:EnableUser made the request work, but also gave the operator the more powerful recovery transition that the policy intentionally withheld.
This is not a missing convenience permission. It is a mismatch between the policy model and the enforcement point:
Why two actions must mean two capabilities
An account state transition has direction. Disabling is commonly delegated to incident responders, fraud controls, compliance automation, or a break-glass process. Enabling restores access and may require a separate approver.
If either action authorizes both transitions, a policy author cannot express that separation. The server would publish two names while enforcing one combined capability. The design contract is therefore strict:
| Principal policy | Disable target | Enable target |
|---|---|---|
admin:DisableUser only |
allow | deny |
admin:EnableUser only |
deny | allow |
| both actions | allow | allow |
| neither action | deny | deny |
The built-in consoleAdmin policy grants admin:*, so full administrators retain both operations. The compatibility impact is limited to custom restricted policies that relied on the old accidental behavior.
The public PBAC reference now states the same contract for admin:EnableUser and admin:DisableUser.
Design goals and non-goals
Goals
- Make both existing Admin Actions enforceable according to their names.
- Preserve least privilege in both directions.
- Perform one authorization decision and write at most one authorization error.
- Preserve the route, request values, response format, self-mutation guard, IAM storage call, and site-replication hook.
- Encode the contract in tests that fail if the two permissions are broadened or swapped again.
Non-goals
- split the endpoint into separate enable and disable routes;
- add a new combined action or change policy syntax;
- change user status persistence or replication;
- redesign Console permissions;
- infer release, image, deployment, or production delivery from a source merge.
Alternatives considered
Keep checking admin:EnableUser for both states
This preserves behavior but leaves admin:DisableUser unusable and forces over-privileged policies. It is the defect, not a compatibility contract worth retaining.
Require both actions for either transition
This makes the two labels decorative and prevents delegated disable-only operation. It is stricter in quantity but weaker in expressiveness and least privilege.
Try Enable authorization, then retry Disable authorization
Upstream PR #21482 attempted this shape for a disabled request. It first called validateAdminReq with EnableUser, then called it again with DisableUser if the first result was nil.
That helper has an important contract: when it returns a nil object layer, it has already written an error response. A Disable-only request can therefore commit a 403 response before the second authorization succeeds and the handler proceeds to mutate account state. Authorization fallback must never continue after an error response has been committed.
Accept either Enable or Disable for a disabled request
validateAdminReq already accepts multiple actions and succeeds if any one is allowed, so compatibility behavior could be implemented safely with one variadic call. That would let Disable-only policies work while preserving the historical ability of Enable-only policies to disable.
SILO rejected this option because the historical ability was the enforcement bug. It would solve the reporter’s positive case but retain a cross-action privilege that contradicts the two-action model. Operators who want both transitions can grant both actions explicitly.
Validate the status before authenticating
Rejecting unknown status values first would change error precedence: a caller that previously had to pass the Enable authorization gate could now receive a validation result before authorization. The repair does not need that broader behavioral change.
Unknown values therefore retain admin:EnableUser as the authorization default. Valid disabled is the only value that selects admin:DisableUser; the existing IAM layer remains responsible for rejecting invalid status values after authorization.
The selected implementation
The repair adds a pure selector:
The handler reads the route variables, selects the action, and authorizes exactly once:
Everything after the gate remains unchanged:
- a caller still cannot enable or disable its own account;
globalIAMSys.SetUserStatusvalidates and persists the requested status;- site replication records the same status and timestamp;
- response and audit behavior use the existing path.
The selector depends only on the requested target state. It does not load the current user, infer a transition from stored state, or make authorization depend on whether the target exists. This keeps authorization deterministic and avoids a read-before-authentication dependency.
Why the repair is safe
The correctness argument consists of five invariants:
- Every valid status maps to exactly one Admin Action.
validateAdminReqis invoked once, so a failed authorization cannot be followed by mutation.- The mutation call is reachable only after the selected action succeeds.
- Invalid status values preserve the old Enable authorization boundary and are still rejected by the existing status-validation path.
- No storage, replication, wire, or client contract changes; only the permission required to reach the existing mutation changes.
The change is a deliberate authorization tightening for Enable-only custom policies that used the disable operation. That tightening is the mechanism that makes admin:DisableUser a real independent capability.
Test design
Pure action mapping
The unit test fixes three selector cases:
| Input | Expected action |
|---|---|
enabled |
EnableUser |
disabled |
DisableUser |
| invalid | legacy EnableUser default |
Four-way IAM authorization matrix
The integration test creates separate users and policies, then exercises the real Admin API:
- a Disable-only client successfully disables a target;
- the same client receives
AccessDeniedwhen enabling it; - an Enable-only client successfully enables the target;
- the same client receives
AccessDeniedwhen disabling it.
Positive assertions alone would not prove least privilege: both policies could accidentally authorize both states and still pass. The two negative cross-action assertions are the security regression tests.
The test removes every temporary user and policy after execution. It runs inside the existing IAM server suite, so it covers request signing, policy attachment, handler authorization, persistence, and Admin-client error decoding rather than testing only the helper.
Repair and verification record
The server checkout originally contained unrelated dependency, generated-credit, checksum-test, and security-document changes, while local main was behind the remote. The two user-status files were isolated into a clean worktree based on current origin/main; no unrelated file entered the repair commit.
Local verification passed:
The signed-off commit 58735ee38 was pushed in PR #73. Its eight remote checks all passed:
- DCO sign-off;
- format, build, and vet;
- lint and generated files;
cmd/tests;internal/tests;- race detector and S3 Select;
- cross compilation;
- vulnerability analysis.
The PR was merged with the repository’s normal merge strategy as 2e2377d1c. Local main was then fast-forwarded only after the two original working files were byte-for-byte and patch-ID identical to the merged result. The unrelated local changes remained intact, and the temporary worktree and task branch were removed after the code became recoverable from main and PR #73.
Least-privilege policy examples
Disable-only operator
This principal can inspect and disable another user, but cannot enable it.
Enable-only operator
This principal can inspect and enable another user, but cannot disable it. Grant both actions explicitly to roles responsible for the complete account lifecycle.
Group-status follow-up
The group endpoint has the same shape as the user endpoint:
It also publishes two existing actions, admin:EnableGroup and admin:DisableGroup. The inherited handler nevertheless authorized every request with EnableGroup before reading status. This was not merely a dead permission: it reversed least privilege in both directions. The wrong principal could disable a group, and the intended disable-only principal could not.
The follow-up adds setGroupStatusAdminAction, deliberately matching setUserStatusAdminAction:
The integration test creates separate EnableGroup-only and DisableGroup-only administrators and a real target group. It proves:
- DisableGroup-only can disable;
- DisableGroup-only cannot enable;
- EnableGroup-only can enable;
- EnableGroup-only cannot disable.
The suite exercises signed Admin requests, policy attachment, handler authorization, IAM mutation, response decoding, and cleanup. Invalid status still selects the legacy Enable action before the existing validation error, so the change does not expose a new pre-authentication oracle. The successful site-replication hook remains after mutation and is not called for denied requests.
This follow-up changes no user behavior and introduces no new policy action. It makes the two already documented group actions enforce the same state-specific contract as their user counterparts.
Compatibility and migration
No client or API migration is required. The endpoint, query parameters, status strings, success response, and Admin-client method are unchanged.
Policy review is required for restricted administrative roles:
- a role that should only disable users needs
admin:DisableUser; - a role that should only enable users needs
admin:EnableUser; - a role that must do both needs both actions;
consoleAdminand otheradmin:*policies are unaffected;- a legacy custom policy containing only
admin:EnableUsercan no longer use that permission to disable users and must addadmin:DisableUserif both operations are intended.
The equivalent rules now apply to group-management roles:
- a role that should only disable groups needs
admin:DisableGroup; - a role that should only enable groups needs
admin:EnableGroup; - a role that must do both needs both actions;
- a legacy EnableGroup-only role can no longer disable groups.
This is a source-level compatibility change in authorization behavior, not a wire-protocol break.
Upstream disposition
As of this record, upstream issue #21478 and PR #21482 are still displayed as open. The upstream repository is archived and read-only. An attempt to leave the single-authorization analysis on the PR was rejected by GitHub because archived, locked discussions cannot accept comments.
The upstream artifacts remain useful provenance but are no longer an actionable delivery path. SILO owns its implemented semantics, tests, merge, release note, and eventual production verification.
Delivery state
| Gate | User repair | Group follow-up on 2026-08-28 |
|---|---|---|
| Design decision | complete | complete |
| Implementation and local tests | complete | complete |
| Independent adversarial review | complete | complete, GO |
| Signed-off commit | complete | local d98250110 |
| Push, PR CI, and merge | complete | not established |
| Tagged SILO release | not established | not established |
| Release package or container image | not established | not established |
| Deployment | not established | not established |
| Production behavior | not established | not established |
| Upstream merge | unavailable; repository archived | not applicable |
Conclusion
The repairs make the authorization model tell the truth. Enabling and disabling users or groups are opposite state transitions with different operational risk, and SILO already exposes different policy actions for each direction. Each handler must therefore select the action from the requested target state and authorize once before mutation.
The code change is small because the design boundary is clear. The durable result is larger: an explicit permission matrix, rejected compatibility alternatives, an invalid-input rule, a four-way integration test, a clean merge record, migration guidance, and an honest release boundary.