Repository navigation
Conversation
…r create/update
The view-only/write-capable contradiction check existed only in the
frontend (validateGroupCombination, #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>
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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