Skip to content

sec(auth): bound account scope on self-membership changes, both directions - #1808

Merged
cristim merged 2 commits into
mainfrom
sec/1756-self-membership-account-ceiling
Aug 12, 2026
Merged

cristim merged 2 commits into
mainfrom
sec/1756-self-membership-account-ceiling

Conversation

@cristim

@cristim cristim commented Aug 12, 2026 •

Copy link
Copy Markdown
Member

Closes #1756

The bug

An actor could raise their own cloud-account scope in a single self-edit of PUT /api/users/{id}, by two routes running in opposite directions. Both were live on main.

Join a wider group. guardSelfEscalation gated self-added groups on update:users, which the actor already holds, then checked only the carved-out money verbs of the group being joined. The account dimension was never checked, so any broader-scoped group would do. #1756 names the seeded Administrators group as the one such group a default deployment ships; in fact every seeded group ships allowed_accounts = ARRAY['*'] (migrations 000024, 000057, 000059/000064, 000096), so the default deployment offers several laundering targets rather than one. The guard keys off the scope value rather than a group ID, so it covers all of them.

Leave the scoping group. guardGroupChange returned early unless the change added a group, so a self-edit that only removed groups was not guarded at all. Dropping the group carrying the restriction collapses the allowed_accounts union to empty, and IsUnrestrictedAccess reads empty as all accounts. Removing the restriction granted the restriction.

Both routes cleared the permission-dimension guards correctly. The defect was that the account dimension had no equivalent on this endpoint, the same asymmetry #1737 closed on the group-write path.

The guard

guardSelfAccountScope compares the resulting scope against the prior scope, rather than inspecting what is being added. That is what closes both routes at once: an add-oriented check can never see the removal, and the removal is the counter-intuitive half.

It reuses accountScopeGap from the group-write ceiling, which treats empty and "*" as unrestricted on either side. That is why it is not a subset test: the empty set is a subset of everything while meaning the opposite of narrow.

prior next verdict
["*"] or [] (unrestricted) anything allowed, nothing left to widen
restricted [] or contains "*" refused (route 2 and route 1 respectively)
[A,B] [A], [A,B] allowed
[A] [A,B] refused

The addsNewGroup screen moves from guardGroupChange into guardSelfEscalation, where it now covers only the permission checks. That reach is correct for permissions (a plain union, which a removal can only shrink) and wrong for accounts (a union whose meaning inverts on empty). Keeping it at the top is what left the removal route unguarded, so it had to move rather than be worked around.

accountsForGroups resolves the union from a membership list and fails closed when a group it cannot load leaves the surviving union empty, plus when no group resolved at all. Its permission-side twin permissionsForGroups skips a missing group safely; here a skipped group can widen the result, so swallowing that case would compute an unrestricted prior scope and wave every change through, which is #1748's mechanism in a new place.

Those two conditions are deliberately identical to grantCeilingAccounts and ResolveAllowedAccounts. The emptiness qualifier is the load-bearing part and is not a softening: a skip can only widen the union when what survives is empty, because empty is the value that reads as every account. When the surviving union is non-empty, the computed scope is not an approximation at all, it is exactly the scope enforcement will apply, since BuildAuthContext skips an unresolvable group too. Both sides of the comparison therefore describe effective access.

Refusing on any skip is the tempting stronger rule and was the first draft. It is wrong, and the case it breaks is ordinary rather than exotic: DeleteGroup drops the groups row and never purges users.group_ids (an array column with no foreign key), so a dangling membership id is the normal state after any custom group is deleted. Under refuse-on-any-skip an administrator who is already unrestricted could not remove that dangling id from their own membership, a change that widens nothing from a principal at maximum scope, leaving a single-admin deployment with no way to clean up. That is pure availability cost for zero security benefit, which is the trade-off grantCeilingAccounts already documents rejecting. TestSelfAccountScope_UnrestrictedActorCanDropDanglingGroup pins it so a future tightening cannot quietly reintroduce it.

