Skip to content
Open
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
22 changes: 19 additions & 3 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import (
)

type Connector struct {
client *client.Client
client *client.Client
syncRoles bool
}

// Option is a function that configures a Connector.
Expand All @@ -37,17 +38,32 @@ func WithAPIKey(ctx context.Context, apiKey string, orgId string, baseURL string
}
}

// WithSyncRoles configures whether the connector should emit role grants
// discovered as a side effect of syncing users. This should reflect whether
// the role resource type is actually included in the sync filter.
func WithSyncRoles(syncRoles bool) Option {
return func(c *Connector) error {
c.syncRoles = syncRoles
return nil
}
}

func NewLambdaConnector(ctx context.Context, jumpcloudCfg *cfg.Jumpcloud, cliOpts *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) {
l := ctxzap.Extract(ctx)

syncRoles := true
if cliOpts != nil {
syncRoles = cliOpts.WillSyncResourceType(RoleResourceTypeID)
}

opts := WithAPIKey(
ctx,
jumpcloudCfg.ApiKey,
jumpcloudCfg.OrgId,
jumpcloudCfg.BaseUrl,
)

cb, err := New(ctx, opts)
cb, err := New(ctx, opts, WithSyncRoles(syncRoles))
if err != nil {
l.Error("error creating connector", zap.Error(err))
return nil, nil, err
Expand Down Expand Up @@ -79,7 +95,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.syncRoles),
newGroupBuilder(c.client),
newRoleBuilder(),
newAppBuilder(c.client),
Expand Down
8 changes: 7 additions & 1 deletion pkg/connector/resource_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import (
"github.com/conductorone/baton-sdk/pkg/annotations"
)

// RoleResourceTypeID is the resource type ID for roles, exported so callers
// (e.g. cmd/main.go) can gate cross-type grant emission on
// cli.ConnectorOpts.WillSyncResourceType(RoleResourceTypeID) without
// duplicating the string literal.
const RoleResourceTypeID = "role"

var (
resourceTypeUser = &v2.ResourceType{
Id: "user",
Expand All @@ -23,7 +29,7 @@ var (
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_APP},
}
resourceTypeRole = &v2.ResourceType{
Id: "role",
Id: RoleResourceTypeID,
DisplayName: "Role",
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE},
Annotations: annotations.New(&v2.SkipGrants{}),
Expand Down
20 changes: 18 additions & 2 deletions pkg/connector/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"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"
)

Expand All @@ -28,9 +29,19 @@
return o.resourceType
}

