diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 566090b9..ad28f9d8 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -3,19 +3,26 @@ package connector import ( "context" "fmt" + "strings" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/pagination" + "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/okta/okta-sdk-golang/v2/okta" "github.com/okta/okta-sdk-golang/v2/okta/query" - "google.golang.org/protobuf/types/known/structpb" ) const ( - unknownProfileValue = "unknown" - userStatusSuspended = "SUSPENDED" - userStatusDeprovisioned = "DEPROVISIONED" + unknownProfileValue = "unknown" + userStatusSuspended = "SUSPENDED" + userStatusDeprovisioned = "DEPROVISIONED" + userStatusActive = "ACTIVE" + userStatusLockedOut = "LOCKED_OUT" + userStatusPasswordExpired = "PASSWORD_EXPIRED" + userStatusProvisioned = "PROVISIONED" + userStatusRecovery = "RECOVERY" + userStatusStaged = "STAGED" ) type userResourceType struct { @@ -133,61 +140,60 @@ func userBuilder(domain string, apiToken string, client *okta.Client) *userResou func userResource(ctx context.Context, user *okta.User) (*v2.Resource, error) { firstName, lastName := userName(user) - trait, err := userTrait(ctx, user) - if err != nil { - return nil, err - } - var annos annotations.Annotations - annos.Update(trait) - annos.Update(&v2.V1Identifier{ - Id: fmtResourceIdV1(user.Id), - }) - - return &v2.Resource{ - Id: fmtResourceId(resourceTypeUser.Id, user.Id), - DisplayName: fmt.Sprintf("%s %s", firstName, lastName), - Annotations: annos, - }, nil -} - -// Create and return a User trait for a okta user. -func userTrait(ctx context.Context, user *okta.User) (*v2.UserTrait, error) { oktaProfile := *user.Profile + options := []resource.UserTraitOption{ + resource.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), + } - email, ok := oktaProfile["email"].(string) + displayName, ok := oktaProfile["displayName"].(string) if !ok { - email = unknownProfileValue + displayName = fmt.Sprintf("%s %s", firstName, lastName) } - profile, err := structpb.NewStruct(oktaProfile) - if err != nil { - return nil, fmt.Errorf("okta-connectorv2: failed to construct user profile for user trait: %w", err) + if user.Created != nil { + resource.WithCreatedAt(*user.Created) } - - var status v2.UserTrait_Status_Status - switch user.Status { - case userStatusSuspended, userStatusDeprovisioned: - status = v2.UserTrait_Status_STATUS_DISABLED - default: - status = v2.UserTrait_Status_STATUS_ENABLED + if user.LastLogin != nil { + resource.WithLastLogin(*user.LastLogin) } - ret := &v2.UserTrait{ - Profile: profile, - Status: &v2.UserTrait_Status{ - Status: status, - Details: user.Status, - }, + if email, ok := oktaProfile["email"].(string); ok && email != "" { + options = append(options, resource.WithEmail(email, true)) + } + if secondEmail, ok := oktaProfile["secondEmail"].(string); ok && secondEmail != "" { + options = append(options, resource.WithEmail(secondEmail, false)) } - if email != "" { - ret.Emails = []*v2.UserTrait_Email{ - { - Address: email, - IsPrimary: true, - }, + if login, ok := oktaProfile["login"].(string); ok { + // If possible, calculate shortname alias from login + splitLogin := strings.Split(login, "@") + if len(splitLogin) == 2 { + options = append(options, resource.WithUserLogin(login, splitLogin[0])) + } else { + options = append(options, resource.WithUserLogin(login)) } } - return ret, nil + 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, userStatusLockedOut, userStatusDeprovisioned: + options = append(options, resource.WithDetailedStatus(v2.UserTrait_Status_STATUS_DISABLED, user.Status)) + case userStatusActive, userStatusProvisioned, userStatusStaged, userStatusPasswordExpired, userStatusRecovery: + options = append(options, resource.WithDetailedStatus(v2.UserTrait_Status_STATUS_ENABLED, user.Status)) + default: + options = append(options, resource.WithDetailedStatus(v2.UserTrait_Status_STATUS_UNSPECIFIED, user.Status)) + } + + ret, err := resource.NewUserResource( + displayName, + resourceTypeUser, + user.Id, + options, + ) + return ret, err } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/app_trait.go b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/app_trait.go new file mode 100644 index 00000000..8d2335d6 --- /dev/null +++ b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/app_trait.go @@ -0,0 +1,83 @@ +package resource + +import ( + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "google.golang.org/protobuf/types/known/structpb" +) + +type AppTraitOption func(gt *v2.AppTrait) error + +func WithAppIcon(assetRef *v2.AssetRef) AppTraitOption { + return func(at *v2.AppTrait) error { + at.Icon = assetRef + + return nil + } +} + +func WithAppLogo(assetRef *v2.AssetRef) AppTraitOption { + return func(at *v2.AppTrait) error { + at.Logo = assetRef + + return nil + } +} + +func WithAppFlags(flags ...v2.AppTrait_AppFlag) AppTraitOption { + return func(at *v2.AppTrait) error { + at.Flags = flags + return nil + } +} + +func WithAppProfile(profile map[string]interface{}) AppTraitOption { + return func(at *v2.AppTrait) error { + p, err := structpb.NewStruct(profile) + if err != nil { + return err + } + + at.Profile = p + + return nil + } +} + +func WithAppHelpURL(helpURL string) AppTraitOption { + return func(at *v2.AppTrait) error { + at.HelpUrl = helpURL + return nil + } +} + +// NewAppTrait creates a new `AppTrait` with the given help URL, and profile. +func NewAppTrait(opts ...AppTraitOption) (*v2.AppTrait, error) { + at := &v2.AppTrait{} + + for _, opt := range opts { + err := opt(at) + if err != nil { + return nil, err + } + } + + return at, nil +} + +// GetAppTrait attempts to return the AppTrait instance on a resource. +func GetAppTrait(resource *v2.Resource) (*v2.AppTrait, error) { + ret := &v2.AppTrait{} + annos := annotations.Annotations(resource.Annotations) + ok, err := annos.Pick(ret) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("app trait was not found on resource") + } + + return ret, nil +} diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/group_trait.go b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/group_trait.go new file mode 100644 index 00000000..d37506a7 --- /dev/null +++ b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/group_trait.go @@ -0,0 +1,60 @@ +package resource + +import ( + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "google.golang.org/protobuf/types/known/structpb" +) + +type GroupTraitOption func(gt *v2.GroupTrait) error + +func WithGroupProfile(profile map[string]interface{}) GroupTraitOption { + return func(gt *v2.GroupTrait) error { + p, err := structpb.NewStruct(profile) + if err != nil { + return err + } + + gt.Profile = p + + return nil + } +} + +func WithGroupIcon(assetRef *v2.AssetRef) GroupTraitOption { + return func(gt *v2.GroupTrait) error { + gt.Icon = assetRef + return nil + } +} + +// NewGroupTrait creates a new `GroupTrait` with the provided profile. +func NewGroupTrait(opts ...GroupTraitOption) (*v2.GroupTrait, error) { + groupTrait := &v2.GroupTrait{} + + for _, opt := range opts { + err := opt(groupTrait) + if err != nil { + return nil, err + } + } + + return groupTrait, nil +} + +// GetGroupTrait attempts to return the GroupTrait instance on a resource. +func GetGroupTrait(resource *v2.Resource) (*v2.GroupTrait, error) { + ret := &v2.GroupTrait{} + annos := annotations.Annotations(resource.Annotations) + ok, err := annos.Pick(ret) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("group trait was not found on resource") + } + + return ret, nil +} diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/resource.go b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/resource.go new file mode 100644 index 00000000..a49dff15 --- /dev/null +++ b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/resource.go @@ -0,0 +1,298 @@ +package resource + +import ( + "fmt" + "strconv" + "strings" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "google.golang.org/protobuf/proto" +) + +type ResourceOption func(*v2.Resource) error + +func WithAnnotation(msgs ...proto.Message) ResourceOption { + return func(r *v2.Resource) error { + annos := annotations.Annotations(r.Annotations) + for _, msg := range msgs { + if msg == nil { + continue + } + annos.Append(msg) + } + r.Annotations = annos + + return nil + } +} + +func WithExternalID(externalID *v2.ExternalId) ResourceOption { + return func(r *v2.Resource) error { + r.ExternalId = externalID + return nil + } +} + +func WithParentResourceID(parentResourceID *v2.ResourceId) ResourceOption { + return func(r *v2.Resource) error { + r.ParentResourceId = parentResourceID + + return nil + } +} + +func WithDescription(description string) ResourceOption { + return func(r *v2.Resource) error { + r.Description = description + + return nil + } +} + +func WithUserTrait(opts ...UserTraitOption) ResourceOption { + return func(r *v2.Resource) error { + var err error + ut := &v2.UserTrait{} + + annos := annotations.Annotations(r.Annotations) + + picked, err := annos.Pick(ut) + if err != nil { + return err + } + if picked { + // We found an existing user trait, so we want to update it in place + for _, o := range opts { + err = o(ut) + if err != nil { + return err + } + } + } else { + // No existing user trait found, so create a new one with the provided options + ut, err = NewUserTrait(opts...) + if err != nil { + return err + } + } + + annos.Update(ut) + r.Annotations = annos + return nil + } +} + +func WithGroupTrait(opts ...GroupTraitOption) ResourceOption { + return func(r *v2.Resource) error { + ut := &v2.GroupTrait{} + + annos := annotations.Annotations(r.Annotations) + _, err := annos.Pick(ut) + if err != nil { + return err + } + + for _, o := range opts { + err = o(ut) + if err != nil { + return err + } + } + + annos.Update(ut) + r.Annotations = annos + return nil + } +} + +func WithRoleTrait(opts ...RoleTraitOption) ResourceOption { + return func(r *v2.Resource) error { + rt := &v2.RoleTrait{} + + annos := annotations.Annotations(r.Annotations) + _, err := annos.Pick(rt) + if err != nil { + return err + } + + for _, o := range opts { + err := o(rt) + if err != nil { + return err + } + } + + annos.Update(rt) + r.Annotations = annos + + return nil + } +} + +func WithAppTrait(opts ...AppTraitOption) ResourceOption { + return func(r *v2.Resource) error { + at := &v2.AppTrait{} + + annos := annotations.Annotations(r.Annotations) + _, err := annos.Pick(at) + if err != nil { + return err + } + + for _, o := range opts { + err := o(at) + if err != nil { + return err + } + } + + annos.Update(at) + r.Annotations = annos + + return nil + } +} + +func convertIDToString(id interface{}) (string, error) { + var resourceID string + switch objID := id.(type) { + case string: + resourceID = objID + case int64: + resourceID = strconv.FormatInt(objID, 10) + case int: + resourceID = strconv.Itoa(objID) + default: + return "", fmt.Errorf("unexpected type for id") + } + + return resourceID, nil +} + +// NewResourceType returns a new *v2.ResourceType where the id is the name lowercased with spaces replaced by hyphens. +func NewResourceType(name string, requiredTraits []v2.ResourceType_Trait, msgs ...proto.Message) *v2.ResourceType { + id := strings.ReplaceAll(strings.ToLower(name), " ", "-") + + var annos annotations.Annotations + for _, msg := range msgs { + annos.Append(msg) + } + + return &v2.ResourceType{ + Id: id, + DisplayName: name, + Traits: requiredTraits, + Annotations: annos, + } +} + +// NewResourceID returns a new resource ID given a resource type parent ID, and arbitrary object ID. +func NewResourceID(resourceType *v2.ResourceType, objectID interface{}) (*v2.ResourceId, error) { + id, err := convertIDToString(objectID) + if err != nil { + return nil, err + } + + return &v2.ResourceId{ + ResourceType: resourceType.Id, + Resource: id, + }, nil +} + +// NewResource returns a new resource instance with no traits. +func NewResource(name string, resourceType *v2.ResourceType, objectID interface{}, resourceOptions ...ResourceOption) (*v2.Resource, error) { + rID, err := NewResourceID(resourceType, objectID) + if err != nil { + return nil, err + } + + resource := &v2.Resource{ + Id: rID, + DisplayName: name, + } + + for _, resourceOption := range resourceOptions { + err = resourceOption(resource) + if err != nil { + return nil, err + } + } + return resource, nil +} + +// NewUserResource returns a new resource instance with a configured user trait. +// The trait is configured with the provided email address and profile and status set to enabled. +func NewUserResource( + name string, + resourceType *v2.ResourceType, + objectID interface{}, + userTraitOpts []UserTraitOption, + opts ...ResourceOption, +) (*v2.Resource, error) { + opts = append(opts, WithUserTrait(userTraitOpts...)) + + ret, err := NewResource(name, resourceType, objectID, opts...) + if err != nil { + return nil, err + } + + return ret, nil +} + +// NewGroupResource returns a new resource instance with a configured group trait. +// The trait is configured with the provided profile. +func NewGroupResource( + name string, + resourceType *v2.ResourceType, + objectID interface{}, + groupTraitOpts []GroupTraitOption, + opts ...ResourceOption, +) (*v2.Resource, error) { + opts = append(opts, WithGroupTrait(groupTraitOpts...)) + + ret, err := NewResource(name, resourceType, objectID, opts...) + if err != nil { + return nil, err + } + + return ret, nil +} + +// NewRoleResource returns a new resource instance with a configured role trait. +// The trait is configured with the provided profile. +func NewRoleResource( + name string, + resourceType *v2.ResourceType, + objectID interface{}, + roleTraitOpts []RoleTraitOption, + opts ...ResourceOption, +) (*v2.Resource, error) { + opts = append(opts, WithRoleTrait(roleTraitOpts...)) + + ret, err := NewResource(name, resourceType, objectID, opts...) + if err != nil { + return nil, err + } + + return ret, nil +} + +// NewAppResource returns a new resource instance with a configured app trait. +// The trait is configured with the provided helpURL and profile. +func NewAppResource( + name string, + resourceType *v2.ResourceType, + objectID interface{}, + appTraitOpts []AppTraitOption, + opts ...ResourceOption, +) (*v2.Resource, error) { + opts = append(opts, WithAppTrait(appTraitOpts...)) + + ret, err := NewResource(name, resourceType, objectID, opts...) + if err != nil { + return nil, err + } + + return ret, nil +} diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/role_trait.go b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/role_trait.go new file mode 100644 index 00000000..44fe8096 --- /dev/null +++ b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/role_trait.go @@ -0,0 +1,53 @@ +package resource + +import ( + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "google.golang.org/protobuf/types/known/structpb" +) + +type RoleTraitOption func(gt *v2.RoleTrait) error + +func WithRoleProfile(profile map[string]interface{}) RoleTraitOption { + return func(rt *v2.RoleTrait) error { + p, err := structpb.NewStruct(profile) + if err != nil { + return err + } + + rt.Profile = p + + return nil + } +} + +// NewRoleTrait creates a new `RoleTrait` with the provided profile. +func NewRoleTrait(opts ...RoleTraitOption) (*v2.RoleTrait, error) { + groupTrait := &v2.RoleTrait{} + + for _, opt := range opts { + err := opt(groupTrait) + if err != nil { + return nil, err + } + } + + return groupTrait, nil +} + +// GetRoleTrait attempts to return the RoleTrait instance on a resource. +func GetRoleTrait(resource *v2.Resource) (*v2.RoleTrait, error) { + ret := &v2.RoleTrait{} + annos := annotations.Annotations(resource.Annotations) + ok, err := annos.Pick(ret) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("group trait was not found on resource") + } + + return ret, nil +} diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/traits.go b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/traits.go new file mode 100644 index 00000000..470c2fc5 --- /dev/null +++ b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/traits.go @@ -0,0 +1,43 @@ +package resource + +import ( + "google.golang.org/protobuf/types/known/structpb" +) + +// GetProfileStringValue returns a string and true if the value is found. +func GetProfileStringValue(profile *structpb.Struct, k string) (string, bool) { + if profile == nil { + return "", false + } + + v, ok := profile.Fields[k] + if !ok { + return "", false + } + + s, ok := v.Kind.(*structpb.Value_StringValue) + if !ok { + return "", false + } + + return s.StringValue, true +} + +// GetProfileInt64Value returns an int64 and true if the value is found. +func GetProfileInt64Value(profile *structpb.Struct, k string) (int64, bool) { + if profile == nil { + return 0, false + } + + v, ok := profile.Fields[k] + if !ok { + return 0, false + } + + s, ok := v.Kind.(*structpb.Value_NumberValue) + if !ok { + return 0, false + } + + return int64(s.NumberValue), true +} diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/user_trait.go b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/user_trait.go new file mode 100644 index 00000000..7c104801 --- /dev/null +++ b/vendor/github.com/conductorone/baton-sdk/pkg/types/resource/user_trait.go @@ -0,0 +1,153 @@ +package resource + +import ( + "fmt" + "time" + + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" +) + +type UserTraitOption func(ut *v2.UserTrait) error + +func WithStatus(status v2.UserTrait_Status_Status) UserTraitOption { + return func(ut *v2.UserTrait) error { + ut.Status = &v2.UserTrait_Status{Status: status} + + return nil + } +} + +func WithDetailedStatus(status v2.UserTrait_Status_Status, details string) UserTraitOption { + return func(ut *v2.UserTrait) error { + ut.Status = &v2.UserTrait_Status{Status: status, Details: details} + + return nil + } +} + +func WithEmail(email string, primary bool) UserTraitOption { + return func(ut *v2.UserTrait) error { + if email == "" { + return nil + } + + traitEmail := &v2.UserTrait_Email{ + Address: email, + IsPrimary: primary, + } + + ut.Emails = append(ut.Emails, traitEmail) + + return nil + } +} +func WithUserLogin(login string, aliases ...string) UserTraitOption { + return func(ut *v2.UserTrait) error { + if login == "" { + // If login is empty do nothing + return nil + } + ut.Login = login + ut.LoginAliases = aliases + return nil + } +} + +func WithUserIcon(assetRef *v2.AssetRef) UserTraitOption { + return func(ut *v2.UserTrait) error { + ut.Icon = assetRef + + return nil + } +} + +func WithUserProfile(profile map[string]interface{}) UserTraitOption { + return func(ut *v2.UserTrait) error { + p, err := structpb.NewStruct(profile) + if err != nil { + return err + } + + ut.Profile = p + + return nil + } +} + +func WithAccountType(accountType v2.UserTrait_AccountType) UserTraitOption { + return func(ut *v2.UserTrait) error { + ut.AccountType = accountType + return nil + } +} + +func WithCreatedAt(createdAt time.Time) UserTraitOption { + return func(ut *v2.UserTrait) error { + ut.CreatedAt = timestamppb.New(createdAt) + return nil + } +} + +func WithLastLogin(lastLogin time.Time) UserTraitOption { + return func(ut *v2.UserTrait) error { + ut.LastLogin = timestamppb.New(lastLogin) + return nil + } +} + +func WithMFAStatus(mfaStatus *v2.UserTrait_MFAStatus) UserTraitOption { + return func(ut *v2.UserTrait) error { + ut.MfaStatus = mfaStatus + return nil + } +} + +func WithSSOStatus(ssoStatus *v2.UserTrait_SSOStatus) UserTraitOption { + return func(ut *v2.UserTrait) error { + ut.SsoStatus = ssoStatus + return nil + } +} + +// NewUserTrait creates a new `UserTrait`. +func NewUserTrait(opts ...UserTraitOption) (*v2.UserTrait, error) { + userTrait := &v2.UserTrait{} + + for _, opt := range opts { + err := opt(userTrait) + if err != nil { + return nil, err + } + } + + // If no status was set, default to be enabled. + if userTrait.Status == nil { + userTrait.Status = &v2.UserTrait_Status{Status: v2.UserTrait_Status_STATUS_ENABLED} + } + + // If account type isn't specified, default to a human user. + if userTrait.AccountType == v2.UserTrait_ACCOUNT_TYPE_UNSPECIFIED { + userTrait.AccountType = v2.UserTrait_ACCOUNT_TYPE_HUMAN + } + + return userTrait, nil +} + +// GetUserTrait attempts to return the UserTrait instance on a resource. +func GetUserTrait(resource *v2.Resource) (*v2.UserTrait, error) { + ret := &v2.UserTrait{} + annos := annotations.Annotations(resource.Annotations) + ok, err := annos.Pick(ret) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("user trait was not found on resource") + } + + return ret, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 01a8edcc..42ce488c 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -175,6 +175,7 @@ github.com/conductorone/baton-sdk/pkg/tasks github.com/conductorone/baton-sdk/pkg/tasks/c1api github.com/conductorone/baton-sdk/pkg/tasks/local github.com/conductorone/baton-sdk/pkg/types +github.com/conductorone/baton-sdk/pkg/types/resource github.com/conductorone/baton-sdk/pkg/ugrpc github.com/conductorone/baton-sdk/pkg/uhttp github.com/conductorone/baton-sdk/pkg/us3