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") +}