func newUserBuilder(client *client.Client) *userResourceType {
func newUserBuilder(client *client.Client, syncRoles bool) *userResourceType {
resourceType := resourceTypeUser
if !syncRoles {
// The user builder has no entitlements or grants of its own -- its only
// Grants() output is the cross-type role grant gated below. When roles
// aren't being synced, skip entitlement/grant discovery for users entirely.
Comment on lines +35 to +37

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 is now stale — it says the role grant is "gated below", but this commit removed the if !o.syncRoles gate from Grants(). Reword to say the gating is done entirely by this annotation (or restore the guard and keep the wording).

rt := proto.Clone(resourceTypeUser).(*v2.ResourceType)
rt.Annotations = annotations.New(&v2.SkipEntitlementsAndGrants{})
resourceType = rt
}

return &userResourceType{
resourceType: resourceTypeUser,
resourceType: resourceType,
client: client,
managers: make(map[string]*jcapi1.Systemuserreturn),
usersCache: newUsersCache(client),
Expand All @@ -41,6 +52,11 @@
return nil, nil, nil
}

// Grants emits the cross-type role grant. There is no syncRoles guard here:
// when roles aren't being synced, newUserBuilder annotates the user resource
// type SkipEntitlementsAndGrants and the SDK never calls Grants() at all
// (shouldSkipGrants -> shouldSkipEntitlementsAndGrants in the SDK's
// pkg/sync/syncer.go), so a guard would be unreachable.
Comment on lines +55 to +59

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 claim holds for the syncer path — I verified shouldSkipGrantsshouldSkipEntitlementsAndGrants in baton-sdk v0.20.2 (pkg/sync/syncer.go:1263 and :2093) does gate on the resource type annotation — but it does not hold for the ListGrants gRPC entrypoint: pkg/connectorbuilder/resource_syncer.go:303 looks up the syncer by resource type and calls rb.Grants(...) unconditionally, with no annotation check. Any direct ListGrants call for a user resource will now hit GetUserByID and emit a grant referencing the unsynced role type. Keeping the cheap syncRoles guard was defense-in-depth for exactly that; consider restoring it rather than coupling correctness to SDK-internal syncer behavior.

func (o *userResourceType) Grants(ctx context.Context, resource *v2.Resource, _ sdkResources.SyncOpAttrs) ([]*v2.Grant, *sdkResources.SyncOpResults, error) {
userID := resource.Id.Resource
// Only admin users have role grants. System users won't be found in the admin users endpoint.
Expand Down Expand Up @@ -177,14 +193,14 @@
}

userTraitOps := []sdkResources.UserTraitOption{
sdkResources.WithUserProfile(profile),

Check failure on line 196 in pkg/connector/users.go

View workflow job for this annotation

GitHub Actions / verify / lint

SA1019: sdkResources.WithUserProfile is deprecated: profile has moved from UserTrait to an attribute on Resource. This option still works — it also populates the resource-level profile when used with WithUserTrait or NewUserResource — but new code should use WithResourceProfile instead. (staticcheck)
}

status := v2.UserTrait_Status_STATUS_ENABLED
if user.GetSuspended() {
status = v2.UserTrait_Status_STATUS_DISABLED
}
userTraitOps = append(userTraitOps, sdkResources.WithStatus(status))

Check failure on line 203 in pkg/connector/users.go

View workflow job for this annotation

GitHub Actions / verify / lint

SA1019: sdkResources.WithStatus is deprecated: status has moved from UserTrait to an attribute on Resource. This option still works — it also populates the resource-level status when used with WithUserTrait or NewUserResource — but new code should use WithResourceStatus instead. (staticcheck)

email := user.GetEmail()
if email != "" {
Expand Down Expand Up @@ -275,22 +291,22 @@

switch st := user.GetState(); st {
case "", "ACTIVATED":
ret.Status.Status = v2.UserTrait_Status_STATUS_ENABLED

Check failure on line 294 in pkg/connector/users.go

View workflow job for this annotation

GitHub Actions / verify / lint

SA1019: ret.Status is deprecated: Marked as deprecated in c1/connector/v2/annotation_trait.proto. (staticcheck)
case "STAGED":
ret.Status.Status = v2.UserTrait_Status_STATUS_DISABLED

Check failure on line 296 in pkg/connector/users.go

View workflow job for this annotation

GitHub Actions / verify / lint

SA1019: ret.Status is deprecated: Marked as deprecated in c1/connector/v2/annotation_trait.proto. (staticcheck)
ret.Status.Details = strings.ToLower(st)

Check failure on line 297 in pkg/connector/users.go

View workflow job for this annotation

GitHub Actions / verify / lint

SA1019: ret.Status is deprecated: Marked as deprecated in c1/connector/v2/annotation_trait.proto. (staticcheck)
case "SUSPENDED":
ret.Status.Status = v2.UserTrait_Status_STATUS_DISABLED

Check failure on line 299 in pkg/connector/users.go

View workflow job for this annotation

GitHub Actions / verify / lint

SA1019: ret.Status is deprecated: Marked as deprecated in c1/connector/v2/annotation_trait.proto. (staticcheck)
ret.Status.Details = strings.ToLower(st)

Check failure on line 300 in pkg/connector/users.go

View workflow job for this annotation

GitHub Actions / verify / lint

SA1019: ret.Status is deprecated: Marked as deprecated in c1/connector/v2/annotation_trait.proto. (staticcheck)
}

if user.GetAccountLocked() {
ret.Status.Status = v2.UserTrait_Status_STATUS_DISABLED

Check failure on line 304 in pkg/connector/users.go

View workflow job for this annotation

GitHub Actions / verify / lint

SA1019: ret.Status is deprecated: Marked as deprecated in c1/connector/v2/annotation_trait.proto. (staticcheck)
ret.Status.Details = "locked"

Check failure on line 305 in pkg/connector/users.go

View workflow job for this annotation

GitHub Actions / verify / lint

SA1019: ret.Status is deprecated: Marked as deprecated in c1/connector/v2/annotation_trait.proto. (staticcheck)
}

if user.GetSuspended() {
ret.Status.Status = v2.UserTrait_Status_STATUS_DISABLED

Check failure on line 309 in pkg/connector/users.go

View workflow job for this annotation

GitHub Actions / verify / lint

SA1019: ret.Status is deprecated: Marked as deprecated in c1/connector/v2/annotation_trait.proto. (staticcheck)
ret.Status.Details = "suspended"
}

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

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/conductorone/baton-jumpcloud/pkg/client"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/annotations"
sdkResources "github.com/conductorone/baton-sdk/pkg/types/resource"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
)

// TestUserGrants_RoleSyncFilter covers ConductorOne/baton-linear#55: the user
// builder emits role grants as a sync optimization, but must not do so when
// the customer's sync filter excludes the role resource type.
func TestUserGrants_RoleSyncFilter(t *testing.T) {
Comment on lines +19 to +22

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: Two staleness nits after the guard removal. The doc comment references ConductorOne/baton-linear#55, which is a different connector's issue — it should point at the jumpcloud issue (or drop the reference). And TestUserGrants_RoleSyncFilter now contains only the positive case, so its name and "must not do so when the customer's sync filter excludes the role resource type" no longer describe what it asserts; the filter behavior lives in TestNewUserBuilder_ResourceTypeAnnotations. Renaming to something like TestUserGrants_RoleGrantEmitted would keep the intent clear.

userResource := &v2.Resource{
Id: fmtResourceId(resourceTypeUser.Id, "user-1"),
}

// Grants() itself is unconditional: when roles are filtered out the SDK is
// stopped by the SkipEntitlementsAndGrants annotation before it ever calls
// Grants, which TestNewUserBuilder_ResourceTypeAnnotations pins.
t.Run("role type synced -> role grant emitted", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"id": "user-1",
"roleName": "Administrator",
})
}))
defer srv.Close()

