From 5b2b391de79fb58d5fe1983d258be3812cd04bc1 Mon Sep 17 00:00:00 2001 From: Lauren Leach Date: Fri, 7 Aug 2026 15:44:17 -0700 Subject: [PATCH] Guard scope grant emission behind WillSyncResourceType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gates cross-type grant emission from the user syncer on the customer's sync filter, so grants aren't emitted for a resource type the sync excludes. Reference: ConductorOne/baton-linear#55. Each target is guarded individually in Grants(); when every target is excluded the user resource type is annotated SkipEntitlementsAndGrants so the SDK skips the pass entirely. Flags are named skipResourceType and stored inverted so the zero value means "sync everything" — main.go registers a zero-value Connector{} as the capabilities factory, bypassing New. Build, tests, and golangci-lint (0 issues) pass. **No resource-type annotation here, deliberately.** `teammateBuilder` has entitlements of its own (`access`, grantable to subuser) and emits subuser grants alongside the scope grants, so `SkipEntitlements` / `SkipEntitlementsAndGrants` would suppress real data. Only the scope emission is gated, and the guard sits before the per-teammate scope lookup so the extra API call is skipped too. --- pkg/connector/connector.go | 20 ++++++++++----- pkg/connector/teammates.go | 15 ++++++++--- pkg/connector/teammates_test.go | 45 +++++++++++++++++++++++++++++---- 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 960f7535..9969bbc5 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -52,12 +52,16 @@ type SendGridClient interface { type Connector struct { client SendGridClient ignoreSubusers bool + // skipScopeResourceType reports whether scope is excluded from the sync + // filter. Named for the skip condition so the zero value is safe: main.go + // registers a zero-value Connector{} as the capabilities factory. + skipScopeResourceType bool } // ResourceSyncers returns a ResourceSyncerV2 for each resource type that should be synced from the upstream service. func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 { return []connectorbuilder.ResourceSyncerV2{ - newTeammateBuilder(d.client), + newTeammateBuilder(d.client, d.skipScopeResourceType), newTeammateInvitationBuilder(d.client), newScopeBuilder(d.client), newSubuserBuilder(d.client, d.ignoreSubusers), @@ -120,19 +124,20 @@ func (d *Connector) Validate(ctx context.Context) (annotations.Annotations, erro } // New returns a new instance of the connector. -func New(ctx context.Context, sgClient SendGridClient, ignoreSubusers bool) (*Connector, error) { +func New(ctx context.Context, sgClient SendGridClient, ignoreSubusers bool, skipScopeResourceType bool) (*Connector, error) { if sgClient == nil { return nil, ErrSendgridClientNotProvided } return &Connector{ - client: sgClient, - ignoreSubusers: ignoreSubusers, + client: sgClient, + ignoreSubusers: ignoreSubusers, + skipScopeResourceType: skipScopeResourceType, }, nil } // NewLambdaConnector creates a new connector from config for lambda/containerized deployment. -func NewLambdaConnector(ctx context.Context, cfg *config.Sendgrid, _ *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) { +func NewLambdaConnector(ctx context.Context, cfg *config.Sendgrid, opts *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) { l := ctxzap.Extract(ctx) sendGridApiKey := cfg.SendgridApiKey @@ -161,7 +166,10 @@ func NewLambdaConnector(ctx context.Context, cfg *config.Sendgrid, _ *cli.Connec return nil, nil, fmt.Errorf("baton-sendgrid: error creating client: %w", err) } - cb, err := New(ctx, sendGridClient, sendgridIgnoreSubusers) + // nil opts means no filter, so nothing is skipped. + skipScopeResourceType := opts != nil && !opts.WillSyncResourceType(scopeResourceType.Id) + + cb, err := New(ctx, sendGridClient, sendgridIgnoreSubusers, skipScopeResourceType) if err != nil { return nil, nil, fmt.Errorf("baton-sendgrid: error creating connector: %w", err) } diff --git a/pkg/connector/teammates.go b/pkg/connector/teammates.go index b3faa7b4..6a923b6e 100644 --- a/pkg/connector/teammates.go +++ b/pkg/connector/teammates.go @@ -28,6 +28,11 @@ const ( type teammateBuilder struct { client SendGridClient + // skipScopeResourceType reports whether scope is excluded from the sync + // filter. Only the scope emission below is gated: teammates have their own + // entitlements and subuser grants, so the resource-type-level skip + // annotations would suppress real data and are deliberately not used here. + skipScopeResourceType bool } func (u *teammateBuilder) ResourceType(ctx context.Context) *v2.ResourceType { @@ -241,8 +246,9 @@ func (u *teammateBuilder) Grants(ctx context.Context, resource *v2.Resource, opt rv = append(rv, grants...) } - // Scope grants — only on the first (and only) page to avoid duplicate API calls. - if opts.PageToken.Token == "" { + // Scope grants — only on the first (and only) page to avoid duplicate API + // calls, and only when scope is in the sync filter. + if opts.PageToken.Token == "" && !u.skipScopeResourceType { specificTeammate, err := u.client.GetSpecificTeammate(ctx, sgclient.Username(username), sgclient.OnBehalfOf(onBehalfOf)) if err != nil { return nil, nil, fmt.Errorf("baton-sendgrid: failed to get teammate %s: %w", username, err) @@ -293,9 +299,10 @@ func (u *teammateBuilder) Delete(ctx context.Context, resourceId *v2.ResourceId, return nil, nil } -func newTeammateBuilder(client SendGridClient) *teammateBuilder { +func newTeammateBuilder(client SendGridClient, skipScopeResourceType bool) *teammateBuilder { return &teammateBuilder{ - client: client, + client: client, + skipScopeResourceType: skipScopeResourceType, } } diff --git a/pkg/connector/teammates_test.go b/pkg/connector/teammates_test.go index b01afeba..90d1472d 100644 --- a/pkg/connector/teammates_test.go +++ b/pkg/connector/teammates_test.go @@ -195,7 +195,7 @@ func TestTeammateBuilder_List_RootTeammates(t *testing.T) { }, } - tb := newTeammateBuilder(client) + tb := newTeammateBuilder(client, false) resources := drainTeammateList(t, tb, nil, nil) require.Len(t, resources, 2) @@ -222,7 +222,7 @@ func TestTeammateBuilder_List_SubuserTeammates(t *testing.T) { }, } - tb := newTeammateBuilder(client) + tb := newTeammateBuilder(client, false) sub1ResourceID, err := rs.NewResourceID(subuserResourceType, 1) require.NoError(t, err) @@ -261,7 +261,7 @@ func TestTeammateBuilder_List_TeammateRestrictedToMultipleSubusers(t *testing.T) }, } - tb := newTeammateBuilder(client) + tb := newTeammateBuilder(client, false) session := newFakeSessionStore() sub1ResourceID, err := rs.NewResourceID(subuserResourceType, 1) @@ -298,7 +298,7 @@ func TestTeammateBuilder_Grants_SubuserAccessForbidden(t *testing.T) { resource, err := teammateResource(&models.Teammate{Username: "local-1", Email: "local-1@example.com"}, sub1ResourceID, "sub1") require.NoError(t, err) - tb := newTeammateBuilder(client) + tb := newTeammateBuilder(client, false) grants, results, err := tb.Grants(context.Background(), resource, rs.SyncOpAttrs{PageToken: pagination.Token{}}) require.NoError(t, err, "a 403 from subuser_access must not abort Grants for a subuser-only teammate") @@ -325,8 +325,43 @@ func TestTeammateBuilder_Grants_SubuserAccessOtherErrorPropagates(t *testing.T) resource, err := teammateResource(&models.Teammate{Username: "local-1", Email: "local-1@example.com"}, sub1ResourceID, "sub1") require.NoError(t, err) - tb := newTeammateBuilder(client) + tb := newTeammateBuilder(client, false) _, _, err = tb.Grants(context.Background(), resource, rs.SyncOpAttrs{PageToken: pagination.Token{}}) require.Error(t, err, "only PermissionDenied should be tolerated, other errors must still propagate") } + +// scopeCallRecorder records whether the scope lookup was attempted. +type scopeCallRecorder struct { + fakeSendGridClient + called bool +} + +func (f *scopeCallRecorder) GetSpecificTeammate(_ context.Context, _ sgclient.Username, _ sgclient.OnBehalfOf) (*models.TeammateScope, error) { + f.called = true + return &models.TeammateScope{Teammate: models.Teammate{Username: "u1"}, Scopes: []string{"mail.send"}}, nil +} + +// Scope grants are cross-type. When scope is excluded from the sync filter the +// connector must not even make the per-teammate scope lookup. Subuser grants +// are unaffected: teammates own those. +func TestTeammateBuilder_Grants_SkipScopeResourceType(t *testing.T) { + ctx := context.Background() + res, err := teammateResource(&models.Teammate{Username: "u1", Email: "u1@example.com"}, nil, "") + require.NoError(t, err) + + filtered := &scopeCallRecorder{} + tb := newTeammateBuilder(filtered, true) + grants, _, err := tb.Grants(ctx, res, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.False(t, filtered.called, "scope lookup must be skipped when scope is filtered out") + for _, g := range grants { + require.NotEqual(t, scopeResourceType.Id, g.GetEntitlement().GetResource().GetId().GetResourceType()) + } + + inScope := &scopeCallRecorder{} + tb = newTeammateBuilder(inScope, false) + _, _, err = tb.Grants(ctx, res, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.True(t, inScope.called, "scope lookup must run when scope is in the sync filter") +}