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 |
…permissions change (#1462) * fix(auth): apply the view-only group-combination rule when a group's permissions change #1205 refuses a view-only plus write-capable group combination on user create and update, but UpdateGroup could still produce one with no user write: adding a write permission to a group whose members also belong to a view-only group (or making a group view-only while its members hold a write-capable one) silently contradicted every such member. UpdateGroupAPI now builds the updated group, and when its role in the rule changes (view-only, write-capable or neutral) lists the users and checks each member's combination before and after. If any member who was consistent would become contradictory, the update is refused with ErrContradictoryGroups naming the group and how many members conflict; PUT /api/groups/{id} maps it to 400. Members who were already contradictory do not block it, matching the unchanged-membership rule on user edits. An update that keeps the role, including any update that omits or repeats the permissions, takes the fast path and reads no user. The check runs after the grant and account ceilings, so a 403 still wins over the 400. Like the #1205 check it is an accident guard, not a security boundary. checkGroupCombination now shares its classification with the new check through a group loader, which the new check caches per call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(auth): cover the group-combination check on group updates Pins the #1409 rule through UpdateGroupAPI: adding a write permission to a group whose members also hold a view-only group is refused with the member count, as is making a group view-only while members hold a write-capable one, and a first permission on an empty group is checked too. A role change with no conflicting member saves, a member who was already contradictory does not block, and an update that keeps the group's role reads no user. Store errors fail closed and are not reported as a contradiction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): answer a contradictory group update with 400 UpdateGroupAPI now refuses a permission change that would give a member both a view-only and a write-capable group (ErrContradictoryGroups). mapGroupAuthError did not know that sentinel, so the refusal surfaced as a 500. Map it to 400 like the other malformed-input group errors, and document the case on PUT /api/groups/{id}. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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