Skip to content

fix: emit role entitlements statically to avoid N×N explosion (CXH-1977) - #28

Open
luisina-santos wants to merge 4 commits into
luisinasantos/containerize-connectorfrom
luisinasantos/fix-roles-model
Open

fix: emit role entitlements statically to avoid N×N explosion (CXH-1977)#28
luisina-santos wants to merge 4 commits into
luisinasantos/containerize-connectorfrom
luisinasantos/fix-roles-model

Conversation

@luisina-santos

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

Copy link
Copy Markdown

Summary

Fixes CXH-1977.

roleBuilder.Entitlements() is called once per role resource by the SDK, but it re-fetched the full account role list and emitted one entitlement per role onto the current resource — producing O(n²) entitlements (50 roles → 2500). Only one entitlement per resource (the one whose slug matched the resource's own role name) could ever receive a grant; the other ~n-1 were dead.

While live-testing the fix against a real Cloudflare Zero Trust account, two further, independent bugs surfaced and are fixed in this same PR (see below): roleBuilder.List() was silently truncating role sync to 50 of 112 real roles, and — as a consequence — role grants were always empty.

1. Static entitlements (the original N² fix)

  • StaticEntitlements() declares a single assigned assignment entitlement (grantable to user). The SDK materializes it once per role resource → exactly N entitlements.
  • The role resource type is annotated with SkipEntitlements, so the SDK no longer calls the per-resource Entitlements() hook (which now returns nil).
  • baton_capabilities.json regenerated to reflect the new annotations.

2. Role List() pagination truncation (found via live testing)

ListAccountRoles (cloudflare-go) only returns a ResultInfo — and therefore a "more pages?" signal — when called without explicit Page/PerPage. Passing them turns off the client's own internal pagination with no way for the caller to detect truncation. roleBuilder.List() was passing explicit paging params and unconditionally returning no next-page-token, so it always stopped after page 1.

Verified against the test account: 112 total roles exist, only 50 were synced. Neither of the two roles actually assigned to real members ("Super Administrator - All Privileges", "Cloudflare Access") was in that truncated set — so Grants() was correctly checking real data, but had nothing to match against. This is the root cause of role grants being empty, independent of the N² entitlements bug.

Fix: call ListAccountRoles with no paging params, letting the client fetch every role internally in one call — the same pattern groupBuilder.List() already uses for ListAccessGroups.

3. Role grants restructured to be emitted from users, not roles

Even with (2) fixed, roleBuilder.Grants() re-fetched and re-scanned the entire paginated member list once per role — Cloudflare has no "list members with role X" endpoint. With 100+ built-in roles per tenant (most with zero assignees), that's a lot of redundant, expensive re-fetching for no benefit.

Decision made: move role-assignment grant emission to userBuilder.Grants() instead, following an established pattern already used across several other baton-* connectors in this org (e.g. baton-arctic-wolf, baton-metabase, baton-openai) — a resource builder emitting grants for a different resource type's entitlement is a normal, supported SDK usage, not a workaround.

  • Each account member's role IDs are captured once, during the member pagination userBuilder.List() already performs for the merged user sync (see the earlier user/member resource-type merge), and embedded directly into that user's profile (role_ids, comma-separated).
  • userBuilder.Grants() reads the role IDs straight off the already-persisted resource — zero additional API calls, no member-list scanning at sync time at all.
  • roleBuilder.Grants() now always returns nil, and the role resource type additionally carries SkipGrants so the SDK never dispatches a per-resource Grants call for any role.
  • getMemberId (used only by the admin-driven Grant/Revoke provisioning actions, not sync) now shares a correctly-paginated member-lookup helper — this also fixes a latent, unrelated bug where it only ever checked the first page of members.

We considered and ruled out two alternatives before landing here:

  • Per-user member lookup inside Grants() (scan all members once per user) — rejected: still O(users) full member-list scans, no real improvement over the original O(roles) scans for tenants with more users than roles.
  • AccountMember/GetAccountMember by native user ID — verified empirically that Cloudflare's single-member endpoint only accepts the internal membership ID, not the native user ID we key user resources by, so no true O(1) single-call lookup exists. Confirmed by direct API test (Member not found for account (1003)).

4. Pending (not-yet-accepted) invite members — decision needed / documented here

Cloudflare account members can be in status: pending (invited, not yet accepted). These have an empty native user ID (member.User.ID == "") until the invite is accepted. This is a real, observed case in the test tenant.

Decision made for this PR: skip syncing a user resource for any member with an empty native user ID; it's picked up cleanly on a later sync once the invite is accepted and Cloudflare assigns a real ID.

Why not synthesize an ID instead? Two alternatives were considered and rejected:

  • Using the membership ID (member.ID, stable even while pending) as a stand-in resource ID — rejected because once the invite is accepted, Cloudflare's canonical identifier for that person becomes the native user ID (member.User.ID), which is different from the membership ID. Using the membership ID either forces an ID migration later (orphaning grants/history — an unstable-ID breaking change) or, if kept permanently, risks a duplicate user resource if that same person later also appears via ListAccessUsers (keyed by native user ID) — reintroducing the identity-split problem the user/member merge fix solved.
  • Using email as a fallback ID — rejected: different ID format than every other user resource (native Cloudflare IDs elsewhere), and not fully guaranteed stable either.

No warning is logged for skipped pending members (kept intentionally quiet per team preference) — flagging here in case reviewers want visibility into this behavior another way (e.g. a sync summary count) before merge.

Grant correctness

The static entitlement and the grant derive the same ID via NewEntitlementID(role, "assigned") = role:<roleID>:assigned, so grant→entitlement matching holds regardless of which builder emits the grant. Provisioning (Grant/Revoke) is unaffected — it keys off the role ID and user principal, never the slug.

Behavior change / breaking note

The stored entitlement/grant slug moves from the per-role name to a fixed assigned. This re-keys role grants and is a breaking change for any existing sync data, which is acceptable pre-rollout on the containerization branch. Merging into luisinasantos/containerize-connector.

Testing

  • go build ./... and go vet ./... pass.
  • Live-tested end-to-end against a real Cloudflare Zero Trust test account through multiple iterations:
    • Confirmed entitlement count went from 2500 (N²) → 113 (N+1, matching 112 roles + 1 group).
    • Confirmed role resource count went from 50 (truncated) → 112 (all roles).
    • Confirmed role grants went from 0 → 2 correct grants, matching real member/role assignments 1:1 (baton grants -f sync.c1z --resource-type role), with the 3rd real assignment (a pending invite) correctly and cleanly excluded.
    • Confirmed zero errors/warnings on the final sync.

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Jul 6, 2026

Copy link
Copy Markdown

CXH-1977

@luisina-santos
luisina-santos requested a review from ggreer July 6, 2026 19:49
luisina-santos and others added 3 commits July 6, 2026 17:19
roleBuilder.Entitlements() was called once per role resource but re-fetched
the full account role list and emitted one entitlement per role onto the
current resource, producing O(n²) entitlements (50 roles → 2500) where all
but one per resource could never receive a grant.

Declare the single role assignment entitlement via StaticEntitlements, which
the SDK materializes once per role resource, and annotate the role resource
type with SkipEntitlements so the per-resource entitlements phase is skipped.
Grants now use the fixed "assigned" slug so they continue to match the
statically materialized entitlement IDs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on (CXH-1977)

Live-tested against a real Cloudflare Zero Trust account: roleBuilder.List()
requested ListAccountRoles with explicit Page/PerPage, but that call only
returns a ResultInfo (and thus a "more pages?" signal) when called WITHOUT
paging params — passing them turns off the client's own pagination with no
way to detect truncation. This silently capped role sync at 50 resources
while the test account has 112 roles, and neither of the two roles actually
assigned to real members were in that truncated set, so role grants were
always empty. Fixed by calling ListAccountRoles with no paging params,
letting the client fetch every role internally (mirrors groupBuilder.List's
existing call to ListAccessGroups).

Restructure role assignment grants to be emitted from userBuilder.Grants
instead of roleBuilder.Grants. Cloudflare has no endpoint to list "members
with role X", so computing grants role-by-role means re-fetching and
re-scanning the full member list once per role (100+ built-in roles per
tenant, most with zero assignees). Each account member's role IDs are now
captured once, during the member pagination userBuilder.List already does
for the merged user sync, and embedded in the user's profile.
userBuilder.Grants reads them back with no further API calls; roleBuilder.
Grants always returns nil and the role resource type now carries SkipGrants
so the SDK never dispatches it. getMemberId (used by the Grant/Revoke
provisioning actions) now shares the same correctly-paginated member lookup,
fixing a latent bug where it only ever checked the first page of members.

Account members with no native Cloudflare user ID (pending, not-yet-accepted
invites) are skipped when building user resources - there's no stable ID to
key a resource on until the invite is accepted, at which point a later sync
picks them up cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion helper

userBuilder.Grants was returning a nil error whenever getValueFromUserTrait
failed for any reason, masking genuine errors (e.g. GetUserTrait failing)
behind the same nil as the expected "field not present" case. Only treat a
missing role_ids field as the empty case; propagate real errors.

Also remove getPageTokenFromPage, dead since roleBuilder.List and Grants no
longer paginate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@luisina-santos
luisina-santos force-pushed the luisinasantos/fix-roles-model branch from a12fecf to f30b286 Compare July 6, 2026 20:19
roleBuilder.Grant/Revoke updated a member's classic Roles list, but
Cloudflare rejects a Roles-only update once a member has any Policies
(Domain Scoped Roles accounts mirror role assignments as policies) —
confirmed live against the CI test tenant: granting role "Workers Editor"
failed with "Invalid role assignments found (1001)".

When account.Result.Policies is non-empty, Grant/Revoke now go through an
equivalent Policy instead: look up the permission group that mirrors the
role by name (permission groups share their name with the classic role they
represent), and add/remove it via Policies. Members with no existing
Policies keep going through the original Roles path unchanged.

Revoke strips only the matching permission group from within each policy
rather than dropping the whole policy - a policy can bundle multiple
permission groups, and an earlier version of this fix dropped a policy
entirely whenever it matched, which also revoked unrelated permissions
bundled in the same policy object. A policy is only removed once it has no
permission groups left.

Verified live end-to-end against the CI test tenant: grant, revoke, and a
second grant/revoke cycle all round-trip correctly, restoring the account to
its original state with no leftover or duplicate policies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@btipling btipling left a comment

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.

Approved, but this will churn grants I think, it is a breaking change.

@btipling btipling removed their assignment Jul 13, 2026

@mateoHernandez123 mateoHernandez123 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A few suggestions, if they're applicable to this connector

Comment thread pkg/connector/roles.go
return memberUser.ID, nil
}
if member == nil {
return "", nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

getMemberId returns ("", nil) when no member matches (e.g. Access-only user). Grant/Revoke still call GetAccountMember with "".

Treat nil member as NotFound/InvalidArgument and return before the GET.

Comment thread pkg/connector/roles.go
return nil, err
}

l.Warn("Role has been created.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New Policy paths log at Warn — team hard-ban in connector code (inflates OTEL / alerting).

Drop or downgrade to Debug on these Policy branches (and the classic ones while you're here).

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.

4 participants