Fix group grants: pagination, rule semantics, nested groups - #29
Open
luisina-santos wants to merge 8 commits into
Open
Fix group grants: pagination, rule semantics, nested groups#29luisina-santos wants to merge 8 commits into
luisina-santos wants to merge 8 commits into
Conversation
…members Grants() discarded the ResultInfo from AccountMembers and never set a NextPageToken, so accounts with more than 50 members silently lost group membership grants for anyone beyond the first page. Mirrors the pagination pattern already used in roleBuilder.Grants(). Fixes CXH-1978. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…roup grants groupBuilder.Grants() only matched Include rules of type "email", ignoring Require (AND), Exclude (NOT), and other rule types entirely. This both missed real members (email_domain, everyone, nested group references) and could report grants for members who should have been excluded or who didn't satisfy a require condition. groupIncludesUser now evaluates the full Include/Require/Exclude combinator semantics, recursing into nested "group" rules with cycle protection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ened users groupBuilder.Grants() previously resolved a nested "group" Include rule by recursively walking the referenced group's own membership and emitting a direct grant per resolved user. That re-fetches and re-evaluates the nested group's membership on every sync and loses the fact that access came via group nesting. Direct Include rules (email, email_domain, everyone) are now evaluated against fetched account members as before, gated by Require/Exclude. Nested "group" Include rules instead emit one GrantExpandable grant per referenced group, with the nested group itself as principal and EntitlementIds pointing at its own member entitlement — so C1's graph expansion resolves membership (including further nesting) instead of the connector flattening it. The group entitlement is now also grantable to groupResourceType to support this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…source Entitlements The "member" entitlement is identical in shape (slug, grantable-to, display name) for every group, so declare it once via StaticEntitlements instead of rebuilding it per group in Entitlements(). groupResourceType now carries SkipEntitlements so the SDK doesn't bother invoking the per-resource Entitlements() call, which is now a no-op. Also drop groupResourceType from the entitlement's GrantableTo: that field declares which principal types can be manually provisioned this entitlement from the UI, which isn't the intent of the nested-group GrantExpandable grant added earlier — that grant's principal is a group only as an internal expansion pointer, not something an operator should pick from a picklist. Regenerated baton_capabilities.json to reflect the new SkipEntitlements annotation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… profile
Add describeAccessRule(s) to render each rule as a short human-readable
string ("email:jane@x.com", "email_domain:x.com", "everyone", "group:eng",
"ip:10.0.0.0/8", etc.), including rule types this connector doesn't
evaluate for membership, so operators can see a group's full configuration
without pulling the raw Cloudflare API response. newGroupResource now sets
include_rules/require_rules/exclude_rules on the group profile when present.
Also rename access_rules.go to access_rules_helper.go: it's a pure rule-
evaluation/description helper, not a resource builder.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
"cache" implied a general memoization layer; rename to make clear it's just remembering each nested group's definition once it's already been fetched, so a group referenced from Require/Exclude by multiple rules or evaluated for multiple members isn't refetched. getAccessGroupCached -> resolveGroup, cache param -> groups. Clarified the doc comments: this resolution path is only reached for Require/Exclude "group" rules, which gate an already-identified user and have no principal to point a grant at. Include's "group" rules never reach it — those are resolved as GrantExpandable grants in groups.go without fetching or evaluating the nested group's membership at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rules groupIncludesUser recursively re-evaluated a referenced group's own Include/Require/Exclude for every member whenever Require or Exclude contained a "group" rule. Decided against carrying that recursion: instead, "group" rules are only evaluated in Include (as a GrantExpandable grant, no per-user evaluation at all); a "group" rule in Require or Exclude is now explicitly unsupported and never matches. Consequences, both intentional and documented: - Require referencing a group: nobody satisfies it (fails closed, no grants fabricated). - Exclude referencing a group: not enforced — a member who should be excluded via nested-group membership may still be granted. This is the risky direction, so Grants() now logs a warning per affected group. ruleMatchesUser/anyRuleMatches/satisfiesRequireExclude are now pure functions (no ctx/client/accountId/cache), since Require/Exclude evaluation no longer needs to fetch anything from Cloudflare. Documented the supported-rule-type split and the Require/Exclude limitation in docs/connector.mdx (new "Access group rules" section) and in the package doc comment in access_rules_helper.go. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Warn-level logs aren't allowed in connector code per convention — they surface prominently in C1 and should be reserved for truly exceptional, non-recurrent conditions. A group whose Require/Exclude references a nested group re-triggers this on every sync for as long as the customer's Cloudflare config stays that way, so it's recurring, not exceptional. Downgraded both to Debug (found in connector-layer review of PR #29). Also fixes a golangci-lint nonamedreturns failure: splitIncludeRules used named return values purely for documentation; switched to local variables with explicit returns. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
btipling
reviewed
Jul 15, 2026
| requirement through this connector. | ||
| - An `Exclude` rule that references a group is **not enforced**: a member | ||
| who should be excluded because they belong to the referenced group may | ||
| still be reported as a member of the outer group. |
Contributor
There was a problem hiding this comment.
Should we just not apply grant expansion when excludes are detected?
| resource, | ||
| memberRole, | ||
| nestedGroupResource.Id, | ||
| grant.WithAnnotation(&v2.GrantExpandable{EntitlementIds: []string{nestedEntitlementID}}), |
There was a problem hiding this comment.
Nested Include always emits GrantExpandable. C1 expands nested members into the outer group without applying outer Require/Exclude — so Exclude can be bypassed (and Require is silently ignored on the nested path). Direct email/domain/everyone paths do gate; expansion does not.
Skip nested expandable when len(Require)>0 || len(Exclude)>0 (fail closed). Keep direct-rule evaluation. Same point Bjorn raised on the docs warning.
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.
Summary
Fixes CXH-1978 and, while auditing
groupBuilder.Grants()for that bug, extends it to correctly model Cloudflare Access GroupInclude/Require/Excludesemantics instead of only checking a flat list ofIncludeemails.Each commit is a self-contained step; the decisions behind them are below.
Final rule support (read this before the per-rule-type table below)
Cloudflare Access Groups combine three rule lists with different boolean logic:
and each list can hold several rule types. Support differs by rule type, not by list —
RequireandExcludethemselves are fully supported and evaluated on every sync, but the specific"group"(nested Access group) rule type is only supported insideInclude, never insideRequire/Exclude. Final matrix:emailemail_domaineveryonegroup(nested)GrantExpandableSo: a group whose
Require/Excludeonly useemail/email_domain/everyoneis fully and correctly handled. Only the narrower case of a"group"rule specifically insideRequireorExcludeis the documented gap, explained in section 4.1. Pagination bug (the original issue)
groupBuilder.Grants()discarded thecloudflare.ResultInforeturned byAccountMembersand never set aNextPageToken, so any Cloudflare account with more than 50 members (the page size) silently lost membership grants for every member past the first page. Fixed to mirror the working pattern already used inroleBuilder.Grants(): readResultInfo, compareTotalPages/Page, thread aNextPageTokenviagetPageTokenFromPagewhen more pages remain.2. Rule-type coverage
The original code only matched
Includerules of typeemailagainst a flat email list —RequireandExcludeweren't checked at all, so a member excluded by name could still receive a grant, andRequireconditions were silently ignored.Extended
Grants()to evaluateemail,email_domain, andeveryonerules directly against fetched account members, correctly gated byRequire(AND) andExclude(NOT) as shown in the matrix above.3. Nested
"group"Include rules: expandable grants, not flatteningDecision point: how to handle an
Includerule that references another Access Group by ID (i.e. group nesting).GrantExpandablegrant per referenced group, with the nested group as principal andEntitlementIdspointing at its ownmemberentitlement. C1's own graph-expansion engine (pkg/sync/expand) then resolves that group's membership, including further nesting, up to its configured max depth — no per-user evaluation or extra Cloudflare API calls needed for this path at all.memberentitlement'sGrantableTointentionally staysuserResourceTypeonly (notgroupResourceType):GrantableTois UI/provisioning metadata for who an operator can manually pick as a principal, unrelated to this internal expansion grant.4.
Require/Excludereferencing a nested group: the one unsupported case, by decisionA
Require/Excluderule can also reference a nested group (e.g. "user must also be in Engineering", "except members of Contractors"). UnlikeInclude, this can't be expressed viaGrantExpandable(a pure union/OR primitive — confirmed by reading the SDK, there is no negative/conditional grant primitive). The only way to resolve it is to answer, per user, "is this user a member of the referenced group" — which means recursively re-running the same Include/Require/Exclude evaluation against that group.An initial implementation did exactly that (recursive
groupIncludesUser, with a nested-group fetch cache and cycle guard). Decision: reject the recursive approach. Final behavior:"group"rules are only ever evaluated withinInclude(via the expandable-grant path above)."group"rule insideRequireorExcludeis now an explicit, permanent no-op:ruleMatchesUsernever matches it.Requirereferencing a group → nobody satisfies it → fails closed, no grants fabricated.Excludereferencing a group → not enforced → a member who should be excluded via nested-group membership may still be granted. This is the risky direction.Grants()logs aWarn(once per affected group, on the first page) when a group'sRequire/Excludecontains an unsupported"group"rule, so it's discoverable at sync time, not just in docs.docs/connector.mdx(new "Access group rules" section, with the same support matrix and a<Warning>explaining the limitation) and in theaccess_rules_helper.gopackage doc comment.As a result of dropping recursion, rule evaluation (
ruleMatchesUser/anyRuleMatches/satisfiesRequireExclude) is now pure — noctx/client/cache — sinceRequire/Excludeevaluation never needs to call Cloudflare.5.
StaticEntitlementsinstead of per-resourceEntitlementsThe group
memberentitlement is identical in shape for every group, so it's now declared once viaStaticEntitlements(matching the pattern used inbaton-ariba) instead of being rebuilt per group inEntitlements()(now a no-op).groupResourceTypecarries a newSkipEntitlementsannotation so the SDK skips the now-unnecessary per-resourceEntitlements()sync call (this does not skipGrants()).baton_capabilities.jsonregenerated to reflect the new annotation.6. Group profile now surfaces its access rules
Added
describeAccessRule/describeAccessRulesto render everyInclude/Require/Excluderule as a short human-readable string (email:jane@x.com,email_domain:x.com,everyone,group:eng,ip:10.0.0.0/8, etc.) — including rule types this connector doesn't act on, so nothing is silently hidden.newGroupResourcesetsinclude_rules/require_rules/exclude_ruleson the group's profile when present, so customers can see how a group is configured in C1 without pulling the raw Cloudflare API response.Test plan
go build ./...,go vet ./...cleango test ./...— 27 tests passing, including new coverage for pagination, Include/Require/Exclude combinator logic, the nested-group expandable-grant path, the Require/Exclude "group" rule limitation, and profile rule descriptionsbaton_capabilities.json/config_schema.jsonregenerated and diffed against./connector capabilities/./connector configoutput🤖 Generated with Claude Code