From 1d27a3bdec57e57d44ceee3fd07401d7a671baeb Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:25:52 +0000 Subject: [PATCH 1/3] feat: sync app user profile attributes as grant metadata Include app-specific user profile attributes (assigned apps, scopes, custom fields) in grant metadata when syncing app user and group assignments. This surfaces Okta app assignment profile data in ConductorOne, enabling visibility into app-specific attributes like assigned scopes that are set on user profiles within Okta apps. Changes: - Add appUserProfileToMetadata() to convert AppUser profile to grant metadata, including scope, status, and externalId fields - Add appGroupAssignmentProfileToMetadata() for group assignment profiles - Attach profile metadata to user app grants via WithGrantMetadata - Attach profile metadata to group app grants via WithGrantMetadata --- pkg/connector/app.go | 85 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/pkg/connector/app.go b/pkg/connector/app.go index e05ec254..100dbfde 100644 --- a/pkg/connector/app.go +++ b/pkg/connector/app.go @@ -184,13 +184,21 @@ func (o *appResourceType) listAppGroupGrants( for _, applicationGroupAssignment := range applicationGroupAssignments { groupID := applicationGroupAssignment.Id principalID := &v2.ResourceId{ResourceType: resourceTypeGroup.Id, Resource: groupID} - rv = append(rv, sdkGrant.NewGrant(resource, "access", principalID, + + grantOptions := []sdkGrant.GrantOption{ sdkGrant.WithAnnotation( &v2.V1Identifier{ Id: fmtGrantIdV1(V1MembershipEntitlementID(resource.Id.Resource), groupID), }, ), - )) + } + + // Include group assignment profile attributes as grant metadata if available + if profileMetadata := appGroupAssignmentProfileToMetadata(applicationGroupAssignment); profileMetadata != nil { + grantOptions = append(grantOptions, sdkGrant.WithGrantMetadata(profileMetadata)) + } + + rv = append(rv, sdkGrant.NewGrant(resource, "access", principalID, grantOptions...)) } return rv, annos, bag, nil @@ -228,13 +236,21 @@ func (o *appResourceType) listAppUsersGrants( userID := applicationUser.Id principalID := &v2.ResourceId{ResourceType: resourceTypeUser.Id, Resource: userID} - rv = append(rv, sdkGrant.NewGrant(resource, "access", principalID, + + grantOptions := []sdkGrant.GrantOption{ sdkGrant.WithAnnotation( &v2.V1Identifier{ Id: fmtGrantIdV1(V1MembershipEntitlementID(resource.Id.Resource), userID), }, ), - )) + } + + // Include app user profile attributes as grant metadata if available + if profileMetadata := appUserProfileToMetadata(applicationUser); profileMetadata != nil { + grantOptions = append(grantOptions, sdkGrant.WithGrantMetadata(profileMetadata)) + } + + rv = append(rv, sdkGrant.NewGrant(resource, "access", principalID, grantOptions...)) } return rv, annos, bag, nil @@ -587,6 +603,67 @@ func (o *appResourceType) Get(ctx context.Context, resourceId *v2.ResourceId, pa return resource, annos, nil } +// appUserProfileToMetadata converts an Okta AppUser's profile into a metadata map +// suitable for attaching to grants. This includes app-specific attributes like +// assigned scopes, app roles, and custom profile fields that are set on the user's +// app assignment in Okta. +func appUserProfileToMetadata(appUser *okta.AppUser) map[string]interface{} { + if appUser == nil || appUser.Profile == nil { + return nil + } + + profile, ok := appUser.Profile.(map[string]interface{}) + if !ok { + return nil + } + + if len(profile) == 0 { + return nil + } + + metadata := make(map[string]interface{}) + for k, v := range profile { + metadata[k] = v + } + + // Include scope and status from the app user assignment itself + if appUser.Scope != "" { + metadata["_scope"] = appUser.Scope + } + if appUser.Status != "" { + metadata["_status"] = appUser.Status + } + if appUser.ExternalId != "" { + metadata["_externalId"] = appUser.ExternalId + } + + return metadata +} + +// appGroupAssignmentProfileToMetadata converts an Okta ApplicationGroupAssignment's +// profile into a metadata map suitable for attaching to grants. +func appGroupAssignmentProfileToMetadata(assignment *okta.ApplicationGroupAssignment) map[string]interface{} { + if assignment == nil || assignment.Profile == nil { + return nil + } + + profile, ok := assignment.Profile.(map[string]interface{}) + if !ok { + return nil + } + + if len(profile) == 0 { + return nil + } + + metadata := make(map[string]interface{}) + for k, v := range profile { + metadata[k] = v + } + + return metadata +} + func getApp(ctx context.Context, client *okta.Client, appID string) (*okta.Application, *responseContext, error) { app, resp, err := client.Application.GetApplication(ctx, appID, okta.NewApplication(), nil) if err != nil { From ead7cf5074ba0c42dc728ec7f073fa70a2be6594 Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:34:58 +0000 Subject: [PATCH 2/3] fix: validate metadata for structpb compatibility to prevent panics Address review feedback: ensure profile metadata values are compatible with structpb.NewStruct before passing to WithGrantMetadata. Adds toStructpbCompatibleValue() to coerce non-primitive types to strings, and validates the full metadata map with structpb.NewStruct, returning nil if conversion would fail. --- pkg/connector/app.go | 58 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/pkg/connector/app.go b/pkg/connector/app.go index 100dbfde..5e989b18 100644 --- a/pkg/connector/app.go +++ b/pkg/connector/app.go @@ -18,6 +18,7 @@ import ( "github.com/okta/okta-sdk-golang/v2/okta" "github.com/okta/okta-sdk-golang/v2/okta/query" "go.uber.org/zap" + "google.golang.org/protobuf/types/known/structpb" ) type appResourceType struct { @@ -623,10 +624,10 @@ func appUserProfileToMetadata(appUser *okta.AppUser) map[string]interface{} { metadata := make(map[string]interface{}) for k, v := range profile { - metadata[k] = v + metadata[k] = toStructpbCompatibleValue(v) } - // Include scope and status from the app user assignment itself + // Include scope and status from the app user assignment itself. if appUser.Scope != "" { metadata["_scope"] = appUser.Scope } @@ -637,6 +638,12 @@ func appUserProfileToMetadata(appUser *okta.AppUser) map[string]interface{} { metadata["_externalId"] = appUser.ExternalId } + // Validate that the metadata can be converted to a structpb.Struct + // to avoid panics in WithGrantMetadata/NewGrant. + if _, err := structpb.NewStruct(metadata); err != nil { + return nil + } + return metadata } @@ -658,12 +665,57 @@ func appGroupAssignmentProfileToMetadata(assignment *okta.ApplicationGroupAssign metadata := make(map[string]interface{}) for k, v := range profile { - metadata[k] = v + metadata[k] = toStructpbCompatibleValue(v) + } + + // Validate that the metadata can be converted to a structpb.Struct + // to avoid panics in WithGrantMetadata/NewGrant. + if _, err := structpb.NewStruct(metadata); err != nil { + return nil } return metadata } +// toStructpbCompatibleValue converts a value to a type compatible with +// structpb.NewStruct. structpb supports: nil, bool, int/uint/float (as float64), +// string, []interface{}, and map[string]interface{}. For unsupported types, +// we fall back to fmt.Sprintf to produce a string representation. +func toStructpbCompatibleValue(v interface{}) interface{} { + switch val := v.(type) { + case nil, bool, float64, string: + return val + case int: + return float64(val) + case int32: + return float64(val) + case int64: + return float64(val) + case uint: + return float64(val) + case uint32: + return float64(val) + case uint64: + return float64(val) + case float32: + return float64(val) + case []interface{}: + result := make([]interface{}, len(val)) + for i, item := range val { + result[i] = toStructpbCompatibleValue(item) + } + return result + case map[string]interface{}: + result := make(map[string]interface{}) + for k, item := range val { + result[k] = toStructpbCompatibleValue(item) + } + return result + default: + return fmt.Sprintf("%v", val) + } +} + func getApp(ctx context.Context, client *okta.Client, appID string) (*okta.Application, *responseContext, error) { app, resp, err := client.Application.GetApplication(ctx, appID, okta.NewApplication(), nil) if err != nil { From 4969bf23ee025ba96dc43924a307366c7b49fd85 Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:38:54 +0000 Subject: [PATCH 3/3] fix: remove unused nolint directive on Config.ApiToken The gosec linter no longer flags the ApiToken field name, so the //nolint:gosec directive is unused and triggers a nolintlint error. --- pkg/connector/connector.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index bbbbc64c..45195817 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -92,7 +92,7 @@ type userFilterConfig struct { type Config struct { Domain string - ApiToken string //nolint:gosec // Not a credential + ApiToken string OktaClientId string OktaPrivateKey string OktaPrivateKeyId string