Skip to content

Fix group grants: pagination, rule semantics, nested groups - #29

Open
luisina-santos wants to merge 8 commits into
luisinasantos/containerize-connectorfrom
luisinasantos/fix-group-grants
Open

Fix group grants: pagination, rule semantics, nested groups#29
luisina-santos wants to merge 8 commits into
luisinasantos/containerize-connectorfrom
luisinasantos/fix-group-grants

Conversation

@luisina-santos

@luisina-santos luisina-santos commented Jul 7, 2026

Copy link
Copy Markdown

Summary

Fixes CXH-1978 and, while auditing groupBuilder.Grants() for that bug, extends it to correctly model Cloudflare Access Group Include/Require/Exclude semantics instead of only checking a flat list of Include emails.

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:

  • Include — OR (at least one rule matches)
  • Require — AND (every rule matches)
  • Exclude — NOT (no rule matches)

and each list can hold several rule types. Support differs by rule type, not by list — Require and Exclude themselves are fully supported and evaluated on every sync, but the specific "group" (nested Access group) rule type is only supported inside Include, never inside Require/Exclude. Final matrix:

List email email_domain everyone group (nested)
Include ✅ evaluated ✅ evaluated ✅ evaluated ✅ via GrantExpandable
Require ✅ evaluated ✅ evaluated ✅ evaluated ❌ not evaluated (see below)
Exclude ✅ evaluated ✅ evaluated ✅ evaluated ❌ not evaluated (see below)

So: a group whose Require/Exclude only use email/email_domain/everyone is fully and correctly handled. Only the narrower case of a "group" rule specifically inside Require or Exclude is the documented gap, explained in section 4.

1. Pagination bug (the original issue)

groupBuilder.Grants() discarded the cloudflare.ResultInfo returned by AccountMembers and never set a NextPageToken, 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 in roleBuilder.Grants(): read ResultInfo, compare TotalPages/Page, thread a NextPageToken via getPageTokenFromPage when more pages remain.

2. Rule-type coverage

The original code only matched Include rules of type email against a flat email list — Require and Exclude weren't checked at all, so a member excluded by name could still receive a grant, and Require conditions were silently ignored.

Extended Grants() to evaluate email, email_domain, and everyone rules directly against fetched account members, correctly gated by Require (AND) and Exclude (NOT) as shown in the matrix above.

3. Nested "group" Include rules: expandable grants, not flattening

Decision point: how to handle an Include rule that references another Access Group by ID (i.e. group nesting).

  • Rejected: recursively resolving the nested group's membership and emitting a direct grant per resolved user ("flattening"). This re-walks the nested group's membership on every sync and loses the fact that access came via nesting — explicitly rejected during review ("flat grants is not an option").
  • Implemented: one GrantExpandable grant per referenced group, with the nested group as principal and EntitlementIds pointing at its own member entitlement. 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.
  • The group's member entitlement's GrantableTo intentionally stays userResourceType only (not groupResourceType): GrantableTo is UI/provisioning metadata for who an operator can manually pick as a principal, unrelated to this internal expansion grant.

4. Require/Exclude referencing a nested group: the one unsupported case, by decision

A Require/Exclude rule can also reference a nested group (e.g. "user must also be in Engineering", "except members of Contractors"). Unlike Include, this can't be expressed via GrantExpandable (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 within Include (via the expandable-grant path above).
  • A "group" rule inside Require or Exclude is now an explicit, permanent no-op: ruleMatchesUser never matches it.
    • 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.
  • Grants() logs a Warn (once per affected group, on the first page) when a group's Require/Exclude contains an unsupported "group" rule, so it's discoverable at sync time, not just in docs.
  • Documented in docs/connector.mdx (new "Access group rules" section, with the same support matrix and a <Warning> explaining the limitation) and in the access_rules_helper.go package doc comment.

As a result of dropping recursion, rule evaluation (ruleMatchesUser/anyRuleMatches/satisfiesRequireExclude) is now pure — no ctx/client/cache — since Require/Exclude evaluation never needs to call Cloudflare.

5. StaticEntitlements instead of per-resource Entitlements

The group member entitlement is identical in shape for every group, so it's now declared once via StaticEntitlements (matching the pattern used in baton-ariba) instead of being rebuilt per group in Entitlements() (now a no-op). groupResourceType carries a new SkipEntitlements annotation so the SDK skips the now-unnecessary per-resource Entitlements() sync call (this does not skip Grants()). baton_capabilities.json regenerated to reflect the new annotation.

6. Group profile now surfaces its access rules

Added describeAccessRule/describeAccessRules to render every Include/Require/Exclude 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 act on, so nothing is silently hidden. newGroupResource sets include_rules/require_rules/exclude_rules on 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 ./... clean
  • go 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 descriptions
  • baton_capabilities.json / config_schema.json regenerated and diffed against ./connector capabilities / ./connector config output
  • Manual sync against a live Cloudflare account with >50 members and nested Access groups (not performed — no test tenant with those characteristics was available in this session)

🤖 Generated with Claude Code

luisina-santos and others added 7 commits July 7, 2026 10:56
…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>
@linear-code

linear-code Bot commented Jul 7, 2026

Copy link
Copy Markdown

CXH-1978

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>
@luisina-santos
luisina-santos marked this pull request as ready for review July 7, 2026 15:59
@luisina-santos
luisina-santos requested a review from a team July 7, 2026 16:02
Comment thread docs/connector.mdx
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just not apply grant expansion when excludes are detected?

Comment thread pkg/connector/groups.go
resource,
memberRole,
nestedGroupResource.Id,
grant.WithAnnotation(&v2.GrantExpandable{EntitlementIds: []string{nestedEntitlementID}}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants