Skip to content

Guard role, source, warehouse, function, space grant emission behind WillSyncResourceType - #20

Open
laurenleach wants to merge 5 commits into
mainfrom
lauren.leach/guard-cross-type-grants
Open

Guard role, source, warehouse, function, space grant emission behind WillSyncResourceType#20
laurenleach wants to merge 5 commits into
mainfrom
lauren.leach/guard-cross-type-grants

Conversation

@laurenleach

Copy link
Copy Markdown
Contributor

Summary

userBuilder.Grants() (pkg/connector/users.go) and groupBuilder.Grants() (pkg/connector/groups.go, the "group-roles" pagination phase) both emit grants for other resource types (role, source, warehouse, function, space) as a sync optimization — the user/group API response already includes permission data for those types.

This emission was unconditional. If a customer's sync filter excludes a target type (e.g. they sync users but not roles), the connector still emitted grants referencing a type it isn't syncing — wasted work and dangling grants.

This PR gates that cross-type grant emission behind cli.ConnectorOpts.WillSyncResourceType, following the reference pattern in ConductorOne/baton-linear#55.

Target types gated

  • role (workspace-scoped permissions, which map to a role:{id}:member grant)
  • source
  • warehouse
  • function
  • space

Builders fixed

  • userBuilder (the originally reported bug)
  • groupBuilder — found during implementation to have the identical bug in its "group-roles" pagination phase (not originally named in the task brief; included after confirming the fix pattern applies identically). The "group-members" phase, which emits the group's own membership grants, is untouched.

Changes

  • pkg/connector/helpers.go: added willSyncResourceType(cliOpts, resourceTypeID), a nil-safe wrapper around cli.ConnectorOpts.WillSyncResourceType (nil cliOpts means "sync everything", matching direct-construction test usage).
  • pkg/connector/connector.go: Connector now carries cliOpts, threaded through from New() and passed to newUserBuilder / newGroupBuilder. All other builder constructions are unchanged.
  • pkg/connector/users.go: userBuilder gets a cliOpts field; Grants() skips emitting a cross-type grant when the target type won't be synced.
  • pkg/connector/groups.go: groupBuilder gets a cliOpts field; the "group-roles" phase of Grants() gets the identical gating.

resource_types.go annotations are untouched — there are 5 independently-filterable targets here, so a single SkipEntitlementsAndGrants/SkipEntitlements annotation toggle doesn't fit; the gating lives purely in Grants().

Tests

Added:

  • pkg/connector/users_test.go — spins up an httptest.Server for GET /users/{id} returning permissions scoped to WORKSPACE (role), SOURCE, WAREHOUSE, FUNCTION, and SPACE. Verifies all 5 cross-type grants are emitted with no filter (nil, &cli.ConnectorOpts{}, and an explicit empty SyncResourceTypeIDs), and that only the source grant survives when SyncResourceTypeIDs: []string{"user", "source"} is set.
  • pkg/connector/groups_test.go — same coverage for the two-phase groupBuilder.Grants() pagination: drives the "group-members" phase to get the next-page token, then the "group-roles" phase, asserting the same unfiltered/filtered behavior. Group membership grants are excluded from the assertion (only cross-type grants are checked).

No existing tests were weakened or removed.

Test plan

  • go build ./...
  • go vet ./...
  • gofmt -l clean
  • go test ./... -count=1 (all packages pass, including the two new test files)

🤖 Generated with Claude Code

@laurenleach
laurenleach requested a review from a team July 24, 2026 19:59
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: Guard role, source, warehouse, function, space grant emission behind WillSyncResourceType

Blocking Issues: 0 | Suggestions: 2 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 5dd010a0ef99.
Review mode: incremental since bd38624
View review run

Review Summary

The full PR diff was scanned for security and correctness; the new commit (2c5008f) was reviewed in depth for suggestion-level issues. All three previously reported findings are addressed: the stale newGroupBuilder doc comment now describes the real behavior, the group-roles phase short-circuits before GetGroup when skipTargets.all() is true (popping the bag state via NextToken("") so pagination still terminates), and TestUserBuilder_AnnotationsTrackFilter covers the SkipEntitlementsAndGrants branch. I confirmed against the vendored SDK that SkipEntitlements on the group resource type does not suppress static entitlements (vendor/.../pkg/sync/syncer.go:2110 never consults it), so the group member entitlement survives, and that crossTypeGrantTargets covers exactly the target types getScopeResourceType can produce once WORKSPACE is remapped to role. The two new findings are both minor and confined to the new test.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/groups_test.go:240: groupFetches is written from the httptest handler goroutine and read from the test goroutine without synchronization — latent go test -race failure (CI does not currently pass -race).
  • pkg/connector/groups_test.go:236-237: comment claims a suffix check on /users, but the code does an exact-equality match on /groups/g1.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/groups_test.go`:
- Around line 232-265: `groupFetches` is an unsynchronized `int` incremented inside the
  httptest handler goroutine (line 240) and read/reset from the test goroutine (lines 260,
  263, 265). There is no happens-before edge between an HTTP handler goroutine and the
  client goroutine, so `go test -race` can report a data race here. Change the declaration
  to `var groupFetches atomic.Int64`, use `groupFetches.Add(1)` in the handler,
  `groupFetches.Store(0)` for the reset, and compare `groupFetches.Load()` (against
  `int64(1)` / zero) in the assertions. Add `"sync/atomic"` to the imports.
- Around line 236-237: The comment says "The members path ends in /users, so a suffix check
  distinguishes the two", but line 239 performs an exact-equality match
  (`r.URL.Path == "/groups/g1"`), not a suffix check. Reword to describe what the code
  actually does, e.g. "the members path is /groups/g1/users, so exact-matching /groups/g1
  counts only the group-detail fetch."

Reviewed head SHA 2c5008f4c78b6dda142f256b31b12a087e882f32 against base 5dd010a0ef99f190d526ad572a789fe019902a99.

@github-actions github-actions Bot 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.

No blocking issues found.

Comment thread pkg/connector/groups.go Outdated
func newGroupBuilder(c *client.Client) *groupBuilder {
return &groupBuilder{client: c}
func newGroupBuilder(c *client.Client, cliOpts *cli.ConnectorOpts) *groupBuilder {
return &groupBuilder{client: c, cliOpts: cliOpts}

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 this method get only the syncRoles boolean value?

Comment thread pkg/connector/users.go Outdated

func newUserBuilder(c *client.Client) *userBuilder {
return &userBuilder{client: c}
func newUserBuilder(c *client.Client, cliOpts *cli.ConnectorOpts) *userBuilder {

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.

same here, why we need the entire connector opts struct?

…WillSyncResourceType

userBuilder.Grants() and groupBuilder.Grants() (group-roles phase) emit
grants for other resource types (role, source, warehouse, function, space)
as a sync optimization, since the user/group API response already includes
permission data for those types. This emission was unconditional, so a
customer syncing users but excluding e.g. roles would still get grants
referencing the unsynced role type -- wasted work and dangling grants.

Gate cross-type grant emission behind cli.ConnectorOpts.WillSyncResourceType,
mirroring ConductorOne/baton-linear#55. groupBuilder was found to have the
identical bug while implementing the userBuilder fix and is included here.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
@laurenleach
laurenleach force-pushed the lauren.leach/guard-cross-type-grants branch from cdc589e to f71a48c Compare August 7, 2026 18:21
Comment on lines +94 to +98
// Group-members phase grants target the "user" resource type; only
// count cross-type (group-roles phase) grants.
if g.Entitlement.Resource.Id.ResourceType == userResourceType.Id {
continue
}

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.

🟡 Suggestion: this filter never matches. Group-members-phase grants have the group as Entitlement.Resource (the user is the principal), so Entitlement.Resource.Id.ResourceType is "group", never "user". Since driveGroupRolesPhase already returns only phase-2 grants the check is dead code, but the comment is misleading and the helper would silently fail to exclude membership grants if it were ever fed both phases — compare against groupResourceType.Id (or drop the filter and rename the helper).

Comment thread pkg/connector/users.go
Comment on lines +128 to +131
targetTypeID := scopeResourceType.Id
if res.Type == ResourceTypeWorkspace {
targetTypeID = roleResourceType.Id
}

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.

🟡 Suggestion: res.Type == ResourceTypeWorkspace is case-sensitive, while getScopeResourceType normalizes with strings.ToUpper. If the API ever returns "workspace" in another casing, scopeResourceType still resolves to the workspace type but this check fails, so the gate would test "workspace" and the grant would be emitted on the workspace resource instead of role:{id}:member. The mismatch is pre-existing at line 140, but the new gate inherits it — normalizing res.Type once (e.g. strings.EqualFold or an upperType := strings.ToUpper(res.Type) local) would keep both branches consistent. Same applies in groups.go:215.

@github-actions github-actions Bot 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.

No blocking issues found.

Addresses review: builders no longer take the whole connector opts struct.
newSkipCrossTypeGrants precomputes, once, whether each cross-type target
(role, source, warehouse, function, space) is excluded from the sync; builders
receive that set and consult it per scope.

Also adds the resource-type annotation, built in the constructor:
SkipEntitlements normally, escalating to SkipEntitlementsAndGrants when every
target is excluded. userResourceType no longer declares SkipEntitlements
itself so Update() owns it.
Comment thread pkg/connector/groups.go Outdated
Comment on lines +344 to +353
// newGroupBuilder builds the syncer. Its only grants are cross-type, so when every target
// resource type is excluded the grants pass is skipped entirely.
func newGroupBuilder(c *client.Client, skipTargets skipCrossTypeGrants) *groupBuilder {
rt := proto.Clone(groupResourceType).(*v2.ResourceType)
annos := annotations.Annotations(rt.GetAnnotations())
if skipTargets.all() {
annos.Update(&v2.SkipEntitlementsAndGrants{})
} else {
annos.Update(&v2.SkipEntitlements{})
}

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.

🟠 Bug: The comment "Its only grants are cross-type" is not true for groupBuilder — the group-members phase (line 160-181) emits the group's own membership grants with user principals, and the PR description explicitly says that phase was left untouched. With --sync-resource-type user,group, all five entries in crossTypeGrantTargets are excluded so all() is true, the group resource type gets SkipEntitlementsAndGrants, and the SDK's shouldSkipGrantsshouldSkipEntitlementsAndGrants short-circuits the whole grants pass for group resources — silently dropping group membership grants even though user is in the sync set. Suggest always using SkipEntitlements here (the all()/SkipEntitlementsAndGrants shortcut is only sound for userBuilder, whose grants really are all cross-type).

Comment thread pkg/connector/groups.go Outdated
if skipTargets.all() {
annos.Update(&v2.SkipEntitlementsAndGrants{})
} else {
annos.Update(&v2.SkipEntitlements{})

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.

🟡 Suggestion: Adding SkipEntitlements to the group resource type buys nothing — groupBuilder.Entitlements already returns an empty slice — while the type now advertises "no entitlements" even though StaticEntitlements still emits the member entitlement that the membership grants and the GrantExpandable annotation (lines 194-202) reference. The SDK's static-entitlement pass ignores this annotation today, so it works, but the advertised contract and the emitted data now disagree. Consider leaving group's annotations alone.

grants := driveGroupRolesPhase(t, ctx, b, groupResource)
got := groupRoleGrantTargetTypes(t, grants)
require.Equal(t, []string{"source"}, got)
}

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.

🟡 Suggestion: Neither test file covers the skipTargets.all() branch (e.g. SyncResourceTypeIDs: []string{"user", "group"}), which is the new code path that swaps SkipEntitlements for SkipEntitlementsAndGrants. A test asserting the annotations returned by ResourceType(ctx) for both branches — and that group membership grants still survive when only user/group are synced — would have caught the group regression flagged above.

@github-actions github-actions Bot 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.

Blocking issues found — see review comments.

groupBuilder.Grants emits a mix: the cross-type role/source/warehouse/
function/space grants *and* the group's own member grants. Annotating the
group resource type with SkipEntitlementsAndGrants when every cross-type
target was filtered out made the SDK skip the whole grants pass, silently
dropping group membership — data no resource-type filter should affect.

Always annotate with SkipEntitlements only (the member entitlement comes from
StaticEntitlements, so Entitlements is empty either way) and let the existing
per-target filtering in Grants handle the cross-type grants.

newUserBuilder keeps using skipTargets.all(): all of its grants are
cross-type, so skipping the whole pass there is correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread pkg/connector/groups.go Outdated
Comment on lines +344 to +345
// newGroupBuilder builds the syncer. Its only grants are cross-type, so when every target
// resource type is excluded the grants pass is skipped entirely.

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.

🟡 Suggestion: This doc comment is a leftover copy of newUserBuilder's and now directly contradicts the body it sits above — the latest commit deliberately stopped skipping the grants pass for groups because the group-members phase emits the group's own membership grants. Reword to match, e.g. "newGroupBuilder builds the syncer. Cross-type grants are filtered per-target in Grants; the grants pass itself is never skipped because Grants also emits the group's own member grants."

Comment thread pkg/connector/groups.go
if res.Type == ResourceTypeWorkspace {
targetTypeID = roleResourceType.Id
}
if b.skipTargets.skip(targetTypeID) {

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.

🟡 Suggestion: When every cross-type target is excluded (skipTargets.all()), the group-roles phase still issues a GetGroup call per group (line 185) and then discards 100% of the resulting grants here. userBuilder avoids that via SkipEntitlementsAndGrants, but groups can't, so an early if b.skipTargets.all() { break } before the fetch (falling through to bag.NextToken("")) would eliminate N wasted API calls — which is the wasted-work the PR set out to remove.


got := userGrantTargetTypes(t, grants)
require.Equal(t, []string{"source"}, got)
}

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.

🟡 Suggestion: groups_test.go now pins the group annotation branch, but nothing covers newUserBuilder's skipTargets.all() branch — the one path that swaps SkipEntitlements for SkipEntitlementsAndGrants. A case with SyncResourceTypeIDs: []string{"user", "group"} asserting ResourceType(ctx) carries SkipEntitlementsAndGrants (and only SkipEntitlements otherwise) would lock in the behavior this PR's annotation logic depends on.

@github-actions github-actions Bot 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.

No blocking issues found.

The grant guard changes the user resource type's declared annotations, so
baton_capabilities.json no longer matches the committed docs and
validate_metadata fails the docs-freshness check. Regenerate the metadata
from the binary and document the entitlement behavior the metadata now
declares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 13, 2026

Copy link
Copy Markdown

CE-1166

@github-actions github-actions Bot 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.

No blocking issues found.

…coverage

- newGroupBuilder's doc comment was a leftover copy of newUserBuilder's and
  contradicted the body, which deliberately stopped skipping the grants pass
  for groups. Reword it, and narrow skipCrossTypeGrants.all()'s comment to
  match: only userBuilder's grants are entirely cross-type.
- The group-roles phase still issued one GetGroup per group and then
  discarded every resulting grant when all cross-type targets were excluded.
  Return early instead; the group's own member grants come from the
  group-members phase and are unaffected.
- Cover both gaps: pin newUserBuilder's SkipEntitlements /
  SkipEntitlementsAndGrants branches, and assert the group fetch is actually
  skipped by counting requests (one client per case, since uhttp caches).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// path ends in /users, so a suffix check distinguishes the two.
counting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/groups/g1" {
groupFetches++

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.

🟡 Suggestion: groupFetches is written from the httptest handler goroutine and read from the test goroutine at lines 260/263/265 with no synchronization — there is no happens-before edge between the two, so go test -race can flag this. The repo's CI (go test -v -covermode=count -json ./...) doesn't pass -race, so nothing fails today, but a local -race run would. Switching to var groupFetches atomic.Int64 with Add(1) / Load() / Store(0) removes the risk.

Comment on lines +236 to +237
// Wrap the fixture server so GET /groups/{id} can be counted. The members
// path ends in /users, so a suffix check distinguishes the two.

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.

🟡 Suggestion: This comment describes a check the code doesn't do — line 239 is an exact-equality match on /groups/g1, not a suffix check on /users. Same stale-comment class as the newGroupBuilder doc fixed in this commit; rewording to "the members path is /groups/g1/users, so exact-matching /groups/g1 counts only the detail fetch" keeps it accurate.

@github-actions github-actions Bot 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.

No blocking issues found.

Comment thread pkg/connector/groups.go

case "group-roles":
if b.skipTargets.all() {
// Every cross-type target is excluded, so this phase would fetch

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.

can we return nil, nil, nil here? you don't have to build an empty next page token i guess

Comment thread pkg/connector/groups.go
// newGroupBuilder builds the syncer. Cross-type grants are filtered per-target
// in Grants; the grants pass itself is never skipped, because Grants also emits
// the group's own member grants.
func newGroupBuilder(c *client.Client, skipTargets skipCrossTypeGrants) *groupBuilder {

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.

it is not needed if we unconditionally skip just entitlements, keep it at resource_type level

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.

2 participants