Known tradeoff, deliberate: when the refusal does fire, it surfaces as a 500 with a count of unresolvable groups rather than a 4xx naming them. That matches ResolveAllowedAccounts and grantCeilingAccounts, which report the same unresolvable-scope condition the same way; giving only this one call site a sentinel and a 4xx would diverge from both siblings for no security gain. "We cannot establish your account scope" is genuinely a server-side condition, and after the emptiness qualifier above it requires a missing group and an otherwise-empty surviving union, so it is rare. Worth revisiting for all three call sites together, not for this one alone.

Availability

A default deployment is unaffected: every seeded group ships allowed_accounts = ARRAY['*'], so the prior scope is unrestricted and every self-edit passes. The same holds for any actor whose groups carry no allowed_accounts at all. Only a genuinely scoped actor is bounded, and only on a self-edit; an administrator editing another user is untouched, as are trusted internal callers (actorUserID == "").

Verification

Refusals prove little on their own here, since an implementation that refused every membership edit would pass them. Each refusal is therefore paired with a control that must still be allowed, and the controls are the load-bearing half:

  • scoped actor joins Administrators (allowed_accounts = ['*']) -> refused
  • scoped actor drops the group carrying their restriction -> refused
  • same actor drops the other group, which carries no restriction -> allowed
  • scoped actor joins a group scoped to a subset of their own accounts -> allowed
  • a prior group that cannot be resolved, leaving an empty surviving union -> refused (fail closed)
  • an already-unrestricted actor drops a dangling membership id -> allowed (the skip cannot have widened a surviving ["*"])

