Repository navigation
fix(auth): refuse view-only + write-capable group combinations on user create/update - #1205
Conversation
…r create/update The view-only/write-capable contradiction check existed only in the frontend (validateGroupCombination, LeanerCloud#1405), so POST /api/users and PUT /api/users/{id} accepted a contradictory membership from any non-UI API consumer. Port the frontend's classification to internal/auth, reading each group's stored permissions (never its name): view-only means at least one permission and all actions == "view"; write-capable means any action other than "view"; empty and missing groups are neutral. Wire it into CreateUser (via checkCreateUserGroups) and guardGroupChange, after the existing access guards so an escalation is still reported as a 403. It returns a new ErrContradictoryGroups sentinel, mapped to 400 by mapAuthError like ErrNoGroups. An unchanged membership (groups omitted, or echoed back in any order) is not re-checked, so users who already hold a contradictory combination can still be renamed or deactivated; any real change is checked against the full resulting set, as the frontend does. Existing tests whose "ordinary group" fixture was view-only next to an admin group now use a write-capable group, since that combination is exactly what this change refuses. Closes LeanerCloud#78 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 13 billable files and costs up to $3.25. Or wait 59 minutes for your next included review. View limit details
Comment |
|
Independent review 2 (claude-fleet, cold): APPROVE 8ca620f Findings (none blocking):
Against #78: this fixes the root cause.
Attacked:
Verified locally, with no cloud credentials and GOPROXY=off, under the heavy lock:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
VERDICT: APPROVE 8ca620f Findings:
Attacked:
|
|
Local verification at
CI on this head: CI Success and pre-commit green. cristim's independent review approved this head. Migrations in this batch (000136-000139) are applied in numeric order. Nothing was run against any cloud. |
|
Marked ready and merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), after cristim's independent review approving |
Root cause
The check that a view-only group must not be combined with a write-capable group lived only in the frontend (
validateGroupCombinationinfrontend/src/users/utils.ts, #1405). The backend'sguardGroupChangeenforced >=1 group, last-admin and self-escalation (#907) and the #226 ceiling, but not this rule. SoPOST /api/usersandPUT /api/users/{id}accepted a contradictory membership from any non-UI API consumer.Closes #78
Fix
internal/auth/group_combination.goports the frontend's classification. The frontend classifies by permissions, not by names, and so does this port. Each group's stored permissions are read withstore.GetGroup:"view""view"(soadmin,*and a mixed view+create group all count)pgx.ErrNoRows/ nil) is skipped, the same way the frontend skips IDs it can't findauth.ErrContradictoryGroups, wrapped with%wand the same explanation the UI shows (contradictory group combination: "Viewers" is view-only but "Plan Authors" grants write permissions; assign a view-only group or write-capable groups, not both).mapAuthErrormaps it to 400, next toErrNoGroups. It's the sameClientErrorshape as the otherguardGroupChangerefusals.CreateUser->checkCreateUserGroups(renamed fromcheckCreateUserCeiling; it runs the sec(auth): user-membership writes have no grant ceiling, so update:users mints an admin #226 ceiling, then the combination check)guardGroupChangenow runs the existing checks (moved unchanged intoguardGroupChangeAccess), thencheckGroupMembershipChangePUT /api/users/{id}, andSetupAdminonly ever assigns Administrators. The otherstore.UpdateUserwriters (password, MFA) write back the user's existinggroup_idsunchanged.ErrSelfEscalation/ ceiling (403).TestUpdateUser_SelfEscalationDenied(a viewer adding Administrators to themselves) pins this. The check runs for every caller, including trusted internal ones (actorUserID == ""), because it's about the membership, not the actor.Existing tests whose fixtures changed
Five existing tests used a view-only "ordinary"/"subset" group next to an admin group and expected success. That is exactly the combination this PR refuses:
TestSelfAccountScope_JoiningSubsetScopedGroupAllowedTestSelfCarvedOutGrant_OrdinaryGroupStillAllowedTestSelfCarvedOutGrant_AdminMayAddAnotherUserToOrdinaryGroupTestUpdateUser_AdminEditingSelfAllowedTestSelfAccountScopeDispatch_NonWideningChangeAllowedNone of them was testing the combination itself (they cover the carve-out, account-scope and self-edit rules), so their fixture group is now write-capable (
create:plans), keeping each test's intent. Four more tests send several groups without caring about the combination, so they only gotGetGroupstubs:TestSelfCarvedOutGrant_InternalCallerUnaffectedTestAPIAdapters_CreateUser_AllFieldsReachStorecreate user with group IDssubtestupdate groupIDs successfullysubtestConcurrency
There's no DB change. The write still goes through
UpdateUserAdminFields, which comparesgroup_idswith what was read, so two concurrent membership PUTs can't merge into a set nobody validated: each PUT replaces the whole membership with the set it validated, and the losing writer gets a 409 (ErrUserChanged). The only remaining window is cross-row: a group's permissions could be edited (view-only -> write) between the check and the commit. The same kind of edit can also make existing memberships contradictory at any time, with no race needed (see out of scope). Per the issue this is an accident guard, not a security boundary, so I didn't add a DB trigger.Verification
All of this is unit, mock-store and in-process dispatch evidence (real
auth.Serviceand real router, mocked store). I didn't run anything against real Postgres or a real cloud.Fail-before (guard written but not wired into
CreateUser/guardGroupChange):The contradictory membership reached the store write on both paths. Pass-after: all green.
Mutation probes, each applied alone and then reverted (all caught by the tests):
ErrContradictoryGroupsfrommapAuthError(500 instead of 400)guardGroupChangeskips the combination checkcheckCreateUserGroupsskips the combination checkw1 + e1case)adminaction not counted as writepgx.ErrNoRowsnot skippedNew tests:
internal/auth/group_combination_test.goTestCheckGroupCombination_MirrorsFrontend: a case-for-case port of the jest suitevalidateGroupCombination (issue #1405), plus write + empty, two view-only, unknown next to view-only, and order-independence cases_UsesPermissionsNotNames_StoreErrorFailsClosedTestUpdateUser_LegacyContradictoryUserKeepsUnrelatedEdits: rename without groups, rename sending back the same groups, reorder, duplicate, deactivate. It also asserts noGetGroupcall is made.TestUpdateUser_LegacyContradictoryUserCanBeResolvedinternal/server/group_combination_dispatch_test.go: real HTTP dispatch through the router. Contradictory PUT -> 400, contradictory POST -> 400, no store write in either case. A legacy user's rename -> 200, with groups omitted and with groups sent back unchanged.internal/api/handler_users_test.go:TestHandler_users_ContradictoryGroupsMapTo400(create and update).Commands run, on macOS, with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid:Not run locally: the repo-wide
go test -short -race ./...pre-commit hook (skipped because of disk limits on this machine; the change adds no concurrency) and the integration-tagged suites (no integration tests were changed). The frontend wasn't touched.Out of scope / notes
PUT /api/groups/{id}can turn a view-only group write-capable (or the reverse) while it has members, which silently makes existing memberships contradictory. The issue covers user membership writes only, and the frontend has the same gap. This PR doesn't lock such users out (see above).;instead of.), so it isn't byte-for-byte the frontend's string. The wording and singular/plural (is/are,grants/grant) match. Duplicate IDs are de-duplicated before naming groups. The frontend would name a duplicated group twice; the classification is the same either way.internal/auth/service_user.gowas already over the 500-line guideline before this PR, so the new logic lives in its own file.Frontend follow-up for the UI owner
No UI change is required: the frontend already blocks these combinations and shows the backend's 400 message if one gets through. Optional:
userModals.tsvalidatesselectedGroupsbefore an email-only edit, so a legacy contradictory user can't be renamed from the edit modal even though the backend now allows it. If that should work, skipvalidateGroupCombinationthere when the selected set equalscurrentEditingUser.groups.Labels
Could a maintainer please mirror the issue's triage labels onto this PR:
type/chore,triaged,priority/p3,severity/low.🤖 Generated with Claude Code