fix(subadmin): disable group-admin feature by default behind allow_subadmins#41634
Conversation
|
Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a changelog item based on your changes. |
DeepDiver1975
left a comment
There was a problem hiding this comment.
🤖 Automated review by Claude Code review agent.
Overview
This PR gates the sub-admin (group-admin) privilege-delegation feature behind a new allow_subadmins system config that defaults to false, as a security hardening / risk-mitigation change. The approach is sound: enforcement is centralized in the OC\SubAdmin manager, which is the single chokepoint that all ~28 consumers reach via GroupManager::getSubAdmin(). I verified this claim against the head ref (bf33e22) rather than taking it on trust.
Verification of the central-chokepoint claim — the gate is consistent at all entry points I could find:
- OCS / provisioning API — routes declare
API::SUBADMIN_AUTH, which resolves inlib/private/legacy/api.php:334throughgetGroupManager()->getSubAdmin()->isSubAdmin(). SinceisSubAdmin()is gated, subadmin-only OCS endpoints (getUsers,addUser,addToGroup, etc.) correctly fall closed for group-admin-only users. ✅ - Web UI middleware —
settings/Middleware/SubadminMiddleware.phpreceives itsisSubAdminflag fromsettings/Application.php:240, which calls the same centralisSubAdmin(). ✅ User.phppermission bypass —lib/private/User/User.php:459and:483(canChangeDisplayName/canChangeMailAddress) route throughgetSubAdmin()->isSubAdmin(). ✅- Legacy guards —
lib/private/legacy/{util,json,app}.phpall callgetSubAdmin()->isSubAdmin(). ✅ - Settings controllers / MetaData / provisioning Users.php & Groups.php — all reach the gated
isSubAdmin/isSubAdminofGroup/getSubAdminsGroups/getGroupsSubAdmins/getAllSubAdmins/isUserAccessiblemethods. ✅
Because isUserAccessible() short-circuits on isSubAdmin() (which is gated) and getSubAdminsGroups() returns [] when disabled, the stale-DB-rows authorization-bypass concern is addressed: even if group_admin rows remain in the DB after disabling, no consumer treats the user as a subadmin. testReadMethodsReturnEmptyWhenDisabled explicitly proves this. The real-admin short-circuit ordering in isSubAdmin() (admin check before the disabled check) is correct and tested (testIsSubAdminAsAdminWhenDisabled), so real admins retain access. Deletion + post_delete* cleanup hooks intentionally stay enabled so admins can prune dormant assignments — verified by testDeleteSubAdminWorksWhenDisabled.
This is a well-scoped, defensively-designed change with good test coverage of both enabled and disabled states.
Code quality / style
- The
isEnabled()helper and the per-method early returns are clean and readable. ReusingHintExceptionso the two write callers surface a friendly message instead of a 500 is the right call. - Config flag read is consistent:
getSystemValue('allow_subadmins', false) !== false. The!== falseform (rather than truthy) is the established codebase idiom for these boolean opt-outs — siblingsallow_user_to_change_display_name/allow_user_to_change_mail_addressuse the same=== falsecomparison style. Good consistency. (Minor caveat below about string values.) config.sample.phpdocuments the flag with a clear SECURITY NOTE and the upgrade implication ("existing group-admin assignments are ignored"). Good.- Acceptance-test plumbing (
enableSubadminFeature/@AfterScenario cleanupSubadminFeature, idempotency guard) is tidy.
Specific suggestions
-
Add a changelog entry (blocking for CI). The PR touches
changelog/nowhere, butowncloud/coreenforces achangelog/unreleased/<issue>entry per PR (the directory exists and is populated at the head ref). More importantly, this is a behavior-breaking change on upgrade — every deployment currently using subadmins silently loses the feature until an admin sets'allow_subadmins' => true. That belongs in achangelog/unreleased/entry of typeChange(not just inconfig.sample.php), so it surfaces in release notes. Please add one. -
settings/users.phpnow reads the flag directly ($config->getSystemValue('allow_subadmins', false) !== false) in addition to relying on the central gate. SincegetAllSubAdmins()already returns[]when disabled, this second flag-read is redundant for correctness and adds a second source of truth for the default value. It's harmless (it also hides the column), but consider a comment clarifying it's a UI optimization, or derive the column visibility from whether$subAdminsis empty, to keep the flag read in exactly one place. -
String / env-style config values.
getSystemValue('allow_subadmins', false) !== falsetreats any non-falsevalue as enabled. If someone sets'allow_subadmins' => 'false'(string) or0, it will be treated as enabled because'false' !== false. The sample doc uses a real boolean so this is an edge case, but a stricter=== truecheck (matching "opt-in only via explicittrue") would be safer for a security gate that defaults closed. At minimum, worth a deliberate decision. -
createSubAdmin()uses\OC::$server->getL10N('lib')via the static service locator inside an otherwise DI-constructed class. Consistent with surrounding legacy code, but sinceIConfigis now injected, injectingIL10N(or a factory) would be cleaner and keep the class testable without the global container. Not blocking.
Potential issues / risks
- Upgrade/backward-compat (intended, but document it): confirmed this is a deliberate breaking change. The only mitigation for existing deployments is the config flag. Beyond the changelog ask above, consider whether release notes / upgrade docs should call this out prominently, since the impact is "delegated group admins stop working after upgrade with no in-product warning."
- No authorization bypass found via stale rows — addressed by the gating of all read/auth methods (see Overview). Good.
addSubAdmin(provisioning) andtogglesubadmins.phpnow catchHintExceptionand return a clean OCS103/ JSON error respectively — verified both write paths are covered. The OCSremoveSubAdmin/deleteSubAdminpath is intentionally left working when disabled.- Test gap (minor): the unit tests cover the manager thoroughly, but there is no test asserting that an OCS
SUBADMIN_AUTHendpoint actually returns 401/forbidden for a group-admin-only user when the feature is disabled (the integration of the gate at the OCS layer). The acceptance suite enables the feature for subadmin scenarios but doesn't appear to assert the disabled-state denial end-to-end. Consider one acceptance scenario proving a subadmin is denied whenallow_subadmins=false.
Overall: solid, fail-closed hardening with the gate correctly centralized. The main thing to resolve before merge is the missing changelog entry documenting the breaking upgrade behavior; the string-config and single-source-of-truth points are worth a quick look.
bf33e22 to
40da2a9
Compare
|
I added an acceptance test scenario for when the subadmin feature is disabled. I tagged it |
|
A suggested changelog is in #41636 |
DeepDiver1975
left a comment
There was a problem hiding this comment.
🤖 Automated review by Claude Code review agent.
Re-review of feature/disable-subadmins-by-default at current head 40da2a90. The prior review was at bf33e22e; this pass focuses on what changed since then and the status of the three previously-flagged items.
Overview
Goal unchanged: disable the group-admin (subadmin) feature by default behind a new allow_subadmins system config, opt-in via 'allow_subadmins' => true. Enforcement remains centralized in OC\SubAdmin (single chokepoint via GroupManager::getSubAdmin()), with IConfig injected and a private isEnabled() gate.
What changed since bf33e22e (3 commits ahead, 1 behind):
d70439cd— unrelated guzzle dependency bump (#41635), pulled in by rebase; not part of this feature.f07c6ed8— the subadmin change itself, rebased (functionally identical to what was reviewed atbf33e22e).40da2a90— the one substantive new change: adds an acceptance test (createSubAdmin.feature) plus bootstrap plumbing inProvisioning.phpfor the "admin tries to create a subadmin when the feature is disabled" path, asserting OCS status 103 and that no assignment is created. The bootstrap now defaults the feature off, enables it on demand viaenableSubadminFeature()(usingSetupHelper::setSystemConfig(... 'boolean')), and reverts it in an@AfterScenariohook (cleanupSubadminFeature()).
The core enforcement logic in SubAdmin.php is unchanged from the prior review.
Security assessment (default-off gate)
- Fail-closed at all entry points — confirmed.
isSubAdmin()keeps the real-admin short-circuit first, then returnsfalsewhen disabled. Read methods (isSubAdminofGroup,getSubAdminsGroups,getGroupsSubAdmins,getAllSubAdmins) return empty/false. Because the OCSUsersController(SUBADMIN_AUTH, group filtering at lines ~332, 450, 458) andSubadminMiddlewareall route through these methods viagetSubAdmin(), the gate cascades consistently — no entry point queriesgroup_admindirectly to grant rights. - No authz bypass with stale rows. Dormant
group_adminrows are ignored while disabled (reads return empty), anddeleteSubAdmin()+ post-delete hooks remain enabled so admins can prune them — correct asymmetry (privilege-reducing ops stay open, privilege-granting ops are blocked). - Test coverage for enabled + disabled — good. Unit tests cover throw-on-create, empty reads, admin short-circuit preserved, and delete-still-works when disabled. The new acceptance test now covers the disabled path at the API layer too.
Status of the three prior-flagged items
-
Missing changelog for breaking upgrade behavior — NOT addressed. There is still no
changelog/unreleased/*entry for the subadmin behavior change. The only changelog file touched (PHPdependencies20260225onward) belongs to the unrelated guzzle commit. This is a default-changing, potentially breaking upgrade (existing subadmins silently lose access) and owncloud/core requires a changelog entry; please add one (typechange/security) referencing #41634. -
Redundant flag read in
settings/users.php— NOT addressed. Line 75 still reads$config->getSystemValue('allow_subadmins', false) !== falsebefore callinggetAllSubAdmins(), which itself already returns[]when disabled. Harmless defense-in-depth, but redundant and duplicates the gate logic outside the manager. Optional, but worth simplifying to justif ($isAdmin)and letting the manager gate, OR keep it but acknowledge it as intentional belt-and-suspenders. -
String-config edge case in
!== false— NOT addressed.isEnabled()usesgetSystemValue('allow_subadmins', false) !== false. A config value of string'false'or'0'would be truthy under!== falseand thus enable the feature — the opposite of the operator's intent and a fail-open surprise for that input. Recommend=== true(strict opt-in) to match the documented'allow_subadmins' => truecontract, or normalize via a bool cast. The same pattern is duplicated insettings/users.php(item 2), reinforcing the value of centralizing on the manager.
Code quality / style
- The new acceptance bootstrap is clean: idempotent
enableSubadminFeature()guard and config revert in@AfterScenarioavoid cross-scenario leakage. Tracking via$subadminFeatureEnabledis appropriate. createSubAdmin()resolves L10N via\OC::$server->getL10N('lib')(service-locator) rather than the injected container — consistent with surrounding legacy code, acceptable here.- Minor: the
HintExceptionis caught as\OC\HintExceptionin two call sites; fine for this codebase.
Specific suggestions
- Add the changelog entry (item 1) — this is the main blocker for a default-changing security fix.
- Tighten
isEnabled()to=== true(item 3) to remove the fail-open string-config edge case. - Optionally simplify the redundant gate in
settings/users.php(item 2).
Summary
The security posture is solid and unchanged: the default-off gate is enforced fail-closed and consistently at every entry point, with no bypass via stale rows. The only new work since the prior review (commit 40da2a90) is welcome acceptance-test coverage for the disabled path. However, all three previously-flagged items remain unaddressed — most importantly the missing changelog (item 1) and the !== false fail-open edge case (item 3). Recommend addressing at least items 1 and 3 before merge.
#41636 has passed, all API acceptance tests pass. You can cherry-pick the changelog from there, or write and push your own text to here. |
DeepDiver1975
left a comment
There was a problem hiding this comment.
🤖 Automated review by Claude Code review agent.
Overview — what changed since the last review (40da2a9 → abbb607)
A gh api commit comparison shows the head is ahead by exactly one commit (abbb6076, "chore: add changelog for allow_subadmins setting") with no other code changes:
- Added
changelog/unreleased/41634(8 lines, "Security: disable group-admin feature by default behind allow_subadmins").
No security-relevant source files (lib/private/SubAdmin.php, lib/private/Group/Manager.php, the OCS/provisioning entry points, settings/users.php, the legacy guards) changed since the previous review. The fail-closed gating analysis from the 40da2a9 review therefore still holds in full.
Security re-confirmation (gate is still fail-closed at all entry points)
Re-verified against the current diff:
isEnabled()readsgetSystemValue('allow_subadmins', false) \!== false— default-off, single source of truth.isSubAdmin()keeps the real-admin short-circuit first, then returnsfalsefor group-admin-only users when disabled. Authorization cannot be bypassed by a stalegroup_adminrow: every read path (isSubAdminofGroup,getSubAdminsGroups,getGroupsSubAdmins,getAllSubAdmins) returns empty/false before any DB query when disabled.- Write path (
createSubAdmin) throwsHintException; both callers — OCSaddSubAdminandsettings/ajax/togglesubadmins.php— now catch it and surface a clean error instead of a 500. deleteSubAdminand thepost_delete*cleanup hooks remain enabled (privilege-reducing only), so admins can prune dormant assignments.- Test coverage for both enabled and disabled states is present:
testCreateSubAdminThrowsWhenDisabled,testReadMethodsReturnEmptyWhenDisabled,testIsSubAdminAsAdminWhenDisabled(admin short-circuit preserved),testDeleteSubAdminWorksWhenDisabled; acceptance scenario added for the OCS-disabled path (status 103).setUpenables the feature so legacy assertions still exercise it.
No new authz bypass paths introduced. The change is functionally a no-op on the security surface.
Status of the 3 prior-flagged items
- (a) Missing changelog for breaking upgrade behavior — ADDRESSED.
changelog/unreleased/41634is added in this exact commit. It states the feature is disabled by default and how to opt back in. Two minor nits in the changelog text:- Missing space after the comma:
system config,as a security→config, as a. - It does not explicitly call out the breaking upgrade behavior — i.e. that on upgrade, existing group-admin assignments stop taking effect until
allow_subadmins => trueis set. Worth a sentence for admins reading release notes. (If the separate changelog PR #41636/#41638 is the canonical one, consider whether this file is redundant with it.)
- Missing space after the comma:
- (b) Redundant flag read in
settings/users.php— STILL OPEN. Line still inlines$config->getSystemValue('allow_subadmins', false) \!== falserather than routing through the centralSubAdminchokepoint. Not a security issue (it is an extra, correct guard, andgetAllSubAdmins()is itself gated), but it duplicates the gate logic outside the manager and can drift. Minor. - (c) String-config edge case in
\!== false— STILL OPEN.getSystemValue('allow_subadmins', false) \!== falsetreats any non-false value as enabled, including the string"false"or"0"that a hand-editedconfig.phpmight contain — these would (counter-intuitively) enable the feature. This is fail-open relative to operator intent for the string case. Low likelihood given the documented=> falseboolean form and the acceptance harness uses a real boolean, but a stricter check (e.g. cast/=== true) would be more robust. Note: tightening to=== truewould need the acceptance helper to keep passing a true boolean, which it does (setSystemConfig(..., 'true', ..., 'boolean')).
Code quality / style
- Clean, well-scoped, centralized in the manager. Consistent guard placement (early return before DB access).
config.sample.phpdocumentation is clear and includes the security note.
Specific suggestions (all non-blocking)
- Fix the missing space in the changelog (
config,as→config, as). - Optionally mention the upgrade-time behavior change in the changelog for operators.
- Consider replacing inline reads in
settings/users.phpwith a manager method to keep the gate single-sourced (item b). - Consider
=== true/ boolean cast for the config check to harden against string config values (item c).
Verdict
No blocking issues. The security gate remains fail-closed at all entry points and is well tested. Prior item (a) is resolved by this commit; items (b) and (c) remain as minor, non-blocking improvements.
…badmins The subadmin (group-admin) feature lets an admin delegate user management of specific groups to a non-admin user. The related code path has known security shortcomings, so the feature is now disabled by default as a risk-mitigation. Deployments that rely on it can opt back in by setting the new `allow_subadmins` system config to true. Enforcement is centralized in the OC\SubAdmin manager, the single chokepoint all consumers route through: - isSubAdmin() keeps the real-admin short-circuit, then returns false for group-admin-only users when disabled, cascading to permission bypasses, legacy guards and the settings middleware. - Read methods (isSubAdminofGroup, getSubAdminsGroups, getGroupsSubAdmins, getAllSubAdmins) behave as if no subadmins exist. - createSubAdmin throws HintException; the two write callers (togglesubadmins.php, provisioning_api addSubAdmin) surface a clean error. - deleteSubAdmin and the post-delete cleanup hooks stay enabled so admins can prune dormant assignments. The Users settings page hides the group-admin column when disabled, and the option is documented in config.sample.php with a security note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
abbb607 to
3648fe5
Compare
|
I fixed the spacing in the changelog that the review mentioned: and pushed again. Hopefully the automated review will be happier now. |
DeepDiver1975
left a comment
There was a problem hiding this comment.
🤖 Automated review by Claude Code review agent.
Overview — what changed since the last review (abbb6076 → 3648fe54)
The branch was rebased onto a newer master (diverged: 4 ahead / 3 behind) and the feature commit was reworded, but the substantive security logic is unchanged from the previously reviewed abbb6076. The only functional deltas in this range are housekeeping:
chore: add changelog for allow_subadmins setting— addedchangelog/unreleased/41634(resolves prior item (a), the changelog, which was already noted as resolved).test: add acceptance test for when the subadmin feature is disabled— adds a@smokeTestscenario increateSubAdmin.featureplus theenableSubadminFeature()/cleanupSubadminFeature()(@AfterScenario) plumbing inbootstrap/Provisioning.php, so the default-off feature can be exercised and reverted per scenario.- A merge/rebase touched an unrelated
PHPdependencies…changelog (guzzle psr7) — not part of this feature.
The core gate in lib/private/SubAdmin.php, Manager.php, Users.php, togglesubadmins.php, and settings/users.php is byte-for-byte the same as the last reviewed head. No new entry points were introduced.
Fail-closed gate confirmation (re-verified at 3648fe54)
Confirmed the default-off gate is still fail-closed at all entry points:
isEnabled()readsgetSystemValue('allow_subadmins', false)— defaultfalse, so unconfigured = disabled.isSubAdmin()keeps the real-admin short-circuit first, then returnsfalsewhen disabled — so all downstream consumers (User.phppermission bypass, settings middleware, legacy menu/access guards) fail closed. Real admins correctly retain access.- All read methods (
isSubAdminofGroup,getSubAdminsGroups,getGroupsSubAdmins,getAllSubAdmins) return empty/falsewhen disabled → no authz bypass via stalegroup_adminrows. createSubAdmin()throwsHintException; both write callers catch it and surface a clean error (OCS 103 / JSON error) instead of a 500.deleteSubAdmin()+post_delete*cleanup hooks remain enabled (privilege-reducing, safe).
Test coverage for both enabled and disabled paths is present and adequate (SubAdminTest.php: 4 disabled-path tests incl. admin short-circuit preserved, empty reads over a real dormant row, throw-on-create, delete-still-works; plus the new disabled acceptance scenario).
Status of prior open items
- (b) Redundant flag read in
settings/users.php— NOT addressed (still present). The added&& $config->getSystemValue('allow_subadmins', false) !== falseguard duplicates the gate already enforced insidegetAllSubAdmins(), which returns[]when disabled. It is functionally harmless (and acts as a minor fast-path avoiding a DB call), so it is not a blocker — but it is redundant defense and slightly couples the page to the config key name. Optional cleanup, not required for merge. - (c)
!== falsestring-config edge case — NOT addressed (unchanged).getSystemValue('allow_subadmins', false) !== falsetreats any non-falsevalue as enabled. A misconfiguration like'allow_subadmins' => 'false'(string) or'0'would evaluate to enabled (string'false'!== false). This is a fail-open edge under operator misconfiguration. Given the secure default isfalseand correct boolean usage is documented inconfig.sample.php, real-world risk is low, but a stricter check (=== true, orfilter_var(..., FILTER_VALIDATE_BOOLEAN)) would be more robust and would also make theisEnabled()helper and theusers.phpguard consistent. Recommended, low priority.
Code quality / style
- Centralizing the gate in the
OC\SubAdminchokepoint with a single privateisEnabled()helper is the right design — one place to reason about. - The provisioning
addSubAdmintry/catch correctly mapsHintExceptionto OCS 103 with the hint message; consistent with the AJAX handler. - Acceptance-test plumbing (enable +
@AfterScenariorevert viaSetupHelper::setSystemConfig/deleteSystemConfig) is clean and idempotent (guarded by$subadminFeatureEnabled). - Minor: the disabled
getAllSubAdminsshort-circuit and theusers.phpguard express the same intent in two ways (see item (b)).
Potential issues / risks
- Only the string-config edge case (item c) remains as a theoretical fail-open under operator misconfiguration; secure-by-default behavior is correct.
- No bypass found via stale DB rows: every read path is gated.
- No new risks introduced by the rebase/changelog/test commits.
Verdict
Approve in spirit — the security objective (fail-closed, default-off group-admin) is correctly and consistently implemented and well tested. Items (b) and (c) remain open but are both non-blocking; addressing (c) with a strict boolean check would be the most valuable follow-up.
Address remaining review feedback on the allow_subadmins gate: - Tighten the gate to a strict `=== true` check so only the documented boolean opt-in enables the feature; string values such as 'false' or '0' in a hand-edited config.php now fail closed instead of fail open. - Make SubAdmin::isEnabled() public and route settings/users.php through it, removing the duplicated inline config read so the gate has a single source of truth in the OC\SubAdmin manager. - Document the breaking upgrade behavior in the changelog: existing group-admin assignments are ignored until allow_subadmins => true. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
DeepDiver1975
left a comment
There was a problem hiding this comment.
🤖 Automated review by Claude Code review agent.
Overview — what changed since the last review (3648fe5 → 7964448)
Exactly one new commit (79644482, "strict boolean gate and single-source isEnabled()") sits on top of the previously reviewed head. It is ahead by 1 / behind by 0 and touches three files with surgical, low-risk changes:
lib/private/SubAdmin.php—isEnabled()visibility changedprivate → public, and the gate condition tightened from\!== falseto strict=== true.settings/users.php— the inline config read ($config->getSystemValue('allow_subadmins', false) \!== false) replaced with\OC::$server->getGroupManager()->getSubAdmin()->isEnabled().changelog/unreleased/41634— added two lines documenting the breaking upgrade behavior (existing group-admin assignments ignored untilallow_subadmins => true).
No production code paths beyond these were altered since the last review, so the previously confirmed fail-closed enforcement at all entry points (OCS SUBADMIN_AUTH, middleware, User.php permission bypass, legacy menu/access guards) remains intact and unchanged.
Status of prior open items
-
(b) Redundant flag read in
settings/users.php— RESOLVED. The duplicated inlinegetSystemValueread is gone; the page now gates the "Group Admin for" column through the single-sourceSubAdmin::isEnabled(). There is now exactly one place that interprets theallow_subadminsconfig, which is the correct design. This also keepssettings/users.phpconsistent withgetAllSubAdmins(), which itself returns[]when disabled — so even if the guard regressed, the page would degrade safely. -
(c) String-config edge case in the
\!== falsecheck — RESOLVED. The gate is now=== true. A hand-editedconfig.phpwith'allow_subadmins' => 'false','0','',0, ornullnow fails closed (previously a non-empty string like'false'would have been\!== falseand failed open). Only the documented booleantrueopt-in enables the feature. This is the strictly safer interpretation and matches the documented contract inconfig.sample.php('allow_subadmins' => false).
Security review — fail-closed verification (re-confirmed at 7964448)
- Single chokepoint preserved. All ~28 consumers still route through
GroupManager::getSubAdmin()→OC\SubAdmin, which now hasIConfiginjected and a singleisEnabled()authority. isSubAdmin()ordering correct. Real-admin short-circuit (groupManager->isAdmin) runs first, then\!isEnabled() → return false. Real admins retain access; group-admin-only users lose elevation when disabled. No stalegroup_adminrows can grant authority because the row query is never reached when disabled.- Read methods fail closed.
getSubAdminsGroups,getGroupsSubAdmins,getAllSubAdmins,isSubAdminofGroupall short-circuit to empty/falsebefore touching the DB — dormant rows are invisible. isUserAccessibleinherits the gate transitively viagetSubAdminsGroups/isSubAdminofGroup, so no separate bypass exists.- Write path fails closed loudly.
createSubAdmin()throwsHintException; both callers (settings/ajax/togglesubadmins.php, provisioningaddSubAdmin) catch it and surface a clean error (OCS 103 / JSON error) rather than a 500. deleteSubAdmin()andpost_delete*cleanup hooks stay enabled — privilege-reducing operations remain available so admins can prune assignments while the feature is off. Confirmed bytestDeleteSubAdminWorksWhenDisabled.- Config read correct.
getSystemValue('allow_subadmins', false)defaults tofalse;=== trueis the right comparison for a strict boolean opt-in.
Test coverage
Coverage for both states is intact and the new strict semantics are exercised:
- Enabled path:
setUp()setsallow_subadmins => trueso all legacy assertions still exercise the feature; constructor updated to passIConfig. - Disabled path:
testCreateSubAdminThrowsWhenDisabled,testReadMethodsReturnEmptyWhenDisabled(covers all four reads +isSubAdmin/isUserAccessible),testIsSubAdminAsAdminWhenDisabled(admin short-circuit preserved),testDeleteSubAdminWorksWhenDisabled. - Acceptance: new
@smokeTestscenario asserts OCS 103 when creating a subadmin with the feature disabled;enableSubadminFeature()/cleanupSubadminFeature()(@AfterScenario) toggle viaSetupHelper::setSystemConfig(..., 'boolean'), which writes a real boolean and therefore correctly satisfies the new=== truegate.
One minor note: there is no unit test asserting that a string value like 'false' fails closed (the new (c) behavior). The acceptance harness sets a proper boolean, so the strict-string-handling change is covered only implicitly. Adding a one-line assertion (setSystemValue('allow_subadmins', 'true'); assertFalse($subAdmin->isEnabled())) would lock in the fail-closed contract against future regressions. Non-blocking.
Code quality / style
- Clean, minimal diff. Making
isEnabled()public to serve as the single source of truth is the right call and eliminates the duplication flagged previously. - Changelog now correctly documents the breaking upgrade behavior, which is important given this silently ignores existing assignments on upgrade.
- No new lint/style concerns; the
=== trueis more explicit and self-documenting than the prior\!== false.
Verdict
Both previously open items (b) and (c) are fully resolved. The default-off gate remains fail-closed at every entry point, the config read is correct and now strict, and no authz bypass via stale group_admin rows is possible. The only remaining suggestion is a non-blocking unit test for the strict string-config behavior. LGTM.
Summary
Disables the subadmin (group-admin) feature by default behind a new
allow_subadminssystem config, as a security risk-mitigation. The feature's code path has known security shortcomings; deployments that rely on it can opt back in with'allow_subadmins' => trueinconfig.php.Approach
Enforcement is centralized in the
OC\SubAdminmanager — the single chokepoint all ~28 consumers (settings UI, provisioning API,User.phppermission bypass, group filtering, legacy menu/access guards) route through viaGroupManager::getSubAdmin().IConfigis injected and a privateisEnabled()helper gates behavior:isSubAdmin()keeps the real-admin short-circuit first (admins retain access), then returnsfalsefor group-admin-only users when disabled — cascading to all consumers.isSubAdminofGroup,getSubAdminsGroups,getGroupsSubAdmins,getAllSubAdmins) behave as if no subadmins exist.createSubAdmin()throwsHintException; the two write callers (settings/ajax/togglesubadmins.php, provisioning APIaddSubAdmin) surface a clean error instead of a 500.deleteSubAdmin()and thepost_delete*cleanup hooks stay enabled so admins can prune dormant assignments.The Users settings page hides the "Group Admin for" column when disabled, and the option is documented in
config.sample.phpwith a security note.Testing
tests/lib/SubAdminTest.php: updated to passIConfig;setUpenables the feature so existing assertions still exercise it; added 4 tests covering disabled behavior (throw on create, empty reads, admin short-circuit preserved, delete still works). 18 tests pass.apps/provisioning_api/tests/UsersTest.php(91), settings + user controller tests (74) pass unchanged (they mock the manager).Provisioningbootstrap trait, reverted in an@AfterScenariohook.🤖 Generated with Claude Code