Skip to content

fix(auth): refuse view-only + write-capable group combinations on user create/update - #1205

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-78
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-78

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Root cause

The check that a view-only group must not be combined with a write-capable group lived only in the frontend (validateGroupCombination in frontend/src/users/utils.ts, #1405). The backend's guardGroupChange enforced >=1 group, last-admin and self-escalation (#907) and the #226 ceiling, but not this rule. So POST /api/users and PUT /api/users/{id} accepted a contradictory membership from any non-UI API consumer.

Closes #78

Fix

  • New internal/auth/group_combination.go ports the frontend's classification. The frontend classifies by permissions, not by names, and so does this port. Each group's stored permissions are read with store.GetGroup:
    • view-only: at least one permission, and every action is "view"
    • write-capable: any action other than "view" (so admin, * and a mixed view+create group all count)
    • a group with no permissions is neither and takes no part; a missing group (pgx.ErrNoRows / nil) is skipped, the same way the frontend skips IDs it can't find
    • any other store error fails closed
  • New sentinel auth.ErrContradictoryGroups, wrapped with %w and 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). mapAuthError maps it to 400, next to ErrNoGroups. It's the same ClientError shape as the other guardGroupChange refusals.
  • Wired into every backend path that sets a user's groups:
    • create: CreateUser -> checkCreateUserGroups (renamed from checkCreateUserCeiling; it runs the sec(auth): user-membership writes have no grant ceiling, so update:users mints an admin #226 ceiling, then the combination check)
    • update: guardGroupChange now runs the existing checks (moved unchanged into guardGroupChangeAccess), then checkGroupMembershipChange
    • There is no separate group-membership endpoint. The frontend's inline toggles and bulk add both go through PUT /api/users/{id}, and SetupAdmin only ever assigns Administrators. The other store.UpdateUser writers (password, MFA) write back the user's existing group_ids unchanged.
  • Ordering: the combination check runs after the access guards, so an escalation attempt that also happens to be contradictory is still reported as 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 contradictory users aren't locked out. If the requested membership is the same set as the stored one (groups omitted, or sent back in any order or with duplicates), it isn't re-checked. Renames, deactivation and edit forms that send back the unchanged group list keep working. Any real change is checked against the full resulting set, as the frontend does: a change has to resolve the contradiction (drop either side) and can't keep it or add to it.

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_JoiningSubsetScopedGroupAllowed
  • TestSelfCarvedOutGrant_OrdinaryGroupStillAllowed
  • TestSelfCarvedOutGrant_AdminMayAddAnotherUserToOrdinaryGroup
  • TestUpdateUser_AdminEditingSelfAllowed
  • TestSelfAccountScopeDispatch_NonWideningChangeAllowed

None 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 got GetGroup stubs:

  • TestSelfCarvedOutGrant_InternalCallerUnaffected
  • TestAPIAdapters_CreateUser_AllFieldsReachStore
  • the create user with group IDs subtest
  • the update groupIDs successfully subtest

Concurrency

There's no DB change. The write still goes through UpdateUserAdminFields, which compares group_ids with 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.Service and 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):

$ go test ./internal/auth/ -count=1 -run '^TestCreateUser_RejectsContradictoryGroups$'
--- FAIL: TestCreateUser_RejectsContradictoryGroups
    mock: I don't know what to return because the method call was unexpected.
        This method was unexpected: CreateUser(context.backgroundCtx,*auth.User)
$ go test ./internal/auth/ -count=1 -run '^TestUpdateUser_RejectsChangeToContradictoryGroups$'
--- FAIL: TestUpdateUser_RejectsChangeToContradictoryGroups/replace_with_a_contradictory_set
        This method was unexpected: UpdateUserAdminFields(context.backgroundCtx,*auth.User,string,[]string,bool)
$ go test ./internal/server/ -count=1 -run GroupCombinationDispatch
--- FAIL: TestGroupCombinationDispatch_UpdateRefusedWith400
--- FAIL: TestGroupCombinationDispatch_CreateRefusedWith400   (unexpected CreateUser)

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):

# Mutation Result
M1 drop ErrContradictoryGroups from mapAuthError (500 instead of 400) caught
M2 guardGroupChange skips the combination check caught
M3 checkCreateUserGroups skips the combination check caught
M4 remove the unchanged-membership skip (legacy user locked out) caught
M5 run the combination check before the access guards caught
M6 empty-permission group counted as view-only caught (after adding the w1 + e1 case)
M7 admin action not counted as write caught
M8 pgx.ErrNoRows not skipped caught
M9 fail open on a store error caught
M10 require 3 groups instead of 2 caught

New tests:

  • internal/auth/group_combination_test.go
    • TestCheckGroupCombination_MirrorsFrontend: a case-for-case port of the jest suite validateGroupCombination (issue #1405), plus write + empty, two view-only, unknown next to view-only, and order-independence cases
    • _UsesPermissionsNotNames
    • _StoreErrorFailsClosed
    • create reject/allow
    • update reject (including a partial change on a legacy contradictory user)
    • TestUpdateUser_LegacyContradictoryUserKeepsUnrelatedEdits: rename without groups, rename sending back the same groups, reorder, duplicate, deactivate. It also asserts no GetGroup call is made.
    • TestUpdateUser_LegacyContradictoryUserCanBeResolved
  • internal/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/null because this machine's default AWS profile is invalid:

go build ./...
go vet ./...
go test ./internal/auth/ -count=1        # ok
go test ./internal/api/ -count=1         # ok
go test ./internal/server/ -count=1      # ok
go test ./internal/mocks/ -count=1       # ok (repo-wide test guards)
golangci-lint v2.10.1 run --allow-parallel-runners ./internal/auth/... ./internal/api/... ./internal/server/...   # 0 issues
gocyclo -over 10 -ignore "_test\.go" internal/auth internal/api/handler_users.go                                 # silent
bash scripts/gofmt-hook.sh <changed files>; make tidy-check; bash scripts/gosec-hook.sh <changed non-test files>  # clean
bash scripts/test-git-secrets-allowlist.sh   # 42 PASS, 0 FAIL
git secrets --scan                           # clean (changed files + .gitallowed in a fresh git init repo, isolated HOME)

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

  • Editing a group's permissions can still create contradictions. 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).
  • The message follows Go error-string style (lower-case sentinel prefix, ; 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.go was 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.ts validates selectedGroups before 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, skip validateGroupCombination there when the selected set equals currentEditingUser.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

…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>
@coderabbitai

coderabbitai Bot commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@IAmJSD IAmJSD added type/chore Maintenance / non-user-visible triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm labels Oct 20, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/chore Maintenance / non-user-visible

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec: enforce view-only+write group-combination contradiction in the backend (guardGroupChange)

1 participant