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
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cmd/baton-okta/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -65,4 +66,5 @@ var configuration = field.NewConfiguration([]field.SchemaField{
awsSourceIdentityMode,
awsAllowGroupToDirectAssignmentConversionForProvisioning,
filterEmailDomains,
useAppLinksForUserGrants,
}, field.WithConstraints(relationships...))
5 changes: 3 additions & 2 deletions cmd/baton-okta/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 35 additions & 20 deletions pkg/connector/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,36 +21,33 @@ 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 (
appGrantGroup = "group"
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,
}
}

Expand Down Expand Up @@ -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)
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
// 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:
Expand Down Expand Up @@ -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,
}),
))
}

Expand Down
52 changes: 31 additions & 21 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -110,6 +111,7 @@ type Config struct {
AllowGroupToDirectAssignmentConversionForProvisioning bool
SyncSecrets bool
FilterEmailDomains []string
UseAppLinksForUserGrants bool
}

func v1AnnotationsForResourceType(resourceTypeID string, skipEntitlementsAndGrants bool) annotations.Annotations {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
74 changes: 56 additions & 18 deletions pkg/connector/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Grants Method Fails Pagination and Rate Limiting

The Grants method doesn't implement pagination for ListAppLinks. It ignores the input pagination token, always returns an empty next page token, and doesn't extract rate limiting annotations from the response. This can lead to incomplete grant data and potential performance issues for users with many app links.

Fix in Cursor Fix in Web

}

func userName(user *okta.User) (string, string) {
Expand Down Expand Up @@ -350,21 +381,28 @@ 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,
}
}

func userBuilder(connector *Okta) *userResourceType {
return &userResourceType{
resourceType: resourceTypeUser,
resourceType: getResourceType(connector.useAppLinksForUserGrants),
connector: connector,
}
}
Expand All @@ -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),
Expand All @@ -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 {
Expand All @@ -418,36 +456,36 @@ 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 {
// TODO: change userStatusDeprovisioned to STATUS_DELETED once we show deleted stuff in baton & the UI
// 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}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: User Resource Creation Ignores Dynamic Type Selection

The userResource function hardcodes resourceTypeUser when creating user resources. This bypasses the dynamic resource type selection from the builders, which use resourceTypeUserWithGrants when useAppLinksForUserGrants is enabled. Consequently, user resources are created with the wrong type, preventing the new app links grants functionality from working as intended.

Fix in Cursor Fix in Web

)
return ret, err
}
Expand Down
Loading