c, err := client.NewClient(context.Background(), "api-key", "", srv.URL)
require.NoError(t, err)

builder := newUserBuilder(c, true)

grants, _, err := builder.Grants(context.Background(), userResource, sdkResources.SyncOpAttrs{})
require.NoError(t, err)
require.Len(t, grants, 1)
require.Equal(t, fmtRoleNameAsID("Administrator"), grants[0].Entitlement.Resource.Id.Resource)
require.Equal(t, resourceTypeRole.Id, grants[0].Entitlement.Resource.Id.ResourceType)
})
}

// TestNewUserBuilder_ResourceTypeAnnotations covers Step 4 of the
// WillSyncResourceType gating pattern: when the user builder has no
// entitlements/grants of its own to offer, mark the emitted resource type
// with SkipEntitlementsAndGrants so the SDK doesn't bother syncing them.
func TestNewUserBuilder_ResourceTypeAnnotations(t *testing.T) {
t.Run("role type filtered out -> SkipEntitlementsAndGrants set", func(t *testing.T) {
builder := newUserBuilder(nil, false)
var skip v2.SkipEntitlementsAndGrants
ok, err := annotationsContain(builder.resourceType.GetAnnotations(), &skip)
require.NoError(t, err)
require.True(t, ok)
})

t.Run("role type synced -> no SkipEntitlementsAndGrants", func(t *testing.T) {
builder := newUserBuilder(nil, true)
var skip v2.SkipEntitlementsAndGrants
ok, err := annotationsContain(builder.resourceType.GetAnnotations(), &skip)
require.NoError(t, err)
require.False(t, ok)
})
}

func annotationsContain(annos []*anypb.Any, msg proto.Message) (bool, error) {
as := annotations.Annotations(annos)
return as.Pick(msg)
}
Loading