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 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 2ad50f20..3dcdb28c 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,24 @@ func (o *appResourceType) Grants( switch bag.ResourceID() { case "": bag.Pop() - for _, appGrantType := range appGrantTypes { + bag.Push(pagination.PageState{ + ResourceTypeID: resourceTypeApp.Id, + ResourceID: appGrantGroup, + }) + 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 { + 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" { bag.Push(pagination.PageState{ ResourceTypeID: resourceTypeApp.Id, - ResourceID: appGrantType, + ResourceID: appGrantUser, }) } case appGrantGroup: @@ -190,6 +201,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, + }), )) } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 2d59798a..fbd64ed3 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 { @@ -144,6 +146,12 @@ var ( Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}, Annotations: v1AnnotationsForResourceType("user", true), } + resourceTypeUserWithGrants = &v2.ResourceType{ + Id: "user", + DisplayName: "User", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}, + Annotations: v1AnnotationsForResourceType("user", false), + } resourceTypeGroup = &v2.ResourceType{ Id: "group", DisplayName: "Group", @@ -214,7 +222,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 +377,7 @@ func New(ctx context.Context, cfg *Config) (*Okta, error) { oktaClient *okta.Client scopes = defaultScopes ) + client, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, nil)) if err != nil { return nil, err @@ -454,14 +463,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..a0e87bbb 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,37 @@ func (o *userResourceType) Grants( resource *v2.Resource, token *pagination.Token, ) ([]*v2.Grant, string, annotations.Annotations, error) { - return nil, "", nil, nil + // 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)) + } + 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) { @@ -350,13 +381,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, } @@ -364,7 +402,7 @@ func ciamUserBuilder(connector *Okta) *userResourceType { func userBuilder(connector *Okta) *userResourceType { return &userResourceType{ - resourceType: resourceTypeUser, + resourceType: getResourceType(connector.useAppLinksForUserGrants), connector: connector, } } @@ -376,8 +414,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 +427,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 +456,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 +473,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 }