From e3f8b97832510f6ac90948b14b2472ac3cbac0b8 Mon Sep 17 00:00:00 2001 From: Lauren Leach Date: Fri, 28 Aug 2026 18:33:03 -0700 Subject: [PATCH] Guard role grant emission behind WillSyncResourceType Gates cross-type role-grant emission from the user syncer on the customer's sync filter, so grants aren't emitted for a resource type the sync excludes. The user builder clones the user resource type and annotates it SkipEntitlements, or SkipEntitlementsAndGrants when role is filtered out -- users have no entitlements of their own and their only grants are the cross-type role grants, so the whole pass can be skipped. The flag is named skipRoleResourceType and stored inverted so the zero value means "sync everything". Co-Authored-By: Claude Opus 5 (1M context) --- baton_capabilities.json | 5 +++ pkg/connector/connector.go | 21 +++++++++-- pkg/connector/resource_types.go | 12 ++++++- pkg/connector/users.go | 17 +++++++-- pkg/connector/users_guard_test.go | 59 +++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 pkg/connector/users_guard_test.go diff --git a/baton_capabilities.json b/baton_capabilities.json index e82d1edf..856c5322 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -53,6 +53,11 @@ "traits": [ "TRAIT_USER" ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements" + } + ], "description": "JumpCloud User: The User account is the core identity for your employees, and is the account type that is used to authenticate against resources" }, "capabilities": [ diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index a415021f..98c21781 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -18,6 +18,10 @@ import ( type Connector struct { client *client.Client + // skipRoleResourceType reports whether role is excluded from the sync filter. + // Named for the skip condition so the zero value is safe: a Connector built + // without the option syncs everything. + skipRoleResourceType bool } // Option is a function that configures a Connector. @@ -37,6 +41,16 @@ func WithAPIKey(ctx context.Context, apiKey string, orgId string, baseURL string } } +// WithSkipRoleResourceType configures whether the role resource type is excluded from +// the sync filter. The user builder emits cross-type role grants, so when role is +// excluded those grants must be suppressed rather than left pointing at an unsynced type. +func WithSkipRoleResourceType(skip bool) Option { + return func(c *Connector) error { + c.skipRoleResourceType = skip + return nil + } +} + func NewLambdaConnector(ctx context.Context, jumpcloudCfg *cfg.Jumpcloud, cliOpts *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) { l := ctxzap.Extract(ctx) @@ -47,7 +61,10 @@ func NewLambdaConnector(ctx context.Context, jumpcloudCfg *cfg.Jumpcloud, cliOpt jumpcloudCfg.BaseUrl, ) - cb, err := New(ctx, opts) + // nil opts means no filter, so nothing is skipped. + skipRoleResourceType := cliOpts != nil && !cliOpts.WillSyncResourceType(RoleResourceTypeID) + + cb, err := New(ctx, opts, WithSkipRoleResourceType(skipRoleResourceType)) if err != nil { l.Error("error creating connector", zap.Error(err)) return nil, nil, err @@ -79,7 +96,7 @@ func New(ctx context.Context, opts ...Option) (*Connector, error) { // ResourceSyncers returns a ResourceSyncer for each resource type that should be synced from the upstream service. func (c *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 { return []connectorbuilder.ResourceSyncerV2{ - newUserBuilder(c.client), + newUserBuilder(c.client, c.skipRoleResourceType), newGroupBuilder(c.client), newRoleBuilder(), newAppBuilder(c.client), diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 0b4e4ec1..8f428555 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -5,7 +5,14 @@ import ( "github.com/conductorone/baton-sdk/pkg/annotations" ) +// RoleResourceTypeID must equal resourceTypeRole.Id below. Exported so the connector +// can gate cross-type role-grant emission on WillSyncResourceType without duplicating +// the string literal. +const RoleResourceTypeID = "role" + var ( + // newUserBuilder clones this and adds SkipEntitlements, or + // SkipEntitlementsAndGrants when role isn't synced. resourceTypeUser = &v2.ResourceType{ Id: "user", DisplayName: "User", @@ -22,8 +29,11 @@ var ( DisplayName: "App", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_APP}, } + // resourceTypeRole skips Grants(): roleBuilder.Grants() is a no-op because the role + // grants are emitted from the user builder, which reads each admin user's role in + // the pass it already makes rather than re-scanning every user once per role. resourceTypeRole = &v2.ResourceType{ - Id: "role", + Id: RoleResourceTypeID, DisplayName: "Role", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}, Annotations: annotations.New(&v2.SkipGrants{}), diff --git a/pkg/connector/users.go b/pkg/connector/users.go index e5b70f3d..ecef1f4e 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -14,6 +14,7 @@ import ( "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" ) @@ -28,9 +29,21 @@ func (o *userResourceType) ResourceType(_ context.Context) *v2.ResourceType { return o.resourceType } -func newUserBuilder(client *client.Client) *userResourceType { +// newUserBuilder returns the user syncer. Users have no entitlements of their own, +// and their only grants are cross-type role grants, so when role is excluded from +// the sync the grants pass is skipped too. +func newUserBuilder(client *client.Client, skipRoleResourceType bool) *userResourceType { + rt := proto.Clone(resourceTypeUser).(*v2.ResourceType) + annos := annotations.Annotations(rt.GetAnnotations()) + if skipRoleResourceType { + annos.Update(&v2.SkipEntitlementsAndGrants{}) + } else { + annos.Update(&v2.SkipEntitlements{}) + } + rt.Annotations = annos + return &userResourceType{ - resourceType: resourceTypeUser, + resourceType: rt, client: client, managers: make(map[string]*jcapi1.Systemuserreturn), usersCache: newUsersCache(client), diff --git a/pkg/connector/users_guard_test.go b/pkg/connector/users_guard_test.go new file mode 100644 index 00000000..d8788044 --- /dev/null +++ b/pkg/connector/users_guard_test.go @@ -0,0 +1,59 @@ +package connector + +import ( + "context" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "google.golang.org/protobuf/proto" +) + +func hasGuardAnno(rt *v2.ResourceType, msg proto.Message) bool { + for _, a := range rt.GetAnnotations() { + if a.MessageIs(msg) { + return true + } + } + return false +} + +// The user type's only grants are cross-type role grants, so when role is +// excluded the whole grants pass is skipped. +func TestUserResourceType_SkipAnnotation(t *testing.T) { + inScope := newUserBuilder(nil, false).ResourceType(context.Background()) + if !hasGuardAnno(inScope, &v2.SkipEntitlements{}) || hasGuardAnno(inScope, &v2.SkipEntitlementsAndGrants{}) { + t.Fatalf("role in scope: want SkipEntitlements only, got %v", inScope.GetAnnotations()) + } + + filtered := newUserBuilder(nil, true).ResourceType(context.Background()) + if !hasGuardAnno(filtered, &v2.SkipEntitlementsAndGrants{}) { + t.Fatalf("role filtered: want SkipEntitlementsAndGrants, got %v", filtered.GetAnnotations()) + } + + if hasGuardAnno(resourceTypeUser, &v2.SkipEntitlementsAndGrants{}) { + t.Fatal("package-level resourceTypeUser was mutated") + } +} + +// skipRoleResourceType is stored inverted so the zero value means "sync +// everything": a Connector built without WithSkipRoleResourceType must report +// the unfiltered capability set. +func TestZeroValueConnector_DoesNotSkipGrants(t *testing.T) { + for _, s := range (&Connector{}).ResourceSyncers(context.Background()) { + rt := s.ResourceType(context.Background()) + if rt.GetId() != resourceTypeUser.GetId() { + continue + } + if hasGuardAnno(rt, &v2.SkipEntitlementsAndGrants{}) { + t.Fatal("zero-value Connector advertised SkipEntitlementsAndGrants") + } + } +} + +// RoleResourceTypeID is what the sync filter is queried with; it must match the +// resource type actually advertised. +func TestRoleResourceTypeIDMatches(t *testing.T) { + if resourceTypeRole.GetId() != RoleResourceTypeID { + t.Fatalf("RoleResourceTypeID = %q, resourceTypeRole.Id = %q", RoleResourceTypeID, resourceTypeRole.GetId()) + } +}