Coverage runs at the service layer (internal/auth/self_account_scope_test.go) and end to end through Handler.HandleRequest -> Router.Route -> requireAdmin -> the real auth.Service with only the store mocked (internal/server/self_account_scope_dispatch_test.go), because a handler-level test has missed router-level bypasses in this repo before (#1757, #1773).

Pre-fix reproduction

With the two production files restored to origin/main (git checkout origin/main -- internal/auth/service_user.go internal/auth/service_group.go) and the new tests left in place, all five refusals fail and both controls still pass. That is the bug reproducing against the code as shipped, not against a mutant:

JoiningWiderGroupRefused                   FAIL (assertion)
LeavingScopingGroupRefused                 FAIL (assertion)
NonWideningRemovalAllowed                  PASS
JoiningSubsetScopedGroupAllowed            PASS
FailsClosedOnUnresolvablePriorGroup        FAIL (assertion)
dispatch: JoinWiderGroupRefused            FAIL (assertion)
dispatch: LeaveScopingGroupRefused         FAIL (assertion)
dispatch: NonWideningChangeAllowed         PASS

Mutation matrix

Each test was run individually (go test <pkg> -run '^Name$' -count=1), because an aggregate package run misreports: a mock panic kills the test binary. Every failure below was an assertion failure, never a panic; refusal tests carry a permissive UpdateUser stub precisely so a guard removal fails by assertion rather than by an unstubbed-call panic.

test baseline M1 no-op guard M2 restore addsNewGroup screen M3 blanket refusal M4 swallow skipped group M5 swap comparison direction M6 refuse on any skip
join wider group refused PASS FAIL PASS PASS PASS FAIL PASS
leave scoping group refused PASS FAIL FAIL PASS PASS FAIL PASS
non-widening removal allowed PASS PASS PASS FAIL PASS PASS PASS
subset-scoped join allowed PASS PASS PASS FAIL PASS PASS PASS
fail closed on unresolvable prior group PASS FAIL FAIL FAIL FAIL PASS PASS
unrestricted actor drops dangling group PASS PASS PASS FAIL PASS PASS FAIL
dispatch: join wider group refused PASS FAIL PASS PASS PASS FAIL PASS
dispatch: leave scoping group refused PASS FAIL FAIL PASS PASS FAIL PASS
dispatch: non-widening change allowed PASS PASS PASS FAIL PASS PASS PASS

M2 is the one that matters most: it restores only the addsNewGroup early return and leaves the new guard in place, and the removal route reopens on its own. M3 keeps the controls honest, M5 pins the comparison direction rather than merely its presence, and M4/M6 pin the skip rule from both sides at once, which is what makes the emptiness qualifier a measured boundary rather than a preference. Every cell was measured on the current head; M1 and M3 were re-run after the dangling-group control was added rather than inferred for it.

Full root module suite green (go test ./...), race detector green on both changed packages, golangci-lint v2.10.1 (the CI-pinned version) clean, gocyclo -over 10 -ignore "_test\.go" clean. CI was 20/20 green on the first commit; the second commit is under the same checks.

Summary by CodeRabbit

  • Bug Fixes
    • Strengthened account-scope protection for self-managed memberships.
    • Prevented users from joining groups with broader access or leaving groups that restrict their account scope.
    • Allowed membership changes that do not expand account access.
    • Requests violating account-scope rules now return a clear permission error without applying updates.
    • Added fail-closed handling when account-scope information cannot be resolved.

…tions

An actor could raise their own account scope in a single self-edit through
PUT /api/users/{id}, by two routes running in opposite directions.

Joining a wider group: guardSelfEscalation gated self-added groups on
update:users, which the actor already holds, then checked only the carved-out
money verbs of the group being joined. The account dimension was never
checked, and a default deployment ships exactly one broader group -- the
seeded Administrators group, allowed_accounts = ARRAY['*'] (migrations
000024/000057).

Leaving the scoping group: guardGroupChange returned early unless the change
added a group, so a self-edit that only removed groups was not guarded at all.
Dropping the group carrying the restriction collapses the allowed_accounts
union to empty, and IsUnrestrictedAccess reads empty as every account.
Removing the restriction granted the restriction.

guardSelfAccountScope now compares the RESULTING scope against the PRIOR
scope, which is what closes both routes: inspecting only what is being added
can never see the removal. It reuses accountScopeGap from the group-write
ceiling, which treats empty and "*" as unrestricted on EITHER side, so the
comparison is not a subset test (the empty set is a subset of everything while
meaning the opposite of narrow).

The addsNewGroup screen moves from guardGroupChange into guardSelfEscalation,
where it now covers only the permission checks. That reach is correct for
permissions, which are a plain union that a removal can only shrink, and wrong
for accounts, whose union inverts on empty.

accountsForGroups resolves the union from a membership list and FAILS CLOSED
on any group it cannot load. Its permission-side twin permissionsForGroups
skips a missing group safely; here a skipped group widens the result, so
swallowing it would compute an unrestricted prior scope and wave every change
through -- issue #1748's mechanism in a new place.

Verification covers both refusals and, as importantly, both controls: a
removal that does not widen scope and a join of a subset-scoped group are
still allowed, so a guard that refused every self-membership edit fails the
suite. Coverage runs at the service layer and end to end through
Handler.HandleRequest -> Router.Route -> the real auth.Service, since a
handler-level test has missed router-level bypasses here before.

Closes #1756
@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/critical Major harm when it happens urgency/now Drop other things impact/internal Team-internal only effort/m Days type/security Security finding labels Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cbf00865-4636-463d-a259-362e97cf6cd0

📥 Commits

Reviewing files that changed from the base of the PR and between 51e89a6 and cd20d52.

📒 Files selected for processing (4)
  • internal/auth/self_account_scope_test.go
  • internal/auth/service_group.go
  • internal/auth/service_user.go
  • internal/server/self_account_scope_dispatch_test.go

📝 Walkthrough

Walkthrough

The authentication service now prevents self-membership changes from widening account scope, including removal of restricting groups. It fails closed when group scope resolution is incomplete. Unit and router tests cover rejected and allowed changes.

Changes

Self-membership account-scope enforcement

Layer / File(s) Summary
Scope resolution and escalation guard
internal/auth/service_group.go, internal/auth/service_user.go
accountsForGroups resolves group account scopes and rejects unresolved groups. Self-membership validation compares prior and resulting scopes for additions and removals while retaining permission checks for additions.
Authentication guard coverage
internal/auth/self_account_scope_test.go
Unit tests cover widening joins, restricting-group removals, non-widening changes, subset-scoped joins, unresolved groups, dangling memberships, and update suppression.
Router-level enforcement coverage
internal/server/self_account_scope_dispatch_test.go
End-to-end tests verify HTTP 403 responses and suppressed writes for widening changes, plus HTTP 200 and one update for a non-widening change.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant APIHandler
  participant guardSelfEscalation
  participant accountsForGroups
  participant GroupStore
  participant UpdateUser
  APIHandler->>guardSelfEscalation: validate self-membership change
  guardSelfEscalation->>accountsForGroups: resolve prior and resulting groups
  accountsForGroups->>GroupStore: load group account scopes
  GroupStore-->>accountsForGroups: scopes or resolution error
  accountsForGroups-->>guardSelfEscalation: resolved scopes
  guardSelfEscalation-->>APIHandler: allow or reject with ErrSelfEscalation
  APIHandler->>UpdateUser: update memberships when allowed
Loading

Possibly related PRs

  • LeanerCloud/CUDly#1737: Adds related self-membership escalation protection for permission and carved-out grant checks.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: enforcing account-scope bounds in both directions for self-membership changes.
Linked Issues check ✅ Passed The implementation and tests address both escalation routes, preserve allowed non-widening changes, and fail closed for unresolved scopes [#1756].
Out of Scope Changes check ✅ Passed All implementation and test changes directly support account-scope enforcement for self-membership changes described in the linked issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/1756-self-membership-account-ceiling

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

@cristim

cristim commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

The automatic review on this PR hit the adaptive rate limit and never ran. Requesting a full review rather than an incremental one, since an incremental review after a throttle can skip the commit that was in flight and report a false clean.

Context worth having: this closes a p1/severity-critical account-scope escalation that is live on main. The guard must refuse in both directions, including the counter-intuitive one where removing a group widens scope because an empty AllowedAccounts is read as unrestricted.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I will run a full review. I will verify both account-scope widening paths, including group removal that produces unrestricted access.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-26T17:23:32.317Z
Learning: In this repository, if a CodeRabbit review was throttled or hit a rate limit, the correct recovery is to request `coderabbitai full review` rather than `coderabbitai review`, because incremental review can silently skip the affected in-flight commit and report a false-clean result.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-08-06T08:28:24.968Z
Learning: In the LeanerCloud/CUDly repository, request CodeRabbit reviews at most once per hour across the repository because the adaptive quota is shared across open pull requests. When a PR head was pushed while automatic review was quota-exhausted and was not retried, use a full review rather than an incremental review so the missed commits are reviewed.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-26T17:21:01.385Z
Learning: For the LeanerCloud/CUDly repository review workflow, when a previous CodeRabbit review pass was skipped or failed to produce findings due to a rate-limit event, use a full review request on the pull request rather than the incremental review form.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-08-05T05:27:02.254Z
Learning: For the LeanerCloud/CUDly repository, pace CodeRabbit review requests at one request per hour across the repository. The review quota is adaptive and shared per developer and organization; burst requests can exhaust the quota and tighten the limit. When a PR head was pushed while quota was exhausted, request a full review because an incremental review skips the unreviewed commits.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-08-04T08:23:48.546Z
Learning: For the LeanerCloud/CUDly repository, CodeRabbit review quota is shared per developer across the organization and adapts to recent review volume. Space review requests to approximately one per hour. When an automatic review did not run because quota was exhausted, use a full review instead of an incremental review so CodeRabbit inspects the missed commits.
✅ Action performed

Full review finished.

accountsForGroups refused on ANY group it could not resolve. That is stricter
than the widening it guards against, and the extra strictness has no security
value while costing real availability.

A skip can only widen the union when what SURVIVES is empty, because empty is
the value that reads as every account. When the surviving union is non-empty
the computed scope is exactly the scope enforcement will apply: BuildAuthContext
skips an unresolvable group too, so both sides of the comparison already
describe effective access rather than an approximation of it.

The refused-for-nothing case is not hypothetical. DeleteGroup drops the groups
row and never purges users.group_ids, an array column with no foreign key, so a
dangling membership id is the ordinary state after any custom group is deleted.
Under refuse-on-any-skip an administrator who is already unrestricted could not
remove that dangling id from their own membership: a change that widens nothing,
from a principal at maximum scope. In a single-admin deployment nobody could
clean it up.

The two conditions now match grantCeilingAccounts and ResolveAllowedAccounts
exactly: refuse when the surviving union is empty and something was skipped,
and refuse when no group resolved at all. The second is unreachable while
migration 000057's users_min_one_group CHECK holds, and is carried for the same
reason its siblings carry it, an unresolved scope being unknown rather than
unrestricted.

The new control test pins the availability half so a future tightening cannot
quietly reintroduce it. Mutation-verified per test: tightening back to
refuse-on-any-skip fails that control alone, and dropping the emptiness
condition fails the fail-closed test alone.
@cristim

cristim commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

Retrying. A full review was acknowledged on this PR at 01:21:15Z but no review was ever delivered: zero inline comments and zero submitted reviews since. Re-requesting rather than treating the acknowledgement as a verdict.

Note the head has moved since that acknowledgement, to cd20d5250, which adds a second commit. Please review that head.

Requesting full rather than incremental deliberately, since an incremental review after a throttle can skip the in-flight commit and report a false clean.

What matters most here: the guard must refuse in both directions. The non-obvious one is that removing a group can widen scope, because an empty AllowedAccounts is read as unrestricted by IsUnrestrictedAccess. The second commit relaxes the unresolved-group case specifically to len(AllowedAccounts) == 0 && SkippedGroups > 0; the question worth your attention is whether that relaxation can ever fail open.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Checked-out revision ==='
git rev-parse HEAD
git status --short

printf '%s\n' '=== PR commits and changed files ==='
git diff --stat HEAD^ HEAD
git diff --name-only HEAD^ HEAD
git show --format='fuller' --no-ext-diff --no-renames --stat HEAD

printf '%s\n' '=== Changed-file outlines ==='
for f in internal/auth/service_group.go internal/auth/service_user.go internal/auth/self_account_scope_test.go internal/server/self_account_scope_dispatch_test.go; do
  if [ -f "$f" ]; then
    echo "--- $f"
    ast-grep outline "$f" --items all || true
  fi
done

printf '%s\n' '=== Account scope definitions and call sites ==='
rg -n -C 5 --glob '*.go' \
  'func\s+\(?[^)]*\)?\s*(accountScopeGap|accountsForGroups|IsUnrestrictedAccess)\s*\(|\b(accountScopeGap|accountsForGroups|IsUnrestrictedAccess)\s*\(' \
  internal

Length of output: 28671


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Full PR diff against merge-base ==='
base="$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main)"
printf 'base=%s\n' "$base"
git diff --no-ext-diff --no-renames --unified=80 "$base"...HEAD -- \
  internal/auth/service_group.go \
  internal/auth/service_user.go \
  internal/auth/self_account_scope_test.go \
  internal/server/self_account_scope_dispatch_test.go \
  internal/auth/group_ceiling.go \
  internal/auth/account_scope_fail_closed_test.go

printf '%s\n' '=== Scope resolution and self-edit guards ==='
sed -n '95,225p' internal/auth/service_group.go
sed -n '360,515p' internal/auth/service_user.go
sed -n '245,290p' internal/auth/group_ceiling.go

printf '%s\n' '=== Self-scope tests ==='
sed -n '1,280p' internal/auth/self_account_scope_test.go
sed -n '1,210p' internal/server/self_account_scope_dispatch_test.go

printf '%s\n' '=== Related fail-closed tests ==='
sed -n '1,345p' internal/auth/account_scope_fail_closed_test.go

printf '%s\n' '=== Group deletion and group-membership database constraints ==='
rg -n -C 8 --glob '*.go' --glob '*.sql' \
  'func\s+\(?[^)]*\)?\s*DeleteGroup\s*\(|DELETE\s+FROM\s+groups|users_min_one_group|group_ids' \
  internal migrations

Length of output: 201951


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Exact resolution logic ==='
sed -n '125,215p' internal/auth/service_group.go
sed -n '270,305p' internal/auth/service_group.go

printf '%s\n' '=== Exact self-membership path ==='
sed -n '300,505p' internal/auth/service_user.go
sed -n '575,615p' internal/auth/service_user.go

printf '%s\n' '=== Exact scope comparison ==='
sed -n '225,290p' internal/auth/group_ceiling.go
sed -n '165,210p' internal/auth/types.go

printf '%s\n' '=== Full changed hunks, separated by file ==='
base="$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main)"
for f in internal/auth/service_group.go internal/auth/service_user.go internal/auth/self_account_scope_test.go internal/server/self_account_scope_dispatch_test.go; do
  echo "--- $f"
  git diff --no-ext-diff --no-renames --unified=12 "$base"...HEAD -- "$f"
done

printf '%s\n' '=== Static patch integrity ==='
git diff --check "$base"...HEAD
git status --short

Length of output: 53265


@cristim, I reviewed head cd20d5250b823e7217f61422272162a18a39e6ae.

I found no blocking issue.

The guard rejects both escalation paths:

  • Adding a broader-scoped group is rejected by accountScopeGap(priorAccounts, nextAccounts).
  • Removing the group that supplies the restriction is rejected because an empty resulting account list means unrestricted access.

The unresolved-group relaxation does not fail open. If resolved groups produce a non-empty account union, skipped groups cannot make the computed scope broader. They can only make the computed scope narrower than effective access. The ceiling can then reject too strictly, but it cannot allow a scope widening. If the surviving union is empty and one or more groups were skipped, accountsForGroups fails closed.

accountsForGroups and ResolveAllowedAccounts use the same skip rule. Storage errors other than a deleted or absent group still return an error. The tests cover both refusal paths, allowed non-widening changes, the fail-closed empty-survivor case, and cleanup of a dangling membership for an already unrestricted actor.

git diff --check reported no whitespace errors. I did not execute repository tests because the review sandbox does not permit executing repository code.

I also used the repository guidance that a throttled review requires a full review rather than an incremental review.

🐇🔎


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-26T17:21:01.385Z
Learning: For the LeanerCloud/CUDly repository review workflow, when a previous CodeRabbit review pass was skipped or failed to produce findings due to a rate-limit event, use a full review request on the pull request rather than the incremental review form.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-26T17:23:32.317Z
Learning: In this repository, if a CodeRabbit review was throttled or hit a rate limit, the correct recovery is to request `coderabbitai full review` rather than `coderabbitai review`, because incremental review can silently skip the affected in-flight commit and report a false-clean result.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-08-06T08:28:24.968Z
Learning: In the LeanerCloud/CUDly repository, request CodeRabbit reviews at most once per hour across the repository because the adaptive quota is shared across open pull requests. When a PR head was pushed while automatic review was quota-exhausted and was not retried, use a full review rather than an incremental review so the missed commits are reviewed.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-08-05T05:27:02.254Z
Learning: For the LeanerCloud/CUDly repository, pace CodeRabbit review requests at one request per hour across the repository. The review quota is adaptive and shared per developer and organization; burst requests can exhaust the quota and tighten the limit. When a PR head was pushed while quota was exhausted, request a full review because an incremental review skips the unreviewed commits.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-08-04T08:23:48.546Z
Learning: For the LeanerCloud/CUDly repository, CodeRabbit review quota is shared per developer across the organization and adapts to recent review volume. Space review requests to approximately one per hour. When an automatic review did not run because quota was exhausted, use a full review instead of an incremental review so CodeRabbit inspects the missed commits.
✅ Action performed

Full review finished.

@cristim
cristim merged commit 2c87db0 into main Aug 12, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/internal Team-internal only priority/p1 Next up; this sprint severity/critical Major harm when it happens triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(auth): account scope can be laundered through the group-membership endpoint (join a wider group, or leave the scoping one)

1 participant