Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions pkg/connector/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func NewClient(ctx context.Context, baseUrl, apiKey string) (*SendGridClient, er
return nil, err
}

uhtppClient, err := uhttp.NewBaseHttpClientWithContext(ctx, httpClient)
uhtppClient, err := uhttp.NewBaseHttpClientWithContext(ctx, httpClient, uhttp.WithCacheKeyHeaders(OnBehalfOfHeaderName))

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: The whole point of this change is that parent- and subuser-scoped responses no longer collide in the cache, but nothing pins that. teammates_test.go uses a fake SendGridClient, so it never exercises the real uhttp client. A test that builds a SendGridClient against an httptest server, calls GetTeammates with "" and then with a subuser, and asserts two distinct upstream hits with distinct bodies would catch a future regression (e.g. someone renaming OnBehalfOfHeaderName without updating the WithCacheKeyHeaders call) that otherwise surfaces as silently wrong sync data.

if err != nil {
return nil, err
}
Expand Down Expand Up @@ -140,8 +140,31 @@ func (h *SendGridClient) DeleteTeammate(ctx context.Context, username Username,

// GetSpecificTeammate Retrieve a specific teammate with scopes.
// onBehalfOf, when non-empty, scopes the lookup to a subuser. Pass "" to look up
// the teammate at parent scope.
// the teammate at parent scope. The response is http-cached, so this is for
// read-only callers (sync); reads whose result feeds a write must use
// GetSpecificTeammateNoCache.
func (h *SendGridClient) GetSpecificTeammate(ctx context.Context, username Username, onBehalfOf OnBehalfOf) (*models.TeammateScope, error) {
return h.getSpecificTeammate(ctx, username, onBehalfOf)
}

// GetSpecificTeammateNoCache is GetSpecificTeammate with the http cache
// bypassed, for the read half of the read-modify-write in scope Grant/Revoke.
// SetTeammateScopes replaces a teammate's entire scope list, so that read must
// see the live list: uhttp caches GETs for an hour, never invalidates them on a
// write, and only clears caches at end-of-sync — so a cached read would let
// back-to-back provisioning tasks on the same teammate each build their new
// scope list from a pre-write snapshot, silently dropping whatever the previous
// task granted.
func (h *SendGridClient) GetSpecificTeammateNoCache(ctx context.Context, username Username, onBehalfOf OnBehalfOf) (*models.TeammateScope, error) {
return h.getSpecificTeammate(ctx, username, onBehalfOf, uhttp.WithNoCache())
}
Comment on lines +158 to +160

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: uhttp.WithNoCache() only suppresses the cache readBaseHttpClient.Do still writes every 200-OK GET into the cache unconditionally (vendor/.../uhttp/wrapper.go:592-596 is not gated on Cache-Control), and Cache-Control is not part of CreateCacheKey. So this "no-cache" read stores the pre-write scope list under exactly the key GetSpecificTeammate reads from, and since caches are only cleared at end-of-sync (TTL 1h), a sync running later in the same process (teammates.go:252) emits scope grants missing whatever the grant task just wrote. Simplest fixes: keep WithNoCache() on the specific-teammate endpoint entirely (the PR's goal is caching the subuser calls), or re-issue the no-cache GET after a successful SetTeammateScopes so the write-through refreshes the entry to live state. (medium confidence — depends on provisioning and sync sharing a process)


func (h *SendGridClient) getSpecificTeammate(
ctx context.Context,
username Username,
onBehalfOf OnBehalfOf,
extraOpts ...uhttp.RequestOption,
) (*models.TeammateScope, error) {
uri := h.getUrl(fmt.Sprintf(SpecificTeammateEndpoint, username))
var requestResponse models.TeammateScope

Expand All @@ -151,7 +174,7 @@ func (h *SendGridClient) GetSpecificTeammate(ctx context.Context, username Usern
uri,
&requestResponse,
nil,
onBehalfOfOpts(onBehalfOf)...,
append(onBehalfOfOpts(onBehalfOf), extraOpts...)...,
)
if err != nil {
return nil, err
Expand Down Expand Up @@ -405,14 +428,14 @@ func getTokenValue(pToken *pagination.Token) (int, error) {
return value, nil
}

// onBehalfOfOpts always disables caching: uhttp's cache key ignores the
// on-behalf-of header, so parent- and subuser-scoped calls would otherwise collide.
// onBehalfOfOpts scopes a request to a subuser when onBehalfOf is set. The
// on-behalf-of header is folded into the cache key via WithCacheKeyHeaders on
// the client, so parent- and subuser-scoped responses stay distinct in the cache.
func onBehalfOfOpts(onBehalfOf OnBehalfOf) []uhttp.RequestOption {
opts := []uhttp.RequestOption{uhttp.WithNoCache()}
if onBehalfOf != "" {
opts = append(opts, uhttp.WithHeader(OnBehalfOfHeaderName, string(onBehalfOf)))
if onBehalfOf == "" {
return nil
}
return opts
return []uhttp.RequestOption{uhttp.WithHeader(OnBehalfOfHeaderName, string(onBehalfOf))}
}
Comment on lines +431 to 439

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: Dropping WithNoCache() here also enables caching on GetSpecificTeammate, which scopeBuilder.Grant/Revoke use as the read half of a read-modify-write (helper.go:63scopes.go:81,101). uhttp's cache is per-BaseHttpClient, defaults to in-memory with a 1h TTL, has no write invalidation (Do only Get/Sets on GET), and ClearCaches runs only in Cleanup at end of sync — so in service mode the connector subprocess keeps the entry across provisioning tasks. Grant(B) then Grant(C) on the same teammate reads the pre-grant scopes=[A] from cache and PATCHes [A,C], silently dropping B.

Suggest keeping the request uncached specifically for the provisioning read path — e.g. give GetSpecificTeammate a no-cache variant (or a noCache bool/option param) that Grant/Revoke use, while the sync callers in teammates.go:196,252 keep the cache. The cache-key change itself is correct: NewRequest canonicalizes via req.Header.Set, and CreateCacheKey looks up http.CanonicalHeaderKey("on-behalf-of"), so the keys match.


func (h *SendGridClient) doRequest(
Expand Down
3 changes: 3 additions & 0 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ type SendGridClient interface {
InviteTeammate(ctx context.Context, email string, scopes []string, isAdmin bool) (*models.TeammateInvitation, error)

GetSpecificTeammate(ctx context.Context, username client.Username, onBehalfOf client.OnBehalfOf) (*models.TeammateScope, error)
// GetSpecificTeammateNoCache bypasses the http cache; required for reads
// that feed a write, since SetTeammateScopes replaces the whole scope list.
GetSpecificTeammateNoCache(ctx context.Context, username client.Username, onBehalfOf client.OnBehalfOf) (*models.TeammateScope, error)
GetTeammates(ctx context.Context, pToken *pagination.Token, onBehalfOf client.OnBehalfOf) ([]*models.Teammate, string, error)
DeleteTeammate(ctx context.Context, username client.Username, onBehalfOf client.OnBehalfOf) error
GetTeammatesSubAccess(ctx context.Context, username client.Username, pToken *pagination.Token, onBehalfOf client.OnBehalfOf) ([]*models.TeammateSubuser, string, error)
Expand Down
9 changes: 7 additions & 2 deletions pkg/connector/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,13 @@ func teammateOnBehalfOf(ctx context.Context, client SendGridClient, resource *v2
// time — not worth caching. Returns the teammate and the on-behalf-of value
// that worked, so callers can reuse it for follow-up calls (e.g.
// SetTeammateScopes).
//
// The teammate read itself deliberately bypasses the http cache: every caller
// is a provisioning path that turns the returned Scopes into a full-list
// SetTeammateScopes write, and a cached read would make consecutive tasks on
// the same teammate overwrite each other's scopes.
func getTeammateWithFreshOnBehalfOf(ctx context.Context, client SendGridClient, principal *v2.Resource, username, onBehalfOf string) (*models.TeammateScope, string, error) {
teammate, err := client.GetSpecificTeammate(ctx, sgclient.Username(username), sgclient.OnBehalfOf(onBehalfOf))
teammate, err := client.GetSpecificTeammateNoCache(ctx, sgclient.Username(username), sgclient.OnBehalfOf(onBehalfOf))
if err == nil || onBehalfOf == "" || status.Code(err) != codes.NotFound {
return teammate, onBehalfOf, err
}
Expand All @@ -72,7 +77,7 @@ func getTeammateWithFreshOnBehalfOf(ctx context.Context, client SendGridClient,
return nil, onBehalfOf, err
}

teammate, err = client.GetSpecificTeammate(ctx, sgclient.Username(username), sgclient.OnBehalfOf(freshOnBehalfOf))
teammate, err = client.GetSpecificTeammateNoCache(ctx, sgclient.Username(username), sgclient.OnBehalfOf(freshOnBehalfOf))

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 rename-recovery retry now re-resolves through a cached path. resolveOnBehalfOfByParentIDGetSubuserUsernameByIDGetSubusers, and GetSubusers is a plain GET that this PR made cacheable, so if the subuser list was already fetched in this process within the 1h TTL the "fresh" lookup returns the same stale username that just 404'd and the retry is a no-op. teammateBuilder.Delete (teammates.go:285) has the same exposure, and there a stale on-behalf-of produces a 404 that is swallowed as "already deleted" (teammates.go:292-295), i.e. a silently no-op delete. Consider a no-cache variant of the subuser lookup for these provisioning paths. The comments at helper.go:57-58 and :91-92 still describe these as "plain, uncached" calls and should be updated either way.

return teammate, freshOnBehalfOf, err
}

Expand Down
139 changes: 139 additions & 0 deletions pkg/connector/scopes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package connector

import (
"context"
"slices"
"testing"

sgclient "github.com/conductorone/baton-sendgrid/pkg/connector/client"
"github.com/conductorone/baton-sendgrid/pkg/connector/models"

v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/stretchr/testify/require"
)

// scopeStateFake models a teammate's scopes in SendGrid together with the http
// cache sitting in front of the read: GetSpecificTeammate replays the first
// response it ever produced (uhttp caches GETs for an hour and never
// invalidates them on a write), while GetSpecificTeammateNoCache always
// reports live state. SetTeammateScopes replaces the entire list, as the real
// PATCH does — so a provisioning read served from cache silently drops
// whatever the previous task wrote.
type scopeStateFake struct {
fakeSendGridClient

live []string
cached []string
cachedFilled bool
// cachedReads counts reads that went through the cacheable variant, which
// no provisioning path may use.
cachedReads int
}

func (f *scopeStateFake) GetSpecificTeammate(_ context.Context, username sgclient.Username, _ sgclient.OnBehalfOf) (*models.TeammateScope, error) {
f.cachedReads++
if !f.cachedFilled {
f.cached = slices.Clone(f.live)
f.cachedFilled = true
}

return &models.TeammateScope{
Teammate: models.Teammate{Username: string(username)},
Scopes: slices.Clone(f.cached),
}, nil
}

func (f *scopeStateFake) GetSpecificTeammateNoCache(_ context.Context, username sgclient.Username, _ sgclient.OnBehalfOf) (*models.TeammateScope, error) {
return &models.TeammateScope{
Teammate: models.Teammate{Username: string(username)},
Scopes: slices.Clone(f.live),
}, nil
}

func (f *scopeStateFake) SetTeammateScopes(_ context.Context, _ sgclient.Username, scopes []string, _ bool, _ sgclient.OnBehalfOf) error {
f.live = slices.Clone(scopes)
return nil
}

func scopeEntitlementFor(t *testing.T, scope string) *v2.Entitlement {
t.Helper()

scopeRs, err := scopeResource(Scope(scope))
require.NoError(t, err)

return &v2.Entitlement{Resource: scopeRs, Slug: assignedEntitlement}
}

func teammatePrincipal(t *testing.T) *v2.Resource {
t.Helper()

principal, err := teammateResource(
&models.Teammate{Username: "alice@example.com", Email: "alice@example.com"},
nil,
"",
)
require.NoError(t, err)

return principal
}

// Two grants for the same teammate arriving back-to-back: the second must read
// the scope list the first one wrote, not a pre-write snapshot, or it hands
// SetTeammateScopes a full list that's missing the first grant.
func TestScopeBuilder_Grant_ConsecutiveGrantsDoNotOverwriteEachOther(t *testing.T) {
ctx := context.Background()
client := &scopeStateFake{live: []string{"alerts.read", "mail.send"}}
principal := teammatePrincipal(t)
sb := newScopeBuilder(client)

_, _, err := sb.Grant(ctx, principal, scopeEntitlementFor(t, "api_keys.read"))
require.NoError(t, err)

_, _, err = sb.Grant(ctx, principal, scopeEntitlementFor(t, "billing.read"))
require.NoError(t, err)

require.ElementsMatch(t, []string{"alerts.read", "mail.send", "api_keys.read", "billing.read"}, client.live)
require.Zero(t, client.cachedReads, "the read feeding SetTeammateScopes must bypass the http cache")
}

// Revoke reads the same way: it removes one scope from the live list, so a
// cached read would resurrect scopes revoked by an earlier task and drop ones
// granted by it.
func TestScopeBuilder_Revoke_ReadsLiveScopes(t *testing.T) {
ctx := context.Background()
client := &scopeStateFake{live: []string{"alerts.read", "mail.send"}}
principal := teammatePrincipal(t)
sb := newScopeBuilder(client)

_, _, err := sb.Grant(ctx, principal, scopeEntitlementFor(t, "api_keys.read"))
require.NoError(t, err)

_, err = sb.Revoke(ctx, &v2.Grant{
Principal: principal,
Entitlement: scopeEntitlementFor(t, "mail.send"),
})
require.NoError(t, err)

require.ElementsMatch(t, []string{"alerts.read", "api_keys.read"}, client.live)
require.Zero(t, client.cachedReads, "the read feeding SetTeammateScopes must bypass the http cache")
}

// The idempotency short-circuits decide off the same read, so they must also
// see live state: a scope granted by a previous task must be reported as
// already granted rather than re-written.
func TestScopeBuilder_Grant_AlreadyGrantedUsesLiveScopes(t *testing.T) {
ctx := context.Background()
client := &scopeStateFake{live: []string{"alerts.read"}}
principal := teammatePrincipal(t)
sb := newScopeBuilder(client)

_, _, err := sb.Grant(ctx, principal, scopeEntitlementFor(t, "api_keys.read"))
require.NoError(t, err)

grants, annos, err := sb.Grant(ctx, principal, scopeEntitlementFor(t, "api_keys.read"))
require.NoError(t, err)
require.Empty(t, grants)
require.True(t, annos.Contains(&v2.GrantAlreadyExists{}))
require.ElementsMatch(t, []string{"alerts.read", "api_keys.read"}, client.live)
require.Zero(t, client.cachedReads, "the read feeding SetTeammateScopes must bypass the http cache")
}
Loading