From 7c6da5b195aef5ce7168ac07fea968415fb3618f Mon Sep 17 00:00:00 2001 From: Geoff Greer Date: Mon, 25 Aug 2025 12:40:55 -0700 Subject: [PATCH 1/8] Expand groups for app assignments. --- pkg/connector/app.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/connector/app.go b/pkg/connector/app.go index 2ad50f20..be9eddc2 100644 --- a/pkg/connector/app.go +++ b/pkg/connector/app.go @@ -190,6 +190,10 @@ func (o *appResourceType) listAppGroupGrants( Id: fmtGrantIdV1(V1MembershipEntitlementID(resource.Id.Resource), groupID), }, ), + sdkGrant.WithAnnotation(&v2.GrantExpandable{ + EntitlementIds: []string{fmt.Sprintf("group:%s:member", groupID)}, + Shallow: true, + }), )) } From 3dba989c3df4d90058d6186564abdd4d11a804dd Mon Sep 17 00:00:00 2001 From: Geoff Greer Date: Mon, 25 Aug 2025 12:42:16 -0700 Subject: [PATCH 2/8] Add option to use app links API instead of listing app users. --- cmd/baton-okta/config.go | 2 ++ cmd/baton-okta/main.go | 5 ++-- pkg/connector/app.go | 41 ++++++++++++++------------- pkg/connector/connector.go | 51 ++++++++++++++++++++-------------- pkg/connector/user.go | 57 +++++++++++++++++++++++++++----------- 5 files changed, 97 insertions(+), 59 deletions(-) diff --git a/cmd/baton-okta/config.go b/cmd/baton-okta/config.go index 304fa739..02b0deb3 100644 --- a/cmd/baton-okta/config.go +++ b/cmd/baton-okta/config.go @@ -32,6 +32,7 @@ var ( "filter-email-domains", field.WithDescription("Only sync users with primary email addresses that match at least one of the provided domains. When unset or empty, all users will be synced."), ) + useAppLinksForUserGrants = field.BoolField("use-app-links-for-user-grants", field.WithDescription("Whether to use app links for user grants or not"), field.WithDefaultValue(false)) ) var relationships = []field.SchemaFieldRelationship{ @@ -65,4 +66,5 @@ var configuration = field.NewConfiguration([]field.SchemaField{ awsSourceIdentityMode, awsAllowGroupToDirectAssignmentConversionForProvisioning, filterEmailDomains, + useAppLinksForUserGrants, }, field.WithConstraints(relationships...)) diff --git a/cmd/baton-okta/main.go b/cmd/baton-okta/main.go index e345f453..b3e99a8f 100644 --- a/cmd/baton-okta/main.go +++ b/cmd/baton-okta/main.go @@ -54,8 +54,9 @@ func getConnector(ctx context.Context, v *viper.Viper) (types.ConnectorServer, e AWSOktaAppId: v.GetString("aws-okta-app-id"), AWSSourceIdentityMode: v.GetBool("aws-source-identity-mode"), AllowGroupToDirectAssignmentConversionForProvisioning: v.GetBool("aws-allow-group-to-direct-assignment-conversion-for-provisioning"), - SyncSecrets: v.GetBool("sync-secrets"), - FilterEmailDomains: v.GetStringSlice("filter-email-domains"), + SyncSecrets: v.GetBool("sync-secrets"), + FilterEmailDomains: v.GetStringSlice("filter-email-domains"), + UseAppLinksForUserGrants: v.GetBool("use-app-links-for-user-grants"), } cb, err := connector.New(ctx, ccfg) diff --git a/pkg/connector/app.go b/pkg/connector/app.go index be9eddc2..e9741ac5 100644 --- a/pkg/connector/app.go +++ b/pkg/connector/app.go @@ -21,12 +21,13 @@ import ( ) type appResourceType struct { - resourceType *v2.ResourceType - domain string - apiToken string - syncInactiveApps bool - userEmailFilters []string - client *okta.Client + resourceType *v2.ResourceType + domain string + apiToken string + syncInactiveApps bool + userEmailFilters []string + client *okta.Client + useAppLinksForUserGrants bool } const ( @@ -34,23 +35,19 @@ const ( appGrantUser = "user" ) -var appGrantTypes = []string{ - appGrantGroup, - appGrantUser, -} - func (o *appResourceType) ResourceType(_ context.Context) *v2.ResourceType { return o.resourceType } -func appBuilder(domain string, apiToken string, syncInactiveApps bool, filterEmailDomains []string, client *okta.Client) *appResourceType { +func appBuilder(domain string, apiToken string, syncInactiveApps bool, filterEmailDomains []string, useAppLinksForUserGrants bool, client *okta.Client) *appResourceType { return &appResourceType{ - resourceType: resourceTypeApp, - domain: domain, - apiToken: apiToken, - client: client, - syncInactiveApps: syncInactiveApps, - userEmailFilters: filterEmailDomains, + resourceType: resourceTypeApp, + domain: domain, + apiToken: apiToken, + client: client, + syncInactiveApps: syncInactiveApps, + userEmailFilters: filterEmailDomains, + useAppLinksForUserGrants: useAppLinksForUserGrants, } } @@ -129,10 +126,14 @@ func (o *appResourceType) Grants( switch bag.ResourceID() { case "": bag.Pop() - for _, appGrantType := range appGrantTypes { + bag.Push(pagination.PageState{ + ResourceTypeID: resourceTypeApp.Id, + ResourceID: appGrantGroup, + }) + if !o.useAppLinksForUserGrants { bag.Push(pagination.PageState{ ResourceTypeID: resourceTypeApp.Id, - ResourceID: appGrantType, + ResourceID: appGrantUser, }) } case appGrantGroup: diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 2d59798a..adfa8796 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -24,18 +24,19 @@ const AccessDeniedErrorCode = "E0000006" const ExpectedGroupNameCaptureGroupsWithGroupFilterForMultipleAWSInstances = 3 type Okta struct { - client *okta.Client - clientV5 *oktav5.APIClient - domain string - apiToken string - syncInactiveApps bool - ciamConfig *ciamConfig - syncCustomRoles bool - skipSecondaryEmails bool - awsConfig *awsConfig - SyncSecrets bool - userRoleCache sync.Map - userFilters *userFilterConfig + client *okta.Client + clientV5 *oktav5.APIClient + domain string + apiToken string + syncInactiveApps bool + ciamConfig *ciamConfig + syncCustomRoles bool + skipSecondaryEmails bool + awsConfig *awsConfig + SyncSecrets bool + userRoleCache sync.Map + userFilters *userFilterConfig + useAppLinksForUserGrants bool } type ciamConfig struct { @@ -110,6 +111,7 @@ type Config struct { AllowGroupToDirectAssignmentConversionForProvisioning bool SyncSecrets bool FilterEmailDomains []string + UseAppLinksForUserGrants bool } func v1AnnotationsForResourceType(resourceTypeID string, skipEntitlementsAndGrants bool) annotations.Annotations { @@ -214,7 +216,7 @@ func (o *Okta) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceS roleBuilder(o.client, o), userBuilder(o), groupBuilder(o), - appBuilder(o.domain, o.apiToken, o.syncInactiveApps, o.userFilters.includedEmailDomains, o.client), + appBuilder(o.domain, o.apiToken, o.syncInactiveApps, o.userFilters.includedEmailDomains, o.useAppLinksForUserGrants, o.client), } if o.syncCustomRoles { @@ -369,6 +371,12 @@ func New(ctx context.Context, cfg *Config) (*Okta, error) { oktaClient *okta.Client scopes = defaultScopes ) + + if cfg.UseAppLinksForUserGrants { + // We need to fetch app links for users, so don't skip grants for user resource type. + resourceTypeUser.Annotations = v1AnnotationsForResourceType("user", false) + } + client, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, nil)) if err != nil { return nil, err @@ -454,14 +462,15 @@ func New(ctx context.Context, cfg *Config) (*Okta, error) { } return &Okta{ - client: oktaClient, - clientV5: oktaClientV5, - domain: cfg.Domain, - apiToken: cfg.ApiToken, - syncInactiveApps: cfg.SyncInactiveApps, - syncCustomRoles: cfg.SyncCustomRoles, - skipSecondaryEmails: cfg.SkipSecondaryEmails, - SyncSecrets: cfg.SyncSecrets, + client: oktaClient, + clientV5: oktaClientV5, + domain: cfg.Domain, + apiToken: cfg.ApiToken, + syncInactiveApps: cfg.SyncInactiveApps, + useAppLinksForUserGrants: cfg.UseAppLinksForUserGrants, + syncCustomRoles: cfg.SyncCustomRoles, + skipSecondaryEmails: cfg.SkipSecondaryEmails, + SyncSecrets: cfg.SyncSecrets, ciamConfig: &ciamConfig{ Enabled: cfg.Ciam, EmailDomains: cfg.CiamEmailDomains, diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 2d9ce08e..b8b7f835 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -17,7 +17,8 @@ import ( "github.com/conductorone/baton-sdk/pkg/crypto" "github.com/conductorone/baton-sdk/pkg/pagination" "github.com/conductorone/baton-sdk/pkg/ratelimit" - "github.com/conductorone/baton-sdk/pkg/types/resource" + sdkGrant "github.com/conductorone/baton-sdk/pkg/types/grant" + sdkResource "github.com/conductorone/baton-sdk/pkg/types/resource" mapset "github.com/deckarep/golang-set/v2" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "github.com/okta/okta-sdk-golang/v2/okta" @@ -289,7 +290,31 @@ func (o *userResourceType) Grants( resource *v2.Resource, token *pagination.Token, ) ([]*v2.Grant, string, annotations.Annotations, error) { - return nil, "", nil, nil + appLinks, resp, err := o.connector.client.User.ListAppLinks(ctx, resource.Id.Resource) + if err != nil { + return nil, "", nil, fmt.Errorf("okta-connectorv2: failed to fetch app links from okta: %w", handleOktaResponseError(resp, err)) + } + rv := make([]*v2.Grant, 0) + for _, appLink := range appLinks { + var appTraitOpts []sdkResource.AppTraitOption + appResource, err := sdkResource.NewAppResource(appLink.Label, resourceTypeApp, appLink.AppInstanceId, appTraitOpts, + sdkResource.WithAnnotation(&v2.V1Identifier{Id: fmtResourceIdV1(appLink.AppInstanceId)}), + sdkResource.WithAnnotation(&v2.RawId{Id: appLink.AppInstanceId}), + ) + if err != nil { + return nil, "", nil, err + } + + rv = append(rv, sdkGrant.NewGrant(appResource, "access", resource, + sdkGrant.WithAnnotation( + &v2.V1Identifier{ + Id: fmtGrantIdV1(V1MembershipEntitlementID(appResource.Id.Resource), resource.Id.Resource), + }, + ), + )) + } + + return rv, "", nil, nil } func userName(user *okta.User) (string, string) { @@ -376,8 +401,8 @@ func userResource(ctx context.Context, user *okta.User, skipSecondaryEmails bool oktaProfile := *user.Profile oktaProfile["c1_okta_raw_user_status"] = user.Status - options := []resource.UserTraitOption{ - resource.WithUserProfile(oktaProfile), + options := []sdkResource.UserTraitOption{ + sdkResource.WithUserProfile(oktaProfile), // TODO?: use the user types API to figure out the account type // https://developer.okta.com/docs/reference/api/user-types/ // resource.WithAccountType(v2.UserTrait_ACCOUNT_TYPE_UNSPECIFIED), @@ -389,17 +414,17 @@ func userResource(ctx context.Context, user *okta.User, skipSecondaryEmails bool } if user.Created != nil { - options = append(options, resource.WithCreatedAt(*user.Created)) + options = append(options, sdkResource.WithCreatedAt(*user.Created)) } if user.LastLogin != nil { - options = append(options, resource.WithLastLogin(*user.LastLogin)) + options = append(options, sdkResource.WithLastLogin(*user.LastLogin)) } if email, ok := oktaProfile["email"].(string); ok && email != "" { - options = append(options, resource.WithEmail(email, true)) + options = append(options, sdkResource.WithEmail(email, true)) } if secondEmail, ok := oktaProfile["secondEmail"].(string); ok && secondEmail != "" && !skipSecondaryEmails { - options = append(options, resource.WithEmail(secondEmail, false)) + options = append(options, sdkResource.WithEmail(secondEmail, false)) } if skipSecondaryEmails { @@ -418,16 +443,16 @@ func userResource(ctx context.Context, user *okta.User, skipSecondaryEmails bool // If possible, calculate shortname alias from login splitLogin := strings.Split(login, "@") if len(splitLogin) == 2 { - options = append(options, resource.WithUserLogin(login, splitLogin[0])) + options = append(options, sdkResource.WithUserLogin(login, splitLogin[0])) } else { - options = append(options, resource.WithUserLogin(login)) + options = append(options, sdkResource.WithUserLogin(login)) } } } } if employeeIDs.Cardinality() > 0 { - options = append(options, resource.WithEmployeeID(employeeIDs.ToSlice()...)) + options = append(options, sdkResource.WithEmployeeID(employeeIDs.ToSlice()...)) } switch user.Status { @@ -435,19 +460,19 @@ func userResource(ctx context.Context, user *okta.User, skipSecondaryEmails bool // case userStatusDeprovisioned: // options = append(options, resource.WithDetailedStatus(v2.UserTrait_Status_STATUS_DELETED, user.Status)) case userStatusSuspended, userStatusDeprovisioned: - options = append(options, resource.WithDetailedStatus(v2.UserTrait_Status_STATUS_DISABLED, user.Status)) + options = append(options, sdkResource.WithDetailedStatus(v2.UserTrait_Status_STATUS_DISABLED, user.Status)) case userStatusActive, userStatusProvisioned, userStatusStaged, userStatusPasswordExpired, userStatusRecovery, userStatusLockedOut: - options = append(options, resource.WithDetailedStatus(v2.UserTrait_Status_STATUS_ENABLED, user.Status)) + options = append(options, sdkResource.WithDetailedStatus(v2.UserTrait_Status_STATUS_ENABLED, user.Status)) default: - options = append(options, resource.WithDetailedStatus(v2.UserTrait_Status_STATUS_UNSPECIFIED, user.Status)) + options = append(options, sdkResource.WithDetailedStatus(v2.UserTrait_Status_STATUS_UNSPECIFIED, user.Status)) } - ret, err := resource.NewUserResource( + ret, err := sdkResource.NewUserResource( displayName, resourceTypeUser, user.Id, options, - resource.WithAnnotation(&v2.RawId{Id: user.Id}), + sdkResource.WithAnnotation(&v2.RawId{Id: user.Id}), ) return ret, err } From b8d9a171ef0f83ef545426ea5a342bf3e8daf88d Mon Sep 17 00:00:00 2001 From: Geoff Greer Date: Mon, 25 Aug 2025 13:02:46 -0700 Subject: [PATCH 3/8] Clean up my quick hack. Don't modify resourceTypeUser. Return a userResource type without skip grants annotation if we want to use the app links API. --- pkg/connector/connector.go | 11 ++++++----- pkg/connector/user.go | 11 +++++++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index adfa8796..94bcf25c 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -146,6 +146,12 @@ var ( Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}, Annotations: v1AnnotationsForResourceType("user", true), } + resourceTypeUserWithGrants = &v2.ResourceType{ + Id: "user-with-grants", + DisplayName: "User with Grants", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}, + Annotations: v1AnnotationsForResourceType("user", false), + } resourceTypeGroup = &v2.ResourceType{ Id: "group", DisplayName: "Group", @@ -372,11 +378,6 @@ func New(ctx context.Context, cfg *Config) (*Okta, error) { scopes = defaultScopes ) - if cfg.UseAppLinksForUserGrants { - // We need to fetch app links for users, so don't skip grants for user resource type. - resourceTypeUser.Annotations = v1AnnotationsForResourceType("user", false) - } - client, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, nil)) if err != nil { return nil, err diff --git a/pkg/connector/user.go b/pkg/connector/user.go index b8b7f835..b6749aef 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -375,13 +375,20 @@ func listUsers(ctx context.Context, client *okta.Client, token *pagination.Token return oktaUsers, respCtx, nil } +func getResourceType(useAppLinksForUserGrants bool) *v2.ResourceType { + if useAppLinksForUserGrants { + return resourceTypeUserWithGrants + } + return resourceTypeUser +} + func ciamUserBuilder(connector *Okta) *userResourceType { var loweredFilters []string for _, ef := range connector.ciamConfig.EmailDomains { loweredFilters = append(loweredFilters, strings.ToLower(ef)) } return &userResourceType{ - resourceType: resourceTypeUser, + resourceType: getResourceType(connector.useAppLinksForUserGrants), ciamEmailFilters: loweredFilters, connector: connector, } @@ -389,7 +396,7 @@ func ciamUserBuilder(connector *Okta) *userResourceType { func userBuilder(connector *Okta) *userResourceType { return &userResourceType{ - resourceType: resourceTypeUser, + resourceType: getResourceType(connector.useAppLinksForUserGrants), connector: connector, } } From 35a790a7f1c9d07449f7d16ca868515c223bb92e Mon Sep 17 00:00:00 2001 From: Geoff Greer Date: Mon, 25 Aug 2025 13:08:29 -0700 Subject: [PATCH 4/8] Add another guard rail. Mention that the API isn't paginated, so lack of pagination handling is not a bug. --- pkg/connector/user.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/connector/user.go b/pkg/connector/user.go index b6749aef..a0e87bbb 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -290,6 +290,12 @@ func (o *userResourceType) Grants( resource *v2.Resource, token *pagination.Token, ) ([]*v2.Grant, string, annotations.Annotations, error) { + // This shouldn't be necessary since we skip grants if useAppLinksForUserGrants is false, but it's good to be safe. + if !o.connector.useAppLinksForUserGrants { + return nil, "", nil, nil + } + + // This API is not paginated. It returns all app links for a user. appLinks, resp, err := o.connector.client.User.ListAppLinks(ctx, resource.Id.Resource) if err != nil { return nil, "", nil, fmt.Errorf("okta-connectorv2: failed to fetch app links from okta: %w", handleOktaResponseError(resp, err)) From 429b4215c5d7582e2d752100c3114f3e1c210cd0 Mon Sep 17 00:00:00 2001 From: Geoff Greer Date: Mon, 25 Aug 2025 13:13:41 -0700 Subject: [PATCH 5/8] Use app links mode in CI tests --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 33de9bee..5f522489 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,6 +50,7 @@ jobs: BATON_API_TOKEN: ${{ secrets.BATON_API_TOKEN }} BATON_DOMAIN: ${{ secrets.BATON_DOMAIN }} BATON_SYNC_CUSTOM_ROLES: true + BATON_USE_APP_LINKS_FOR_USER_GRANTS: true steps: - name: Install Go uses: actions/setup-go@v5 From 3b78d85320b621dd6de17b75b80f0c3c9c992d6c Mon Sep 17 00:00:00 2001 From: Geoff Greer Date: Mon, 25 Aug 2025 13:19:34 -0700 Subject: [PATCH 6/8] Fix resourceTypeUserWithGrants id. --- pkg/connector/connector.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 94bcf25c..fbd64ed3 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -147,8 +147,8 @@ var ( Annotations: v1AnnotationsForResourceType("user", true), } resourceTypeUserWithGrants = &v2.ResourceType{ - Id: "user-with-grants", - DisplayName: "User with Grants", + Id: "user", + DisplayName: "User", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}, Annotations: v1AnnotationsForResourceType("user", false), } From 38d046ee628ff97c422022f09f349400b29f0ef1 Mon Sep 17 00:00:00 2001 From: Geoff Greer Date: Mon, 25 Aug 2025 18:10:58 -0700 Subject: [PATCH 7/8] Inactive apps are not in appLinks, so we have to fall back to the slow API. --- pkg/connector/app.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/connector/app.go b/pkg/connector/app.go index e9741ac5..2706d282 100644 --- a/pkg/connector/app.go +++ b/pkg/connector/app.go @@ -130,7 +130,14 @@ func (o *appResourceType) Grants( ResourceTypeID: resourceTypeApp.Id, ResourceID: appGrantGroup, }) - if !o.useAppLinksForUserGrants { + var appStatus string + appTrait, err := sdkResource.GetAppTrait(resource) + // If we can't get the app trait on the app resource, we don't know if it's active or not, so we have to fall back to the slow listAppUsersGrants(). + if err == nil { + appStatus = appTrait.Profile.AsMap()["status"].(string) + } + // The app links API for users does not return inactive apps, so we have to fall back to the slow listAppUsersGrants(). + if !o.useAppLinksForUserGrants || strings.ToUpper(appStatus) != "ACTIVE" { bag.Push(pagination.PageState{ ResourceTypeID: resourceTypeApp.Id, ResourceID: appGrantUser, From 58b8d9a7042a940c1dda4b7ab2b28c227ad1a759 Mon Sep 17 00:00:00 2001 From: Geoff Greer Date: Mon, 25 Aug 2025 19:08:38 -0700 Subject: [PATCH 8/8] Fix potential panic if app profile or app status isn't what we expect. --- pkg/connector/app.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/connector/app.go b/pkg/connector/app.go index 2706d282..3dcdb28c 100644 --- a/pkg/connector/app.go +++ b/pkg/connector/app.go @@ -134,7 +134,10 @@ func (o *appResourceType) Grants( appTrait, err := sdkResource.GetAppTrait(resource) // If we can't get the app trait on the app resource, we don't know if it's active or not, so we have to fall back to the slow listAppUsersGrants(). if err == nil { - appStatus = appTrait.Profile.AsMap()["status"].(string) + appStatusValue, ok := appTrait.Profile.AsMap()["status"] + if ok { + appStatus, _ = appStatusValue.(string) + } } // The app links API for users does not return inactive apps, so we have to fall back to the slow listAppUsersGrants(). if !o.useAppLinksForUserGrants || strings.ToUpper(appStatus) != "ACTIVE" {