From c308f9243f970df48d368c2ee83a8ccbc2c095ef Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Tue, 29 Apr 2025 13:14:30 +0200 Subject: [PATCH 01/11] Fix user type issue Signed-off-by: Yury Tsarev --- fn.go | 82 ++++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/fn.go b/fn.go index 87fe3ce..91433fc 100644 --- a/fn.go +++ b/fn.go @@ -375,7 +375,7 @@ func (g *GraphQuery) fetchGroupMembers(ctx context.Context, client *msgraphsdk.G // Select the fields we want membersRequestConfig.QueryParameters.Select = []string{ "id", "displayName", "mail", "userPrincipalName", - "appId", "description", + "appId", "description", "objectType", } // Get the members @@ -383,8 +383,22 @@ func (g *GraphQuery) fetchGroupMembers(ctx context.Context, client *msgraphsdk.G if err != nil { return nil, errors.Wrapf(err, "failed to get members for group %s", groupName) } + + // Get raw response data directly + values := result.GetValue() + + // Hard-code types for users if we can identify them + // This is a workaround until we can understand what data the API provides + for _, obj := range values { + // Get additional data + additionalData := obj.GetAdditionalData() + + // Log the additional data for debugging + additionalDataStr, _ := json.Marshal(additionalData) + fmt.Printf("Member additional data: %s\n", additionalDataStr) + } - return result.GetValue(), nil + return values, nil } // extractDisplayName attempts to extract the display name from a directory object @@ -449,11 +463,11 @@ func (g *GraphQuery) extractServicePrincipalProperties(additionalData map[string // getMemberType determines the type of member based on odata.type func (g *GraphQuery) getMemberType(odataType string) string { switch { - case strings.Contains(odataType, "user"): + case strings.Contains(strings.ToLower(odataType), "user"): return "user" - case strings.Contains(odataType, "servicePrincipal"): + case strings.Contains(strings.ToLower(odataType), "serviceprincipal"): return "servicePrincipal" - case strings.Contains(odataType, "group"): + case strings.Contains(strings.ToLower(odataType), "group"): return "group" default: return "unknown" @@ -464,15 +478,23 @@ func (g *GraphQuery) getMemberType(odataType string) string { func (g *GraphQuery) extractMemberProperties(additionalData map[string]interface{}, memberMap map[string]interface{}) string { memberType := "unknown" - // Determine type from @odata.type + // Try to determine type from @odata.type odataType, ok := g.extractStringProperty(additionalData, "@odata.type") - if !ok { - return memberType + if ok { + memberType = g.getMemberType(odataType) + } else { + // Fallback type detection based on available properties + _, hasUserPrincipalName := g.extractStringProperty(additionalData, "userPrincipalName") + _, hasMail := g.extractStringProperty(additionalData, "mail") + _, hasAppId := g.extractStringProperty(additionalData, "appId") + + if hasUserPrincipalName || hasMail { + memberType = "user" + } else if hasAppId { + memberType = "servicePrincipal" + } } - // Get the member type - memberType = g.getMemberType(odataType) - // Extract type-specific properties switch memberType { case "user": @@ -510,17 +532,47 @@ func (g *GraphQuery) getGroupMembers(ctx context.Context, client *msgraphsdk.Gra memberID := member.GetId() additionalData := member.GetAdditionalData() + // For debugging, log the raw additional data + additionalDataJSON, _ := json.Marshal(additionalData) + fmt.Printf("Member %s additional data: %s\n", *memberID, additionalDataJSON) + + // Check if we can determine the member type using reflection + var memberType string = "unknown" + + // Force user type based on properties + _, hasUserPrincipalName := g.extractStringProperty(additionalData, "userPrincipalName") + _, hasMail := g.extractStringProperty(additionalData, "mail") + if hasUserPrincipalName || hasMail { + memberType = "user" + } + + // Try to use model interfaces instead of additionalData + // Try to cast to user + if user, ok := member.(models.Userable); ok && user != nil { + memberType = "user" + } + + // Try to cast to service principal + if sp, ok := member.(models.ServicePrincipalable); ok && sp != nil { + memberType = "servicePrincipal" + } + // Create basic member info memberMap := map[string]interface{}{ - "id": memberID, + "id": memberID, + "type": memberType, } // Extract display name memberMap["displayName"] = g.extractDisplayName(member, *memberID) - // Extract type-specific properties - memberType := g.extractMemberProperties(additionalData, memberMap) - memberMap["type"] = memberType + // Extract type-specific properties based on determined type + switch memberType { + case "user": + g.extractUserProperties(additionalData, memberMap) + case "servicePrincipal": + g.extractServicePrincipalProperties(additionalData, memberMap) + } members = append(members, memberMap) } From 1faacadfd072a22e22a57b300e16d1fd7f439d11 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Tue, 29 Apr 2025 14:59:59 +0200 Subject: [PATCH 02/11] Force including servicePrincipals into the list by using expand workaround Signed-off-by: Yury Tsarev --- fn.go | 211 +++++++++++++++++++++++++++++----------------------------- 1 file changed, 106 insertions(+), 105 deletions(-) diff --git a/fn.go b/fn.go index 91433fc..c5b875d 100644 --- a/fn.go +++ b/fn.go @@ -180,6 +180,11 @@ func (f *Function) checkStatusTargetHasData(req *fnv1.RunFunctionRequest, in *v1 // executeQuery executes the query. func (f *Function) executeQuery(ctx context.Context, azureCreds map[string]string, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) (interface{}, error) { + // Initialize GraphQuery with logger if needed + if gq, ok := f.graphQuery.(*GraphQuery); ok { + gq.log = f.log + } + results, err := f.graphQuery.graphQuery(ctx, azureCreds, in) if err != nil { response.Fatal(rsp, err) @@ -239,7 +244,9 @@ func getCreds(req *fnv1.RunFunctionRequest) (map[string]string, error) { // GraphQuery is a concrete implementation of the GraphQueryInterface // that interacts with Microsoft Graph API. -type GraphQuery struct{} +type GraphQuery struct { + log logging.Logger +} // createGraphClient initializes a Microsoft Graph client using the provided credentials func (g *GraphQuery) createGraphClient(azureCreds map[string]string) (*msgraphsdk.GraphServiceClient, error) { @@ -367,38 +374,65 @@ func (g *GraphQuery) findGroupByName(ctx context.Context, client *msgraphsdk.Gra // fetchGroupMembers fetches all members of a group by group ID func (g *GraphQuery) fetchGroupMembers(ctx context.Context, client *msgraphsdk.GraphServiceClient, groupID string, groupName string) ([]models.DirectoryObjectable, error) { - // Configure the members request - membersRequestConfig := &groups.ItemMembersRequestBuilderGetRequestConfiguration{ - QueryParameters: &groups.ItemMembersRequestBuilderGetQueryParameters{}, - } - - // Select the fields we want - membersRequestConfig.QueryParameters.Select = []string{ - "id", "displayName", "mail", "userPrincipalName", - "appId", "description", "objectType", + // Create a request configuration that expands members + // This is the workaround for the known issue where service principals + // are not listed as group members in v1.0 + // See: https://developer.microsoft.com/en-us/graph/known-issues/?search=25984 + requestConfig := &groups.GroupItemRequestBuilderGetRequestConfiguration{ + QueryParameters: &groups.GroupItemRequestBuilderGetQueryParameters{ + Expand: []string{"members"}, + }, } - // Get the members - result, err := client.Groups().ByGroupId(groupID).Members().Get(ctx, membersRequestConfig) + // Get the group with expanded members using the workaround + // mentioned in the Microsoft documentation + group, err := client.Groups().ByGroupId(groupID).Get(ctx, requestConfig) if err != nil { return nil, errors.Wrapf(err, "failed to get members for group %s", groupName) } - - // Get raw response data directly - values := result.GetValue() - - // Hard-code types for users if we can identify them - // This is a workaround until we can understand what data the API provides - for _, obj := range values { - // Get additional data - additionalData := obj.GetAdditionalData() - - // Log the additional data for debugging - additionalDataStr, _ := json.Marshal(additionalData) - fmt.Printf("Member additional data: %s\n", additionalDataStr) + + // Extract the members from the expanded result + var members []models.DirectoryObjectable + if group.GetMembers() != nil { + members = group.GetMembers() + } + + // Log what we get directly from the API response + if g.log != nil { + // Log basic information about the response + g.log.Info("API Response Data (using $expand=members workaround)", + "groupID", groupID, + "groupName", groupName, + "memberCount", len(members)) + + // Log if we got members back + if len(members) > 0 { + g.log.Info(fmt.Sprintf("Found %d members in group %s", len(members), groupName)) + + // Log each member's raw data + for i, member := range members { + if i >= 5 { + // Limit logging to first 5 members + break + } + + memberID := member.GetId() + additionalData := member.GetAdditionalData() + memberType := fmt.Sprintf("%T", member) + + // Try to marshal this member's additional data + rawData, err := json.MarshalIndent(additionalData, "", " ") + if err == nil { + g.log.Info(fmt.Sprintf("Member %d (ID: %s, Type: %s)", i, *memberID, memberType)) + g.log.Info(string(rawData)) + } + } + } else { + g.log.Info("No members found in the group using $expand method") + } } - return values, nil + return members, nil } // extractDisplayName attempts to extract the display name from a directory object @@ -460,50 +494,62 @@ func (g *GraphQuery) extractServicePrincipalProperties(additionalData map[string } } -// getMemberType determines the type of member based on odata.type -func (g *GraphQuery) getMemberType(odataType string) string { - switch { - case strings.Contains(strings.ToLower(odataType), "user"): - return "user" - case strings.Contains(strings.ToLower(odataType), "serviceprincipal"): - return "servicePrincipal" - case strings.Contains(strings.ToLower(odataType), "group"): - return "group" - default: - return "unknown" +// processMember extracts member information into a map +func (g *GraphQuery) processMember(member models.DirectoryObjectable) map[string]interface{} { + // Define constants for member types + const ( + userType = "user" + servicePrincipalType = "servicePrincipal" + unknownType = "unknown" + ) + + memberID := member.GetId() + additionalData := member.GetAdditionalData() + + // Create basic member info + memberMap := map[string]interface{}{ + "id": memberID, } -} -// extractMemberProperties extracts relevant properties based on the member type -func (g *GraphQuery) extractMemberProperties(additionalData map[string]interface{}, memberMap map[string]interface{}) string { - memberType := "unknown" + // Determine member type + memberType := unknownType - // Try to determine type from @odata.type - odataType, ok := g.extractStringProperty(additionalData, "@odata.type") - if ok { - memberType = g.getMemberType(odataType) - } else { - // Fallback type detection based on available properties - _, hasUserPrincipalName := g.extractStringProperty(additionalData, "userPrincipalName") - _, hasMail := g.extractStringProperty(additionalData, "mail") - _, hasAppId := g.extractStringProperty(additionalData, "appId") - - if hasUserPrincipalName || hasMail { - memberType = "user" - } else if hasAppId { - memberType = "servicePrincipal" - } + // Check properties that indicate user type + _, hasUserPrincipalName := g.extractStringProperty(additionalData, "userPrincipalName") + _, hasMail := g.extractStringProperty(additionalData, "mail") + if hasUserPrincipalName || hasMail { + memberType = userType + } + + // Check properties that indicate service principal type + _, hasAppID := g.extractStringProperty(additionalData, "appId") + if hasAppID { + memberType = servicePrincipalType + } + + // Try interface type checking for more accuracy + if _, ok := member.(models.Userable); ok { + memberType = userType + } + if _, ok := member.(models.ServicePrincipalable); ok { + memberType = servicePrincipalType } + // Add type to member info + memberMap["type"] = memberType + + // Extract display name + memberMap["displayName"] = g.extractDisplayName(member, *memberID) + // Extract type-specific properties switch memberType { - case "user": + case userType: g.extractUserProperties(additionalData, memberMap) - case "servicePrincipal": + case servicePrincipalType: g.extractServicePrincipalProperties(additionalData, memberMap) } - return memberType + return memberMap } // getGroupMembers retrieves all members of the specified group @@ -527,53 +573,8 @@ func (g *GraphQuery) getGroupMembers(ctx context.Context, client *msgraphsdk.Gra // Process the members members := make([]interface{}, 0, len(memberObjects)) - for _, member := range memberObjects { - memberID := member.GetId() - additionalData := member.GetAdditionalData() - - // For debugging, log the raw additional data - additionalDataJSON, _ := json.Marshal(additionalData) - fmt.Printf("Member %s additional data: %s\n", *memberID, additionalDataJSON) - - // Check if we can determine the member type using reflection - var memberType string = "unknown" - - // Force user type based on properties - _, hasUserPrincipalName := g.extractStringProperty(additionalData, "userPrincipalName") - _, hasMail := g.extractStringProperty(additionalData, "mail") - if hasUserPrincipalName || hasMail { - memberType = "user" - } - - // Try to use model interfaces instead of additionalData - // Try to cast to user - if user, ok := member.(models.Userable); ok && user != nil { - memberType = "user" - } - - // Try to cast to service principal - if sp, ok := member.(models.ServicePrincipalable); ok && sp != nil { - memberType = "servicePrincipal" - } - - // Create basic member info - memberMap := map[string]interface{}{ - "id": memberID, - "type": memberType, - } - - // Extract display name - memberMap["displayName"] = g.extractDisplayName(member, *memberID) - - // Extract type-specific properties based on determined type - switch memberType { - case "user": - g.extractUserProperties(additionalData, memberMap) - case "servicePrincipal": - g.extractServicePrincipalProperties(additionalData, memberMap) - } - + memberMap := g.processMember(member) members = append(members, memberMap) } From a2611cc3b226477141e70e67e24d309d8fcb301a Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Tue, 29 Apr 2025 15:10:12 +0200 Subject: [PATCH 03/11] Simplify debug api logging and update examples Signed-off-by: Yury Tsarev --- example/group-membership-example.yaml | 2 +- example/group-objectids-example.yaml | 1 + example/service-principal-example.yaml | 2 +- fn.go | 34 ++------------------------ 4 files changed, 5 insertions(+), 34 deletions(-) diff --git a/example/group-membership-example.yaml b/example/group-membership-example.yaml index ff6116e..2e89476 100644 --- a/example/group-membership-example.yaml +++ b/example/group-membership-example.yaml @@ -21,7 +21,7 @@ spec: apiVersion: msgraph.fn.crossplane.io/v1alpha1 kind: Input queryType: GroupMembership - group: "All Company" + group: test-fn-msgraph # The function will automatically select standard fields: # - id, displayName, mail, userPrincipalName, appId, description target: "status.groupMembers" diff --git a/example/group-objectids-example.yaml b/example/group-objectids-example.yaml index 8e95cc7..5f5c302 100644 --- a/example/group-objectids-example.yaml +++ b/example/group-objectids-example.yaml @@ -23,6 +23,7 @@ spec: - "Developers" - "All Engineering" - "All Company" + - test-fn-msgraph target: "status.groupObjectIDs" skipQueryWhenTargetHasData: true credentials: diff --git a/example/service-principal-example.yaml b/example/service-principal-example.yaml index 2c36a7e..7ab9a76 100644 --- a/example/service-principal-example.yaml +++ b/example/service-principal-example.yaml @@ -18,7 +18,7 @@ spec: servicePrincipals: - "MyServiceApp" - "ApiConnector" - - "dare-oidc-provider" + - "yury-upbound-oidc-provider" target: "status.servicePrincipalDetails" skipQueryWhenTargetHasData: true credentials: diff --git a/fn.go b/fn.go index c5b875d..2890dca 100644 --- a/fn.go +++ b/fn.go @@ -397,39 +397,9 @@ func (g *GraphQuery) fetchGroupMembers(ctx context.Context, client *msgraphsdk.G members = group.GetMembers() } - // Log what we get directly from the API response + // Log basic information about the membership if g.log != nil { - // Log basic information about the response - g.log.Info("API Response Data (using $expand=members workaround)", - "groupID", groupID, - "groupName", groupName, - "memberCount", len(members)) - - // Log if we got members back - if len(members) > 0 { - g.log.Info(fmt.Sprintf("Found %d members in group %s", len(members), groupName)) - - // Log each member's raw data - for i, member := range members { - if i >= 5 { - // Limit logging to first 5 members - break - } - - memberID := member.GetId() - additionalData := member.GetAdditionalData() - memberType := fmt.Sprintf("%T", member) - - // Try to marshal this member's additional data - rawData, err := json.MarshalIndent(additionalData, "", " ") - if err == nil { - g.log.Info(fmt.Sprintf("Member %d (ID: %s, Type: %s)", i, *memberID, memberType)) - g.log.Info(string(rawData)) - } - } - } else { - g.log.Info("No members found in the group using $expand method") - } + g.log.Debug("Retrieved group members", "groupName", groupName, "groupID", groupID, "memberCount", len(members)) } return members, nil From 535dc7d6e1147e587851709736daa76fa24fb396 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Apr 2025 00:15:29 +0200 Subject: [PATCH 04/11] Implement dynamic `groupRef` capability * Tests * Implementation * Examples Signed-off-by: Yury Tsarev --- example/README.md | 10 + example/envconfig.yaml | 7 + example/functions.yaml | 8 + .../group-membership-example-context-ref.yaml | 44 +++ .../group-membership-example-status-ref.yaml | 34 ++ example/xr.yaml | 3 + fn.go | 152 +++++++-- fn_test.go | 291 ++++++++++++++++++ input/v1beta1/input.go | 5 + input/v1beta1/zz_generated.deepcopy.go | 5 + .../msgraph.fn.crossplane.io_inputs.yaml | 5 + 11 files changed, 533 insertions(+), 31 deletions(-) create mode 100644 example/envconfig.yaml create mode 100644 example/group-membership-example-context-ref.yaml create mode 100644 example/group-membership-example-status-ref.yaml diff --git a/example/README.md b/example/README.md index 80ff73c..fb3557c 100644 --- a/example/README.md +++ b/example/README.md @@ -50,6 +50,16 @@ Get all members of a specified Azure AD group: crossplane render xr.yaml group-membership-example.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc ``` +Dynamic `groupRef` variations: + +```shell +crossplane render xr.yaml group-membership-example-status-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc +``` + +```shell +crossplane render xr.yaml group-membership-example-context-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc --extra-resources=envconfig.yaml +``` + ### 3. Group Object IDs Get object IDs for specified Azure AD groups: diff --git a/example/envconfig.yaml b/example/envconfig.yaml new file mode 100644 index 0000000..ef88cad --- /dev/null +++ b/example/envconfig.yaml @@ -0,0 +1,7 @@ +apiVersion: apiextensions.crossplane.io/v1alpha1 +kind: EnvironmentConfig +metadata: + name: example-config +data: + group: + name: test-fn-msgraph diff --git a/example/functions.yaml b/example/functions.yaml index 759aa99..fde62e6 100644 --- a/example/functions.yaml +++ b/example/functions.yaml @@ -8,3 +8,11 @@ metadata: render.crossplane.io/runtime: Development spec: package: xpkg.upbound.io/upbound/function-msgraph:v0.1.0 +--- +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: crossplane-contrib-function-environment-configs +spec: + package: xpkg.upbound.io/crossplane-contrib/function-environment-configs:v0.2.0 diff --git a/example/group-membership-example-context-ref.yaml b/example/group-membership-example-context-ref.yaml new file mode 100644 index 0000000..3734ac3 --- /dev/null +++ b/example/group-membership-example-context-ref.yaml @@ -0,0 +1,44 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: group-membership-example + # Important: This function requires an Azure AD app registration with Microsoft Graph API permissions: + # - Group.Read.All + # - Directory.Read.All + # - User.Read.All (if groups contain users) + # - Application.Read.All (if groups contain service principals) +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: environmentConfigs + functionRef: + name: crossplane-contrib-function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + environmentConfigs: + - type: Reference + ref: + name: example-config + - step: get-group-members + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: GroupMembership + groupRef: context.[apiextensions.crossplane.io/environment].group.name + # The function will automatically select standard fields: + # - id, displayName, mail, userPrincipalName, appId, description + target: "status.groupMembers" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds diff --git a/example/group-membership-example-status-ref.yaml b/example/group-membership-example-status-ref.yaml new file mode 100644 index 0000000..a99bfd7 --- /dev/null +++ b/example/group-membership-example-status-ref.yaml @@ -0,0 +1,34 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: group-membership-example + annotations: + # Important: This function requires an Azure AD app registration with Microsoft Graph API permissions: + # - Group.Read.All + # - Directory.Read.All + # - User.Read.All (if groups contain users) + # - Application.Read.All (if groups contain service principals) +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: get-group-members + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: GroupMembership + groupRef: status.group.name + # The function will automatically select standard fields: + # - id, displayName, mail, userPrincipalName, appId, description + target: "status.groupMembers" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds diff --git a/example/xr.yaml b/example/xr.yaml index 25472b9..d7b3d50 100644 --- a/example/xr.yaml +++ b/example/xr.yaml @@ -4,3 +4,6 @@ kind: XR metadata: name: example-xr spec: {} +status: + group: + name: test-fn-msgraph diff --git a/fn.go b/fn.go index 2890dca..3a0d83e 100644 --- a/fn.go +++ b/fn.go @@ -46,42 +46,25 @@ func (f *Function) RunFunction(ctx context.Context, req *fnv1.RunFunctionRequest rsp := response.To(req, response.DefaultTTL) - // Ensure oxr to dxr gets propagated and we keep status around - if err := f.propagateDesiredXR(req, rsp); err != nil { - return rsp, nil //nolint:nilerr // errors are handled in rsp. We should not error main function and proceed with reconciliation + // Initialize response with desired XR and preserve context + if err := f.initializeResponse(req, rsp); err != nil { + return rsp, nil //nolint:nilerr // errors are handled in rsp } - // Ensure the context is preserved - f.preserveContext(req, rsp) // Parse input and get credentials in, azureCreds, err := f.parseInputAndCredentials(req, rsp) if err != nil { - return rsp, nil //nolint:nilerr // errors are handled in rsp. We should not error main function and proceed with reconciliation - } - - // Check if target is valid - if !f.isValidTarget(in.Target) { - response.Fatal(rsp, errors.Errorf("Unrecognized target field: %s", in.Target)) - return rsp, nil - } - - // Check if we should skip the query - if f.shouldSkipQuery(req, in, rsp) { - // Set success condition - response.ConditionTrue(rsp, "FunctionSuccess", "Success"). - TargetCompositeAndClaim() - return rsp, nil + return rsp, nil //nolint:nilerr // errors are handled in rsp } - // Execute the query - results, err := f.executeQuery(ctx, azureCreds, in, rsp) - if err != nil { - return rsp, nil //nolint:nilerr // errors are handled in rsp. We should not error main function and proceed with reconciliation + // Validate and prepare input + if !f.validateAndPrepareInput(ctx, req, in, rsp) { + return rsp, nil // Early return if validation failed or query should be skipped } - // Process the results - if err := f.processResults(req, in, results, rsp); err != nil { - return rsp, nil //nolint:nilerr // errors are handled in rsp. We should not error main function and proceed with reconciliation + // Execute the query and process results + if !f.executeAndProcessQuery(ctx, req, in, azureCreds, rsp) { + return rsp, nil // Error already handled in response } // Set success condition @@ -524,19 +507,24 @@ func (g *GraphQuery) processMember(member models.DirectoryObjectable) map[string // getGroupMembers retrieves all members of the specified group func (g *GraphQuery) getGroupMembers(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { - // Validate input - if in.Group == nil || *in.Group == "" { + // Determine the group name to use + var groupName string + + // Check if we have a group name (either directly or resolved from GroupRef) + if in.Group != nil && *in.Group != "" { + groupName = *in.Group + } else { return nil, errors.New("no group name provided") } // Find the group - groupID, err := g.findGroupByName(ctx, client, *in.Group) + groupID, err := g.findGroupByName(ctx, client, groupName) if err != nil { return nil, err } // Fetch the members - memberObjects, err := g.fetchGroupMembers(ctx, client, *groupID, *in.Group) + memberObjects, err := g.fetchGroupMembers(ctx, client, *groupID, groupName) if err != nil { return nil, err } @@ -864,6 +852,63 @@ func (f *Function) preserveContext(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFu } } +// initializeResponse initializes the response with desired XR and preserves context +func (f *Function) initializeResponse(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctionResponse) error { + // Ensure oxr to dxr gets propagated and we keep status around + if err := f.propagateDesiredXR(req, rsp); err != nil { + return err + } + // Ensure the context is preserved + f.preserveContext(req, rsp) + return nil +} + +// validateAndPrepareInput validates the input and prepares it for execution +func (f *Function) validateAndPrepareInput(_ context.Context, req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { + // Check if target is valid + if !f.isValidTarget(in.Target) { + response.Fatal(rsp, errors.Errorf("Unrecognized target field: %s", in.Target)) + return false + } + + // Check if we should skip the query + if f.shouldSkipQuery(req, in, rsp) { + // Set success condition + response.ConditionTrue(rsp, "FunctionSuccess", "Success"). + TargetCompositeAndClaim() + return false + } + + // Process groupRef if it exists for GroupMembership query type + if in.QueryType == "GroupMembership" && in.GroupRef != nil && *in.GroupRef != "" { + groupName, err := f.resolveGroupRef(req, in.GroupRef) + if err != nil { + response.Fatal(rsp, err) + return false + } + in.Group = &groupName + f.log.Info("Resolved GroupRef to group", "group", groupName, "groupRef", *in.GroupRef) + } + + return true +} + +// executeAndProcessQuery executes the query and processes the results +func (f *Function) executeAndProcessQuery(ctx context.Context, req *fnv1.RunFunctionRequest, in *v1beta1.Input, azureCreds map[string]string, rsp *fnv1.RunFunctionResponse) bool { + // Execute the query + results, err := f.executeQuery(ctx, azureCreds, in, rsp) + if err != nil { + return false + } + + // Process the results + if err := f.processResults(req, in, results, rsp); err != nil { + return false + } + + return true +} + // isValidTarget checks if the target is valid func (f *Function) isValidTarget(target string) bool { return strings.HasPrefix(target, "status.") || strings.HasPrefix(target, "context.") @@ -906,3 +951,48 @@ func (f *Function) checkContextTargetHasData(req *fnv1.RunFunctionRequest, in *v } return false } + +// resolveGroupRef resolves the group name from a reference in status or context. +func (f *Function) resolveGroupRef(req *fnv1.RunFunctionRequest, groupRef *string) (string, error) { + if groupRef == nil || *groupRef == "" { + return "", errors.New("empty groupRef provided") + } + + refKey := *groupRef + + // Use a proper switch statement instead of if-else chain + switch { + case strings.HasPrefix(refKey, "status."): + return f.resolveFromStatus(req, refKey) + case strings.HasPrefix(refKey, "context."): + return f.resolveFromContext(req, refKey) + default: + return "", errors.Errorf("unsupported groupRef format: %s", refKey) + } +} + +// resolveFromStatus resolves a reference from XR status +func (f *Function) resolveFromStatus(req *fnv1.RunFunctionRequest, refKey string) (string, error) { + xrStatus, _, err := f.getXRAndStatus(req) + if err != nil { + return "", errors.Wrap(err, "cannot get XR status") + } + + statusField := strings.TrimPrefix(refKey, "status.") + value, ok := GetNestedKey(xrStatus, statusField) + if !ok { + return "", errors.Errorf("cannot resolve groupRef: %s not found", refKey) + } + return value, nil +} + +// resolveFromContext resolves a reference from function context +func (f *Function) resolveFromContext(req *fnv1.RunFunctionRequest, refKey string) (string, error) { + contextMap := req.GetContext().AsMap() + contextField := strings.TrimPrefix(refKey, "context.") + value, ok := GetNestedKey(contextMap, contextField) + if !ok { + return "", errors.Errorf("cannot resolve groupRef: %s not found", refKey) + } + return value, nil +} diff --git a/fn_test.go b/fn_test.go index 9fc9287..562e26b 100644 --- a/fn_test.go +++ b/fn_test.go @@ -29,6 +29,297 @@ func strPtr(s string) *string { return &s } +// TestResolveGroupRef tests the functionality of resolving groupRef from context or status +func TestResolveGroupRef(t *testing.T) { + var ( + xr = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` + creds = &fnv1.CredentialData{ + Data: map[string][]byte{ + "credentials": []byte(`{ +"clientId": "test-client-id", +"clientSecret": "test-client-secret", +"subscriptionId": "test-subscription-id", +"tenantId": "test-tenant-id" +}`), + }, + } + ) + + type args struct { + ctx context.Context + req *fnv1.RunFunctionRequest + } + type want struct { + rsp *fnv1.RunFunctionResponse + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "GroupRefFromStatus": { + reason: "The Function should resolve groupRef from XR status", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupMembership", + "groupRef": "status.groupInfo.name", + "target": "status.groupMembers" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "status": { + "groupInfo": { + "name": "Developers" + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "GroupMembership"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "status": { + "groupInfo": { + "name": "Developers" + }, + "groupMembers": [ + { + "id": "user-id-1", + "displayName": "Test User 1", + "mail": "user1@example.com", + "type": "user", + "userPrincipalName": "user1@example.com" + }, + { + "id": "sp-id-1", + "displayName": "Test Service Principal", + "appId": "sp-app-id-1", + "type": "servicePrincipal" + } + ] + }}`), + }, + }, + }, + }, + }, + "GroupRefFromContext": { + reason: "The Function should resolve groupRef from context", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupMembership", + "groupRef": "context.groupInfo.name", + "target": "status.groupMembers" + }`), + Context: resource.MustStructJSON(`{ + "groupInfo": { + "name": "Developers" + } + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "GroupMembership"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Context: resource.MustStructJSON(`{ + "groupInfo": { + "name": "Developers" + } + }`), + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "cool-xr" + }, + "status": { + "groupMembers": [ + { + "id": "user-id-1", + "displayName": "Test User 1", + "mail": "user1@example.com", + "type": "user", + "userPrincipalName": "user1@example.com" + }, + { + "id": "sp-id-1", + "displayName": "Test Service Principal", + "appId": "sp-app-id-1", + "type": "servicePrincipal" + } + ] + }}`), + }, + }, + }, + }, + }, + "GroupRefNotFound": { + reason: "The Function should handle an error when groupRef cannot be resolved", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupMembership", + "groupRef": "context.nonexistent.value", + "target": "status.groupMembers" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "cannot resolve groupRef: context.nonexistent.value not found", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "cool-xr" + } + }`), + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + // Create mock responders for each type of query + mockQuery := &MockGraphQuery{ + GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { + if in.QueryType == "GroupMembership" { + if in.Group == nil || *in.Group == "" { + return nil, errors.New("no group name provided") + } + return []interface{}{ + map[string]interface{}{ + "id": "user-id-1", + "displayName": "Test User 1", + "mail": "user1@example.com", + "userPrincipalName": "user1@example.com", + "type": "user", + }, + map[string]interface{}{ + "id": "sp-id-1", + "displayName": "Test Service Principal", + "appId": "sp-app-id-1", + "type": "servicePrincipal", + }, + }, nil + } + return nil, errors.Errorf("unsupported query type: %s", in.QueryType) + }, + } + + f := &Function{ + graphQuery: mockQuery, + log: logging.NewNopLogger(), + } + rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) + + if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) + } + }) + } +} + func TestRunFunction(t *testing.T) { var ( diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 8be3ec2..7c0baca 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -35,6 +35,11 @@ type Input struct { // +optional Group *string `json:"group,omitempty"` + // GroupRef is a reference to retrieve the group name (e.g., from status or context) + // Overrides Group field if used + // +optional + GroupRef *string `json:"groupRef,omitempty"` + // ServicePrincipals is a list of service principal names // +optional ServicePrincipals []*string `json:"servicePrincipals,omitempty"` diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index d988bb6..fc9ab8f 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -40,6 +40,11 @@ func (in *Input) DeepCopyInto(out *Input) { *out = new(string) **out = **in } + if in.GroupRef != nil { + in, out := &in.GroupRef, &out.GroupRef + *out = new(string) + **out = **in + } if in.ServicePrincipals != nil { in, out := &in.ServicePrincipals, &out.ServicePrincipals *out = make([]*string, len(*in)) diff --git a/package/input/msgraph.fn.crossplane.io_inputs.yaml b/package/input/msgraph.fn.crossplane.io_inputs.yaml index 63d34f5..2083ef8 100644 --- a/package/input/msgraph.fn.crossplane.io_inputs.yaml +++ b/package/input/msgraph.fn.crossplane.io_inputs.yaml @@ -31,6 +31,11 @@ spec: group: description: Group is a single group name for group membership queries type: string + groupRef: + description: |- + GroupRef is a reference to retrieve the group name (e.g., from status or context) + Overrides Group field if used + type: string groups: description: Groups is a list of group names for group object ID queries items: From ac5ad5351c1aa073b71f3bb1155651d885ad3ceb Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Apr 2025 00:46:20 +0200 Subject: [PATCH 05/11] Add dynamic `groupsRef` for GroupObjectIDs query * Tests * Implementation * Examples Signed-off-by: Yury Tsarev --- example/README.md | 10 + example/envconfig.yaml | 2 + .../group-membership-example-context-ref.yaml | 7 +- .../group-membership-example-status-ref.yaml | 8 +- .../group-objectids-example-context-ref.yaml | 37 +++ .../group-objectids-example-status-ref.yaml | 30 ++ example/xr.yaml | 2 + fn.go | 95 ++++++ fn_test.go | 299 ++++++++++++++++++ input/v1beta1/input.go | 5 + input/v1beta1/zz_generated.deepcopy.go | 5 + .../msgraph.fn.crossplane.io_inputs.yaml | 5 + 12 files changed, 492 insertions(+), 13 deletions(-) create mode 100644 example/group-objectids-example-context-ref.yaml create mode 100644 example/group-objectids-example-status-ref.yaml diff --git a/example/README.md b/example/README.md index fb3557c..77602d3 100644 --- a/example/README.md +++ b/example/README.md @@ -68,6 +68,16 @@ Get object IDs for specified Azure AD groups: crossplane render xr.yaml group-objectids-example.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc ``` +Dynamic `groupsRef` variations: + +```shell +crossplane render xr.yaml group-objectids-example-status-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc +``` + +```shell +crossplane render xr.yaml group-objectids-example-context-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc --extra-resources=envconfig.yaml +``` + ### 4. Service Principal Details Get details of specified service principals: diff --git a/example/envconfig.yaml b/example/envconfig.yaml index ef88cad..a03a123 100644 --- a/example/envconfig.yaml +++ b/example/envconfig.yaml @@ -5,3 +5,5 @@ metadata: data: group: name: test-fn-msgraph + groups: + - test-fn-msgraph diff --git a/example/group-membership-example-context-ref.yaml b/example/group-membership-example-context-ref.yaml index 3734ac3..75ea5f5 100644 --- a/example/group-membership-example-context-ref.yaml +++ b/example/group-membership-example-context-ref.yaml @@ -1,12 +1,7 @@ apiVersion: apiextensions.crossplane.io/v1 kind: Composition metadata: - name: group-membership-example - # Important: This function requires an Azure AD app registration with Microsoft Graph API permissions: - # - Group.Read.All - # - Directory.Read.All - # - User.Read.All (if groups contain users) - # - Application.Read.All (if groups contain service principals) + name: group-membership-example-context-ref spec: compositeTypeRef: apiVersion: example.crossplane.io/v1 diff --git a/example/group-membership-example-status-ref.yaml b/example/group-membership-example-status-ref.yaml index a99bfd7..b5d7660 100644 --- a/example/group-membership-example-status-ref.yaml +++ b/example/group-membership-example-status-ref.yaml @@ -1,13 +1,7 @@ apiVersion: apiextensions.crossplane.io/v1 kind: Composition metadata: - name: group-membership-example - annotations: - # Important: This function requires an Azure AD app registration with Microsoft Graph API permissions: - # - Group.Read.All - # - Directory.Read.All - # - User.Read.All (if groups contain users) - # - Application.Read.All (if groups contain service principals) + name: group-membership-example-status-ref spec: compositeTypeRef: apiVersion: example.crossplane.io/v1 diff --git a/example/group-objectids-example-context-ref.yaml b/example/group-objectids-example-context-ref.yaml new file mode 100644 index 0000000..9cb3dee --- /dev/null +++ b/example/group-objectids-example-context-ref.yaml @@ -0,0 +1,37 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: group-objectids-example-context-ref +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: environmentConfigs + functionRef: + name: crossplane-contrib-function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + environmentConfigs: + - type: Reference + ref: + name: example-config + - step: get-group-objectids + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: GroupObjectIDs + groupsRef: context.[apiextensions.crossplane.io/environment].groups + target: "status.groupObjectIDs" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds diff --git a/example/group-objectids-example-status-ref.yaml b/example/group-objectids-example-status-ref.yaml new file mode 100644 index 0000000..771feae --- /dev/null +++ b/example/group-objectids-example-status-ref.yaml @@ -0,0 +1,30 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: group-objectids-example-status-ref + annotations: + # Important: This function requires an Azure AD app registration with Microsoft Graph API permissions: + # - Group.Read.All + # - Directory.Read.All +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: get-group-objectids + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: GroupObjectIDs + groupsRef: status.groups + target: "status.groupObjectIDs" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds diff --git a/example/xr.yaml b/example/xr.yaml index d7b3d50..a59ceb0 100644 --- a/example/xr.yaml +++ b/example/xr.yaml @@ -7,3 +7,5 @@ spec: {} status: group: name: test-fn-msgraph + groups: + - test-fn-msgraph diff --git a/fn.go b/fn.go index 3a0d83e..5edee25 100644 --- a/fn.go +++ b/fn.go @@ -879,6 +879,16 @@ func (f *Function) validateAndPrepareInput(_ context.Context, req *fnv1.RunFunct return false } + // Process references based on query type + if !f.processReferences(req, in, rsp) { + return false + } + + return true +} + +// processReferences handles resolving references like groupRef and groupsRef +func (f *Function) processReferences(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { // Process groupRef if it exists for GroupMembership query type if in.QueryType == "GroupMembership" && in.GroupRef != nil && *in.GroupRef != "" { groupName, err := f.resolveGroupRef(req, in.GroupRef) @@ -890,6 +900,17 @@ func (f *Function) validateAndPrepareInput(_ context.Context, req *fnv1.RunFunct f.log.Info("Resolved GroupRef to group", "group", groupName, "groupRef", *in.GroupRef) } + // Process groupsRef if it exists for GroupObjectIDs query type + if in.QueryType == "GroupObjectIDs" && in.GroupsRef != nil && *in.GroupsRef != "" { + groupNames, err := f.resolveGroupsRef(req, in.GroupsRef) + if err != nil { + response.Fatal(rsp, err) + return false + } + in.Groups = groupNames + f.log.Info("Resolved GroupsRef to groups", "groupCount", len(groupNames), "groupsRef", *in.GroupsRef) + } + return true } @@ -996,3 +1017,77 @@ func (f *Function) resolveFromContext(req *fnv1.RunFunctionRequest, refKey strin } return value, nil } + +// resolveGroupsRef resolves a list of group names from a reference in status or context. +func (f *Function) resolveGroupsRef(req *fnv1.RunFunctionRequest, groupsRef *string) ([]*string, error) { + if groupsRef == nil || *groupsRef == "" { + return nil, errors.New("empty groupsRef provided") + } + + refKey := *groupsRef + + // Use proper switch statement instead of if-else chain + switch { + case strings.HasPrefix(refKey, "status."): + return f.resolveGroupsFromStatus(req, refKey) + case strings.HasPrefix(refKey, "context."): + return f.resolveGroupsFromContext(req, refKey) + default: + return nil, errors.Errorf("unsupported groupsRef format: %s", refKey) + } +} + +// resolveGroupsFromStatus resolves a list of group names from XR status +func (f *Function) resolveGroupsFromStatus(req *fnv1.RunFunctionRequest, refKey string) ([]*string, error) { + xrStatus, _, err := f.getXRAndStatus(req) + if err != nil { + return nil, errors.Wrap(err, "cannot get XR status") + } + + statusField := strings.TrimPrefix(refKey, "status.") + return f.extractStringArrayFromMap(xrStatus, statusField, refKey) +} + +// resolveGroupsFromContext resolves a list of group names from function context +func (f *Function) resolveGroupsFromContext(req *fnv1.RunFunctionRequest, refKey string) ([]*string, error) { + contextMap := req.GetContext().AsMap() + contextField := strings.TrimPrefix(refKey, "context.") + return f.extractStringArrayFromMap(contextMap, contextField, refKey) +} + +// extractStringArrayFromMap extracts a string array from a map using nested key +func (f *Function) extractStringArrayFromMap(dataMap map[string]interface{}, field, refKey string) ([]*string, error) { + parts, err := ParseNestedKey(field) + if err != nil { + return nil, errors.Wrap(err, "invalid field key") + } + + currentValue := interface{}(dataMap) + for _, k := range parts { + if nestedMap, ok := currentValue.(map[string]interface{}); ok { + if nextValue, exists := nestedMap[k]; exists { + currentValue = nextValue + } else { + return nil, errors.Errorf("cannot resolve groupsRef: %s not found", refKey) + } + } else { + return nil, errors.Errorf("cannot resolve groupsRef: %s not a map", refKey) + } + } + + // The current value should be a slice of strings + if strArray, ok := currentValue.([]interface{}); ok { + result := make([]*string, 0, len(strArray)) + for _, val := range strArray { + if strVal, ok := val.(string); ok { + strCopy := strVal // Create a new string to avoid pointing to a loop variable + result = append(result, &strCopy) + } + } + if len(result) > 0 { + return result, nil + } + } + + return nil, errors.Errorf("cannot resolve groupsRef: %s not a string array or empty", refKey) +} diff --git a/fn_test.go b/fn_test.go index 562e26b..0a5afed 100644 --- a/fn_test.go +++ b/fn_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "testing" "github.com/google/go-cmp/cmp" @@ -29,6 +30,304 @@ func strPtr(s string) *string { return &s } +// TestResolveGroupsRef tests the functionality of resolving groupsRef from context or status +func TestResolveGroupsRef(t *testing.T) { + var ( + xr = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` + creds = &fnv1.CredentialData{ + Data: map[string][]byte{ + "credentials": []byte(`{ +"clientId": "test-client-id", +"clientSecret": "test-client-secret", +"subscriptionId": "test-subscription-id", +"tenantId": "test-tenant-id" +}`), + }, + } + ) + + type args struct { + ctx context.Context + req *fnv1.RunFunctionRequest + } + type want struct { + rsp *fnv1.RunFunctionResponse + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "GroupsRefFromStatus": { + reason: "The Function should resolve groupsRef from XR status", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groupsRef": "status.groups", + "target": "status.groupObjectIDs" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "status": { + "groups": ["Developers", "Operations", "All Company"] + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "GroupObjectIDs"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "status": { + "groups": ["Developers", "Operations", "All Company"], + "groupObjectIDs": [ + { + "id": "group-id-1", + "displayName": "Developers", + "description": "Development team" + }, + { + "id": "group-id-2", + "displayName": "Operations", + "description": "Operations team" + }, + { + "id": "group-id-3", + "displayName": "All Company", + "description": "All company group" + } + ] + }}`), + }, + }, + }, + }, + }, + "GroupsRefFromContext": { + reason: "The Function should resolve groupsRef from context", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groupsRef": "context.groups", + "target": "status.groupObjectIDs" + }`), + Context: resource.MustStructJSON(`{ + "groups": ["Developers", "Operations", "All Company"] + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "GroupObjectIDs"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Context: resource.MustStructJSON(`{ + "groups": ["Developers", "Operations", "All Company"] + }`), + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "cool-xr" + }, + "status": { + "groupObjectIDs": [ + { + "id": "group-id-1", + "displayName": "Developers", + "description": "Development team" + }, + { + "id": "group-id-2", + "displayName": "Operations", + "description": "Operations team" + }, + { + "id": "group-id-3", + "displayName": "All Company", + "description": "All company group" + } + ] + }}`), + }, + }, + }, + }, + }, + "GroupsRefNotFound": { + reason: "The Function should handle an error when groupsRef cannot be resolved", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groupsRef": "context.nonexistent.value", + "target": "status.groupObjectIDs" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "cannot resolve groupsRef: context.nonexistent.value not found", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "cool-xr" + } + }`), + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + // Create mock responders for each type of query + mockQuery := &MockGraphQuery{ + GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { + if in.QueryType == "GroupObjectIDs" { + if len(in.Groups) == 0 { + return nil, errors.New("no group names provided") + } + + var results []interface{} + for i, group := range in.Groups { + if group == nil { + continue + } + + groupID := fmt.Sprintf("group-id-%d", i+1) + var description string + switch *group { + case "Operations": + description = "Operations team" + case "All Company": + description = "All company group" + default: + description = "Development team" + } + + groupMap := map[string]interface{}{ + "id": groupID, + "displayName": *group, + "description": description, + } + results = append(results, groupMap) + } + return results, nil + } + return nil, errors.Errorf("unsupported query type: %s", in.QueryType) + }, + } + + f := &Function{ + graphQuery: mockQuery, + log: logging.NewNopLogger(), + } + rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) + + if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) + } + }) + } +} + // TestResolveGroupRef tests the functionality of resolving groupRef from context or status func TestResolveGroupRef(t *testing.T) { var ( diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 7c0baca..7e83c64 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -31,6 +31,11 @@ type Input struct { // +optional Groups []*string `json:"groups,omitempty"` + // GroupsRef is a reference to retrieve the group names (e.g., from status or context) + // Overrides Groups field if used + // +optional + GroupsRef *string `json:"groupsRef,omitempty"` + // Group is a single group name for group membership queries // +optional Group *string `json:"group,omitempty"` diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index fc9ab8f..15e9740 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -35,6 +35,11 @@ func (in *Input) DeepCopyInto(out *Input) { } } } + if in.GroupsRef != nil { + in, out := &in.GroupsRef, &out.GroupsRef + *out = new(string) + **out = **in + } if in.Group != nil { in, out := &in.Group, &out.Group *out = new(string) diff --git a/package/input/msgraph.fn.crossplane.io_inputs.yaml b/package/input/msgraph.fn.crossplane.io_inputs.yaml index 2083ef8..d4c257a 100644 --- a/package/input/msgraph.fn.crossplane.io_inputs.yaml +++ b/package/input/msgraph.fn.crossplane.io_inputs.yaml @@ -41,6 +41,11 @@ spec: items: type: string type: array + groupsRef: + description: |- + GroupsRef is a reference to retrieve the group names (e.g., from status or context) + Overrides Groups field if used + type: string kind: description: |- Kind is a string value representing the REST resource this object represents. From d271aef1dded2729177fce122ecea38712b62897 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Apr 2025 01:14:57 +0200 Subject: [PATCH 06/11] Dynamic `usersRef` implementation * Tests * Implementation * Examples Signed-off-by: Yury Tsarev --- example/README.md | 10 + example/envconfig.yaml | 2 + .../user-validation-example-context-ref.yaml | 37 ++ .../user-validation-example-status-ref.yaml | 30 ++ example/xr.yaml | 2 + fn.go | 121 +++++-- fn_test.go | 315 ++++++++++++++++++ input/v1beta1/input.go | 5 + input/v1beta1/zz_generated.deepcopy.go | 5 + .../msgraph.fn.crossplane.io_inputs.yaml | 5 + 10 files changed, 501 insertions(+), 31 deletions(-) create mode 100644 example/user-validation-example-context-ref.yaml create mode 100644 example/user-validation-example-status-ref.yaml diff --git a/example/README.md b/example/README.md index 77602d3..6145779 100644 --- a/example/README.md +++ b/example/README.md @@ -42,6 +42,16 @@ Validate if specified Azure AD users exist: crossplane render xr.yaml user-validation-example.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc ``` +Dynamic `usersRef` variations: + +```shell +crossplane render xr.yaml user-validation-example-status-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc +``` + +```shell +crossplane render xr.yaml user-validation-example-context-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc --extra-resources=envconfig.yaml +``` + ### 2. Group Membership Get all members of a specified Azure AD group: diff --git a/example/envconfig.yaml b/example/envconfig.yaml index a03a123..d050042 100644 --- a/example/envconfig.yaml +++ b/example/envconfig.yaml @@ -7,3 +7,5 @@ data: name: test-fn-msgraph groups: - test-fn-msgraph + users: + - yury@upbound.io diff --git a/example/user-validation-example-context-ref.yaml b/example/user-validation-example-context-ref.yaml new file mode 100644 index 0000000..b192872 --- /dev/null +++ b/example/user-validation-example-context-ref.yaml @@ -0,0 +1,37 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: user-validation-example-context-ref +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: environmentConfigs + functionRef: + name: crossplane-contrib-function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + environmentConfigs: + - type: Reference + ref: + name: example-config + - step: validate-users + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: UserValidation + usersRef: context.[apiextensions.crossplane.io/environment].users + target: "status.validatedUsers" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds diff --git a/example/user-validation-example-status-ref.yaml b/example/user-validation-example-status-ref.yaml new file mode 100644 index 0000000..5b7bd21 --- /dev/null +++ b/example/user-validation-example-status-ref.yaml @@ -0,0 +1,30 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: user-validation-example-status-ref + annotations: + # Important: This function requires an Azure AD app registration with Microsoft Graph API permissions: + # - User.Read.All + # - Directory.Read.All +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: validate-users + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: UserValidation + usersRef: status.users + target: "status.validatedUsers" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds \ No newline at end of file diff --git a/example/xr.yaml b/example/xr.yaml index a59ceb0..4f755b9 100644 --- a/example/xr.yaml +++ b/example/xr.yaml @@ -9,3 +9,5 @@ status: name: test-fn-msgraph groups: - test-fn-msgraph + users: + - yury@upbound.io diff --git a/fn.go b/fn.go index 5edee25..eb3488f 100644 --- a/fn.go +++ b/fn.go @@ -887,30 +887,65 @@ func (f *Function) validateAndPrepareInput(_ context.Context, req *fnv1.RunFunct return true } -// processReferences handles resolving references like groupRef and groupsRef +// processReferences handles resolving references like groupRef, groupsRef, and usersRef func (f *Function) processReferences(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { - // Process groupRef if it exists for GroupMembership query type - if in.QueryType == "GroupMembership" && in.GroupRef != nil && *in.GroupRef != "" { - groupName, err := f.resolveGroupRef(req, in.GroupRef) - if err != nil { - response.Fatal(rsp, err) - return false - } - in.Group = &groupName - f.log.Info("Resolved GroupRef to group", "group", groupName, "groupRef", *in.GroupRef) + // Process references based on query type + switch in.QueryType { + case "GroupMembership": + return f.processGroupRef(req, in, rsp) + case "GroupObjectIDs": + return f.processGroupsRef(req, in, rsp) + case "UserValidation": + return f.processUsersRef(req, in, rsp) } + return true +} - // Process groupsRef if it exists for GroupObjectIDs query type - if in.QueryType == "GroupObjectIDs" && in.GroupsRef != nil && *in.GroupsRef != "" { - groupNames, err := f.resolveGroupsRef(req, in.GroupsRef) - if err != nil { - response.Fatal(rsp, err) - return false - } - in.Groups = groupNames - f.log.Info("Resolved GroupsRef to groups", "groupCount", len(groupNames), "groupsRef", *in.GroupsRef) +// processGroupRef handles resolving the groupRef reference for GroupMembership query type +func (f *Function) processGroupRef(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { + if in.GroupRef == nil || *in.GroupRef == "" { + return true } + groupName, err := f.resolveGroupRef(req, in.GroupRef) + if err != nil { + response.Fatal(rsp, err) + return false + } + in.Group = &groupName + f.log.Info("Resolved GroupRef to group", "group", groupName, "groupRef", *in.GroupRef) + return true +} + +// processGroupsRef handles resolving the groupsRef reference for GroupObjectIDs query type +func (f *Function) processGroupsRef(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { + if in.GroupsRef == nil || *in.GroupsRef == "" { + return true + } + + groupNames, err := f.resolveGroupsRef(req, in.GroupsRef) + if err != nil { + response.Fatal(rsp, err) + return false + } + in.Groups = groupNames + f.log.Info("Resolved GroupsRef to groups", "groupCount", len(groupNames), "groupsRef", *in.GroupsRef) + return true +} + +// processUsersRef handles resolving the usersRef reference for UserValidation query type +func (f *Function) processUsersRef(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { + if in.UsersRef == nil || *in.UsersRef == "" { + return true + } + + userNames, err := f.resolveUsersRef(req, in.UsersRef) + if err != nil { + response.Fatal(rsp, err) + return false + } + in.Users = userNames + f.log.Info("Resolved UsersRef to users", "userCount", len(userNames), "usersRef", *in.UsersRef) return true } @@ -1018,27 +1053,41 @@ func (f *Function) resolveFromContext(req *fnv1.RunFunctionRequest, refKey strin return value, nil } -// resolveGroupsRef resolves a list of group names from a reference in status or context. -func (f *Function) resolveGroupsRef(req *fnv1.RunFunctionRequest, groupsRef *string) ([]*string, error) { - if groupsRef == nil || *groupsRef == "" { - return nil, errors.New("empty groupsRef provided") +// resolveStringArrayRef resolves a list of string values from a reference in status or context +func (f *Function) resolveStringArrayRef(req *fnv1.RunFunctionRequest, ref *string, refType string) ([]*string, error) { + if ref == nil || *ref == "" { + return nil, errors.Errorf("empty %s provided", refType) } - refKey := *groupsRef + refKey := *ref + + var ( + result []*string + err error + ) // Use proper switch statement instead of if-else chain switch { case strings.HasPrefix(refKey, "status."): - return f.resolveGroupsFromStatus(req, refKey) + result, err = f.resolveStringArrayFromStatus(req, refKey) case strings.HasPrefix(refKey, "context."): - return f.resolveGroupsFromContext(req, refKey) + result, err = f.resolveStringArrayFromContext(req, refKey) default: - return nil, errors.Errorf("unsupported groupsRef format: %s", refKey) + return nil, errors.Errorf("unsupported %s format: %s", refType, refKey) + } + + // If we got an error and it contains "groupsRef" but we're looking for a different ref type, + // replace it with the correct ref type + if err != nil && refType != "groupsRef" && strings.Contains(err.Error(), "groupsRef") { + errMsg := err.Error() + return nil, errors.New(strings.ReplaceAll(errMsg, "groupsRef", refType)) } + + return result, err } -// resolveGroupsFromStatus resolves a list of group names from XR status -func (f *Function) resolveGroupsFromStatus(req *fnv1.RunFunctionRequest, refKey string) ([]*string, error) { +// resolveStringArrayFromStatus resolves a list of string values from XR status +func (f *Function) resolveStringArrayFromStatus(req *fnv1.RunFunctionRequest, refKey string) ([]*string, error) { xrStatus, _, err := f.getXRAndStatus(req) if err != nil { return nil, errors.Wrap(err, "cannot get XR status") @@ -1048,13 +1097,23 @@ func (f *Function) resolveGroupsFromStatus(req *fnv1.RunFunctionRequest, refKey return f.extractStringArrayFromMap(xrStatus, statusField, refKey) } -// resolveGroupsFromContext resolves a list of group names from function context -func (f *Function) resolveGroupsFromContext(req *fnv1.RunFunctionRequest, refKey string) ([]*string, error) { +// resolveStringArrayFromContext resolves a list of string values from function context +func (f *Function) resolveStringArrayFromContext(req *fnv1.RunFunctionRequest, refKey string) ([]*string, error) { contextMap := req.GetContext().AsMap() contextField := strings.TrimPrefix(refKey, "context.") return f.extractStringArrayFromMap(contextMap, contextField, refKey) } +// resolveGroupsRef resolves a list of group names from a reference in status or context +func (f *Function) resolveGroupsRef(req *fnv1.RunFunctionRequest, groupsRef *string) ([]*string, error) { + return f.resolveStringArrayRef(req, groupsRef, "groupsRef") +} + +// resolveUsersRef resolves a list of user names from a reference in status or context +func (f *Function) resolveUsersRef(req *fnv1.RunFunctionRequest, usersRef *string) ([]*string, error) { + return f.resolveStringArrayRef(req, usersRef, "usersRef") +} + // extractStringArrayFromMap extracts a string array from a map using nested key func (f *Function) extractStringArrayFromMap(dataMap map[string]interface{}, field, refKey string) ([]*string, error) { parts, err := ParseNestedKey(field) diff --git a/fn_test.go b/fn_test.go index 0a5afed..8416c28 100644 --- a/fn_test.go +++ b/fn_test.go @@ -619,6 +619,321 @@ func TestResolveGroupRef(t *testing.T) { } } +// TestResolveUsersRef tests the functionality of resolving usersRef from context or status +func TestResolveUsersRef(t *testing.T) { + var ( + xr = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` + creds = &fnv1.CredentialData{ + Data: map[string][]byte{ + "credentials": []byte(`{ +"clientId": "test-client-id", +"clientSecret": "test-client-secret", +"subscriptionId": "test-subscription-id", +"tenantId": "test-tenant-id" +}`), + }, + } + ) + + type args struct { + ctx context.Context + req *fnv1.RunFunctionRequest + } + type want struct { + rsp *fnv1.RunFunctionResponse + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "UsersRefFromStatus": { + reason: "The Function should resolve usersRef from XR status", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "usersRef": "status.users", + "target": "status.validatedUsers" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "status": { + "users": ["user1@example.com", "user2@example.com", "admin@example.onmicrosoft.com"] + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "UserValidation"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "status": { + "users": ["user1@example.com", "user2@example.com", "admin@example.onmicrosoft.com"], + "validatedUsers": [ + { + "id": "user-id-1", + "displayName": "User 1", + "userPrincipalName": "user1@example.com", + "mail": "user1@example.com" + }, + { + "id": "user-id-2", + "displayName": "User 2", + "userPrincipalName": "user2@example.com", + "mail": "user2@example.com" + }, + { + "id": "admin-id", + "displayName": "Admin User", + "userPrincipalName": "admin@example.onmicrosoft.com", + "mail": "admin@example.onmicrosoft.com" + } + ] + }}`), + }, + }, + }, + }, + }, + "UsersRefFromContext": { + reason: "The Function should resolve usersRef from context", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "usersRef": "context.users", + "target": "status.validatedUsers" + }`), + Context: resource.MustStructJSON(`{ + "users": ["user1@example.com", "user2@example.com", "admin@example.onmicrosoft.com"] + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "UserValidation"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Context: resource.MustStructJSON(`{ + "users": ["user1@example.com", "user2@example.com", "admin@example.onmicrosoft.com"] + }`), + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "cool-xr" + }, + "status": { + "validatedUsers": [ + { + "id": "user-id-1", + "displayName": "User 1", + "userPrincipalName": "user1@example.com", + "mail": "user1@example.com" + }, + { + "id": "user-id-2", + "displayName": "User 2", + "userPrincipalName": "user2@example.com", + "mail": "user2@example.com" + }, + { + "id": "admin-id", + "displayName": "Admin User", + "userPrincipalName": "admin@example.onmicrosoft.com", + "mail": "admin@example.onmicrosoft.com" + } + ] + }}`), + }, + }, + }, + }, + }, + "UsersRefNotFound": { + reason: "The Function should handle an error when usersRef cannot be resolved", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "usersRef": "context.nonexistent.value", + "target": "status.validatedUsers" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "cannot resolve usersRef: context.nonexistent.value not found", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "cool-xr" + } + }`), + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + // Create mock responders for each type of query + mockQuery := &MockGraphQuery{ + GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { + if in.QueryType == "UserValidation" { + if len(in.Users) == 0 { + return nil, errors.New("no users provided for validation") + } + + var results []interface{} + for _, user := range in.Users { + if user == nil { + continue + } + + var ( + userID string + displayName string + ) + + // Generate different test data based on user principal name + switch *user { + case "user1@example.com": + userID = "user-id-1" + displayName = "User 1" + case "user2@example.com": + userID = "user-id-2" + displayName = "User 2" + case "admin@example.onmicrosoft.com": + userID = "admin-id" + displayName = "Admin User" + default: + userID = "test-user-id" + displayName = "Test User" + } + + userMap := map[string]interface{}{ + "id": userID, + "displayName": displayName, + "userPrincipalName": *user, + "mail": *user, + } + results = append(results, userMap) + } + return results, nil + } + return nil, errors.Errorf("unsupported query type: %s", in.QueryType) + }, + } + + f := &Function{ + graphQuery: mockQuery, + log: logging.NewNopLogger(), + } + rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) + + if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) + } + }) + } +} + func TestRunFunction(t *testing.T) { var ( diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 7e83c64..5674ef0 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -27,6 +27,11 @@ type Input struct { // +optional Users []*string `json:"users,omitempty"` + // UsersRef is a reference to retrieve the user names (e.g., from status or context) + // Overrides Users field if used + // +optional + UsersRef *string `json:"usersRef,omitempty"` + // Groups is a list of group names for group object ID queries // +optional Groups []*string `json:"groups,omitempty"` diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index 15e9740..3baafe8 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -24,6 +24,11 @@ func (in *Input) DeepCopyInto(out *Input) { } } } + if in.UsersRef != nil { + in, out := &in.UsersRef, &out.UsersRef + *out = new(string) + **out = **in + } if in.Groups != nil { in, out := &in.Groups, &out.Groups *out = make([]*string, len(*in)) diff --git a/package/input/msgraph.fn.crossplane.io_inputs.yaml b/package/input/msgraph.fn.crossplane.io_inputs.yaml index d4c257a..d9f916b 100644 --- a/package/input/msgraph.fn.crossplane.io_inputs.yaml +++ b/package/input/msgraph.fn.crossplane.io_inputs.yaml @@ -80,6 +80,11 @@ spec: items: type: string type: array + usersRef: + description: |- + UsersRef is a reference to retrieve the user names (e.g., from status or context) + Overrides Users field if used + type: string required: - queryType - target From 66f94a2fa1dbdd370e1c4d7db6cbf11eac035d43 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Apr 2025 01:34:00 +0200 Subject: [PATCH 07/11] Dynamic 'servicePrincipalRef` implementation * Tests * Implementation * Examples Signed-off-by: Yury Tsarev --- example/README.md | 10 + example/envconfig.yaml | 2 + ...service-principal-example-context-ref.yaml | 37 ++ .../service-principal-example-status-ref.yaml | 30 ++ example/xr.yaml | 2 + fn.go | 25 +- fn_test.go | 320 ++++++++++++++++++ input/v1beta1/input.go | 5 + input/v1beta1/zz_generated.deepcopy.go | 5 + .../msgraph.fn.crossplane.io_inputs.yaml | 5 + 10 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 example/service-principal-example-context-ref.yaml create mode 100644 example/service-principal-example-status-ref.yaml diff --git a/example/README.md b/example/README.md index 6145779..a48f858 100644 --- a/example/README.md +++ b/example/README.md @@ -95,3 +95,13 @@ Get details of specified service principals: ```shell crossplane render xr.yaml service-principal-example.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc ``` + +Dynamic `servicePrinicpalsRef` variations: + +```shell +crossplane render xr.yaml service-principal-example-status-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc +``` + +```shell +crossplane render xr.yaml service-principal-example-context-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc --extra-resources=envconfig.yaml +``` diff --git a/example/envconfig.yaml b/example/envconfig.yaml index d050042..77ace3a 100644 --- a/example/envconfig.yaml +++ b/example/envconfig.yaml @@ -9,3 +9,5 @@ data: - test-fn-msgraph users: - yury@upbound.io + servicePrincipalNames: + - yury-upbound-oidc-provider diff --git a/example/service-principal-example-context-ref.yaml b/example/service-principal-example-context-ref.yaml new file mode 100644 index 0000000..de2514f --- /dev/null +++ b/example/service-principal-example-context-ref.yaml @@ -0,0 +1,37 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: service-principal-example-context-ref +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: environmentConfigs + functionRef: + name: crossplane-contrib-function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + environmentConfigs: + - type: Reference + ref: + name: example-config + - step: get-service-principal-details + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: ServicePrincipalDetails + servicePrincipalsRef: context.[apiextensions.crossplane.io/environment].servicePrincipalNames + target: "status.servicePrincipals" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds diff --git a/example/service-principal-example-status-ref.yaml b/example/service-principal-example-status-ref.yaml new file mode 100644 index 0000000..1167f32 --- /dev/null +++ b/example/service-principal-example-status-ref.yaml @@ -0,0 +1,30 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: service-principal-example-status-ref + annotations: + # Important: This function requires an Azure AD app registration with Microsoft Graph API permissions: + # - Application.Read.All + # - Directory.Read.All +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: get-service-principal-details + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: ServicePrincipalDetails + servicePrincipalsRef: status.servicePrincipalNames + target: "status.servicePrincipals" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds \ No newline at end of file diff --git a/example/xr.yaml b/example/xr.yaml index 4f755b9..d517fc0 100644 --- a/example/xr.yaml +++ b/example/xr.yaml @@ -11,3 +11,5 @@ status: - test-fn-msgraph users: - yury@upbound.io + servicePrincipalNames: + - yury-upbound-oidc-provider diff --git a/fn.go b/fn.go index eb3488f..1beaac9 100644 --- a/fn.go +++ b/fn.go @@ -887,7 +887,7 @@ func (f *Function) validateAndPrepareInput(_ context.Context, req *fnv1.RunFunct return true } -// processReferences handles resolving references like groupRef, groupsRef, and usersRef +// processReferences handles resolving references like groupRef, groupsRef, usersRef, and servicePrincipalsRef func (f *Function) processReferences(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { // Process references based on query type switch in.QueryType { @@ -897,6 +897,8 @@ func (f *Function) processReferences(req *fnv1.RunFunctionRequest, in *v1beta1.I return f.processGroupsRef(req, in, rsp) case "UserValidation": return f.processUsersRef(req, in, rsp) + case "ServicePrincipalDetails": + return f.processServicePrincipalsRef(req, in, rsp) } return true } @@ -949,6 +951,22 @@ func (f *Function) processUsersRef(req *fnv1.RunFunctionRequest, in *v1beta1.Inp return true } +// processServicePrincipalsRef handles resolving the servicePrincipalsRef reference for ServicePrincipalDetails query type +func (f *Function) processServicePrincipalsRef(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { + if in.ServicePrincipalsRef == nil || *in.ServicePrincipalsRef == "" { + return true + } + + spNames, err := f.resolveServicePrincipalsRef(req, in.ServicePrincipalsRef) + if err != nil { + response.Fatal(rsp, err) + return false + } + in.ServicePrincipals = spNames + f.log.Info("Resolved ServicePrincipalsRef to service principals", "spCount", len(spNames), "servicePrincipalsRef", *in.ServicePrincipalsRef) + return true +} + // executeAndProcessQuery executes the query and processes the results func (f *Function) executeAndProcessQuery(ctx context.Context, req *fnv1.RunFunctionRequest, in *v1beta1.Input, azureCreds map[string]string, rsp *fnv1.RunFunctionResponse) bool { // Execute the query @@ -1114,6 +1132,11 @@ func (f *Function) resolveUsersRef(req *fnv1.RunFunctionRequest, usersRef *strin return f.resolveStringArrayRef(req, usersRef, "usersRef") } +// resolveServicePrincipalsRef resolves a list of service principal names from a reference in status or context +func (f *Function) resolveServicePrincipalsRef(req *fnv1.RunFunctionRequest, servicePrincipalsRef *string) ([]*string, error) { + return f.resolveStringArrayRef(req, servicePrincipalsRef, "servicePrincipalsRef") +} + // extractStringArrayFromMap extracts a string array from a map using nested key func (f *Function) extractStringArrayFromMap(dataMap map[string]interface{}, field, refKey string) ([]*string, error) { parts, err := ParseNestedKey(field) diff --git a/fn_test.go b/fn_test.go index 8416c28..fba87a2 100644 --- a/fn_test.go +++ b/fn_test.go @@ -934,6 +934,326 @@ func TestResolveUsersRef(t *testing.T) { } } +// TestResolveServicePrincipalsRef tests the functionality of resolving servicePrincipalsRef from context or status +func TestResolveServicePrincipalsRef(t *testing.T) { + var ( + xr = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` + creds = &fnv1.CredentialData{ + Data: map[string][]byte{ + "credentials": []byte(`{ +"clientId": "test-client-id", +"clientSecret": "test-client-secret", +"subscriptionId": "test-subscription-id", +"tenantId": "test-tenant-id" +}`), + }, + } + ) + + type args struct { + ctx context.Context + req *fnv1.RunFunctionRequest + } + type want struct { + rsp *fnv1.RunFunctionResponse + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "ServicePrincipalsRefFromStatus": { + reason: "The Function should resolve servicePrincipalsRef from XR status", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipalsRef": "status.servicePrincipalNames", + "target": "status.servicePrincipals" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "status": { + "servicePrincipalNames": ["MyServiceApp", "ApiConnector", "yury-upbound-oidc-provider"] + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "ServicePrincipalDetails"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "status": { + "servicePrincipalNames": ["MyServiceApp", "ApiConnector", "yury-upbound-oidc-provider"], + "servicePrincipals": [ + { + "id": "sp-id-1", + "appId": "app-id-1", + "displayName": "MyServiceApp", + "description": "Service application" + }, + { + "id": "sp-id-2", + "appId": "app-id-2", + "displayName": "ApiConnector", + "description": "API connector application" + }, + { + "id": "sp-id-3", + "appId": "app-id-3", + "displayName": "yury-upbound-oidc-provider", + "description": "OIDC provider application" + } + ] + }}`), + }, + }, + }, + }, + }, + "ServicePrincipalsRefFromContext": { + reason: "The Function should resolve servicePrincipalsRef from context", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipalsRef": "context.servicePrincipalNames", + "target": "status.servicePrincipals" + }`), + Context: resource.MustStructJSON(`{ + "servicePrincipalNames": ["MyServiceApp", "ApiConnector", "yury-upbound-oidc-provider"] + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "ServicePrincipalDetails"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Context: resource.MustStructJSON(`{ + "servicePrincipalNames": ["MyServiceApp", "ApiConnector", "yury-upbound-oidc-provider"] + }`), + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "cool-xr" + }, + "status": { + "servicePrincipals": [ + { + "id": "sp-id-1", + "appId": "app-id-1", + "displayName": "MyServiceApp", + "description": "Service application" + }, + { + "id": "sp-id-2", + "appId": "app-id-2", + "displayName": "ApiConnector", + "description": "API connector application" + }, + { + "id": "sp-id-3", + "appId": "app-id-3", + "displayName": "yury-upbound-oidc-provider", + "description": "OIDC provider application" + } + ] + }}`), + }, + }, + }, + }, + }, + "ServicePrincipalsRefNotFound": { + reason: "The Function should handle an error when servicePrincipalsRef cannot be resolved", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipalsRef": "context.nonexistent.value", + "target": "status.servicePrincipals" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "cannot resolve servicePrincipalsRef: context.nonexistent.value not found", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "cool-xr" + } + }`), + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + // Create mock responders for each type of query + mockQuery := &MockGraphQuery{ + GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { + if in.QueryType == "ServicePrincipalDetails" { + if len(in.ServicePrincipals) == 0 { + return nil, errors.New("no service principal names provided") + } + + var results []interface{} + for i, sp := range in.ServicePrincipals { + if sp == nil { + continue + } + + var ( + spID string + appID string + description string + ) + + // Generate different test data based on service principal name + switch *sp { + case "MyServiceApp": + spID = "sp-id-1" + appID = "app-id-1" + description = "Service application" + case "ApiConnector": + spID = "sp-id-2" + appID = "app-id-2" + description = "API connector application" + case "yury-upbound-oidc-provider": + spID = "sp-id-3" + appID = "app-id-3" + description = "OIDC provider application" + default: + spID = fmt.Sprintf("sp-id-%d", i+1) + appID = fmt.Sprintf("app-id-%d", i+1) + description = "Generic service principal" + } + + spMap := map[string]interface{}{ + "id": spID, + "appId": appID, + "displayName": *sp, + "description": description, + } + results = append(results, spMap) + } + return results, nil + } + return nil, errors.Errorf("unsupported query type: %s", in.QueryType) + }, + } + + f := &Function{ + graphQuery: mockQuery, + log: logging.NewNopLogger(), + } + rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) + + if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) + } + }) + } +} + func TestRunFunction(t *testing.T) { var ( diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 5674ef0..3c3b5b1 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -54,6 +54,11 @@ type Input struct { // +optional ServicePrincipals []*string `json:"servicePrincipals,omitempty"` + // ServicePrincipalsRef is a reference to retrieve the service principal names (e.g., from status or context) + // Overrides ServicePrincipals field if used + // +optional + ServicePrincipalsRef *string `json:"servicePrincipalsRef,omitempty"` + // Target where to store the Query Result Target string `json:"target"` diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index 3baafe8..a12618f 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -66,6 +66,11 @@ func (in *Input) DeepCopyInto(out *Input) { } } } + if in.ServicePrincipalsRef != nil { + in, out := &in.ServicePrincipalsRef, &out.ServicePrincipalsRef + *out = new(string) + **out = **in + } if in.SkipQueryWhenTargetHasData != nil { in, out := &in.SkipQueryWhenTargetHasData, &out.SkipQueryWhenTargetHasData *out = new(bool) diff --git a/package/input/msgraph.fn.crossplane.io_inputs.yaml b/package/input/msgraph.fn.crossplane.io_inputs.yaml index d9f916b..02cb48f 100644 --- a/package/input/msgraph.fn.crossplane.io_inputs.yaml +++ b/package/input/msgraph.fn.crossplane.io_inputs.yaml @@ -66,6 +66,11 @@ spec: items: type: string type: array + servicePrincipalsRef: + description: |- + ServicePrincipalsRef is a reference to retrieve the service principal names (e.g., from status or context) + Overrides ServicePrincipals field if used + type: string skipQueryWhenTargetHasData: description: |- SkipQueryWhenTargetHasData controls whether to skip the query when the target already has data From 5847027e23a12eb5f36430d7c1becc9d7b2f8cdb Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Apr 2025 02:12:14 +0200 Subject: [PATCH 08/11] Update XRD status definition to handle e2e testing Signed-off-by: Yury Tsarev --- example/definition.yaml | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/example/definition.yaml b/example/definition.yaml index 7db1e37..6a3f3b7 100644 --- a/example/definition.yaml +++ b/example/definition.yaml @@ -27,15 +27,30 @@ spec: description: XRStatus defines the observed state of XR. type: object properties: - azResourceGraphQueryResult: - description: Freeform field containing query results from function-azresourcegraph + groupMembers: + description: Freeform field containing query results from function-msgraph + type: array + items: + type: object + x-kubernetes-preserve-unknown-fields: true + validatedUsers: + description: Freeform field containing query results from function-msgraph + type: array + items: + type: object + x-kubernetes-preserve-unknown-fields: true + groupObjectIDs: + description: Freeform field containing query results from function-msgraph + type: array + items: + type: object + x-kubernetes-preserve-unknown-fields: true + servicePrincipals: + description: Freeform field containing query results from function-msgraph type: array items: type: object x-kubernetes-preserve-unknown-fields: true - azResourceGraphQuery: - description: Freeform field containing query results from function-azresourcegraph - type: string required: - spec type: object From 305a77ed4e635bbe11925758e818ca7a5e17095d Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Apr 2025 13:00:33 +0200 Subject: [PATCH 09/11] Implement spec reference capability * Tests * Implementation * Examples Signed-off-by: Yury Tsarev --- example/README.md | 16 + .../group-membership-example-spec-ref.yaml | 35 ++ example/group-objectids-example-spec-ref.yaml | 31 ++ .../service-principal-example-spec-ref.yaml | 27 ++ example/user-validation-example-spec-ref.yaml | 30 ++ example/xr.yaml | 30 +- fn.go | 94 +++- fn_test.go | 406 +++++++++++++++++- 8 files changed, 655 insertions(+), 14 deletions(-) create mode 100644 example/group-membership-example-spec-ref.yaml create mode 100644 example/group-objectids-example-spec-ref.yaml create mode 100644 example/service-principal-example-spec-ref.yaml create mode 100644 example/user-validation-example-spec-ref.yaml diff --git a/example/README.md b/example/README.md index a48f858..91536ab 100644 --- a/example/README.md +++ b/example/README.md @@ -52,6 +52,10 @@ crossplane render xr.yaml user-validation-example-status-ref.yaml functions.yaml crossplane render xr.yaml user-validation-example-context-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc --extra-resources=envconfig.yaml ``` +```shell +crossplane render xr.yaml user-validation-example-spec-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc +``` + ### 2. Group Membership Get all members of a specified Azure AD group: @@ -70,6 +74,10 @@ crossplane render xr.yaml group-membership-example-status-ref.yaml functions.yam crossplane render xr.yaml group-membership-example-context-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc --extra-resources=envconfig.yaml ``` +```shell +crossplane render xr.yaml group-membership-example-spec-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc +``` + ### 3. Group Object IDs Get object IDs for specified Azure AD groups: @@ -88,6 +96,10 @@ crossplane render xr.yaml group-objectids-example-status-ref.yaml functions.yaml crossplane render xr.yaml group-objectids-example-context-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc --extra-resources=envconfig.yaml ``` +```shell +crossplane render xr.yaml group-objectids-example-spec-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc +``` + ### 4. Service Principal Details Get details of specified service principals: @@ -105,3 +117,7 @@ crossplane render xr.yaml service-principal-example-status-ref.yaml functions.ya ```shell crossplane render xr.yaml service-principal-example-context-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc --extra-resources=envconfig.yaml ``` + +```shell +crossplane render xr.yaml service-principal-example-spec-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc +``` diff --git a/example/group-membership-example-spec-ref.yaml b/example/group-membership-example-spec-ref.yaml new file mode 100644 index 0000000..b2a4020 --- /dev/null +++ b/example/group-membership-example-spec-ref.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: group-membership-example-spec-ref + annotations: + # Important: This function requires an Azure AD app registration with Microsoft Graph API permissions: + # - Group.Read.All + # - Directory.Read.All + # - User.Read.All (if groups contain users) + # - Application.Read.All (if groups contain service principals) +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: get-group-members + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: GroupMembership + # Using spec reference to get group name + groupRef: "spec.groupConfig.name" + # The function will automatically select standard fields: + # - id, displayName, mail, userPrincipalName, appId, description + target: "status.groupMembers" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds \ No newline at end of file diff --git a/example/group-objectids-example-spec-ref.yaml b/example/group-objectids-example-spec-ref.yaml new file mode 100644 index 0000000..abd9d4c --- /dev/null +++ b/example/group-objectids-example-spec-ref.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: group-objectids-example-spec-ref + annotations: + # Important: This function requires an Azure AD app registration with Microsoft Graph API permissions: + # - Group.Read.All + # - Directory.Read.All +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: get-group-objectids + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: GroupObjectIDs + # Using spec reference to get group names + groupsRef: "spec.groupConfig.names" + target: "status.groupObjectIDs" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds \ No newline at end of file diff --git a/example/service-principal-example-spec-ref.yaml b/example/service-principal-example-spec-ref.yaml new file mode 100644 index 0000000..140db31 --- /dev/null +++ b/example/service-principal-example-spec-ref.yaml @@ -0,0 +1,27 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: service-principal-example-spec-ref +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: get-service-principal-details + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: ServicePrincipalDetails + # Using spec reference to get service principal names + servicePrincipalsRef: "spec.servicePrincipalConfig.names" + target: "status.servicePrincipalDetails" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds \ No newline at end of file diff --git a/example/user-validation-example-spec-ref.yaml b/example/user-validation-example-spec-ref.yaml new file mode 100644 index 0000000..dfcfa87 --- /dev/null +++ b/example/user-validation-example-spec-ref.yaml @@ -0,0 +1,30 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: user-validation-example-spec-ref +# Important: This function example requires an Azure AD app registration with Microsoft Graph API permissions: +# - User.Read.All +# - Directory.Read.All +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: validate-user + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: UserValidation + # Using spec reference to get user emails + usersRef: "spec.userAccess.emails" + target: "status.validatedUsers" + skipQueryWhenTargetHasData: true + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: upbound-system + name: azure-account-creds \ No newline at end of file diff --git a/example/xr.yaml b/example/xr.yaml index d517fc0..41c5dd5 100644 --- a/example/xr.yaml +++ b/example/xr.yaml @@ -1,10 +1,36 @@ -# Replace this with your XR! +# Example XR with both spec references and status fields apiVersion: example.crossplane.io/v1 kind: XR metadata: name: example-xr -spec: {} +spec: + # Group config for group references + groupConfig: + # For single group reference (groupRef) + name: test-fn-msgraph + # For multiple group references (groupsRef) + names: + - "Developers" + - "All Engineering" + - "All Company" + - "test-fn-msgraph" + # User access config for user references + userAccess: + # For user validation (usersRef) + emails: + - "admin@example.onmicrosoft.com" + - "user@example.onmicrosoft.com" + - "yury@upbound.io" + # Service principal config for service principal references + servicePrincipalConfig: + # For service principal details (servicePrincipalsRef) + names: + - "MyServiceApp" + - "ApiConnector" + - "yury-upbound-oidc-provider" + status: + # For testing status.field references group: name: test-fn-msgraph groups: diff --git a/fn.go b/fn.go index 1beaac9..e584700 100644 --- a/fn.go +++ b/fn.go @@ -104,7 +104,23 @@ func (f *Function) parseInputAndCredentials(req *fnv1.RunFunctionRequest, rsp *f // getXRAndStatus retrieves status and desired XR, handling initialization if needed func (f *Function) getXRAndStatus(req *fnv1.RunFunctionRequest) (map[string]interface{}, *resource.Composite, error) { - // Get both observed and desired XR + // Get composite resources + oxr, dxr, err := f.getObservedAndDesired(req) + if err != nil { + return nil, nil, err + } + + // Initialize and copy data + f.initializeAndCopyData(oxr, dxr) + + // Get status + xrStatus := f.getStatusFromResources(oxr, dxr) + + return xrStatus, dxr, nil +} + +// getObservedAndDesired gets both observed and desired XR resources +func (f *Function) getObservedAndDesired(req *fnv1.RunFunctionRequest) (*resource.Composite, *resource.Composite, error) { oxr, err := request.GetObservedCompositeResource(req) if err != nil { return nil, nil, errors.Wrap(err, "cannot get observed composite resource") @@ -115,8 +131,11 @@ func (f *Function) getXRAndStatus(req *fnv1.RunFunctionRequest) (map[string]inte return nil, nil, errors.Wrap(err, "cannot get desired composite resource") } - xrStatus := make(map[string]interface{}) + return oxr, dxr, nil +} +// initializeAndCopyData initializes metadata and copies spec +func (f *Function) initializeAndCopyData(oxr, dxr *resource.Composite) { // Initialize dxr from oxr if needed if dxr.Resource.GetKind() == "" { dxr.Resource.SetAPIVersion(oxr.Resource.GetAPIVersion()) @@ -124,22 +143,35 @@ func (f *Function) getXRAndStatus(req *fnv1.RunFunctionRequest) (map[string]inte dxr.Resource.SetName(oxr.Resource.GetName()) } + // Copy spec from observed to desired XR to preserve it + xrSpec := make(map[string]interface{}) + if err := oxr.Resource.GetValueInto("spec", &xrSpec); err == nil && len(xrSpec) > 0 { + if err := dxr.Resource.SetValue("spec", xrSpec); err != nil { + f.log.Debug("Cannot set spec in desired XR", "error", err) + } + } +} + +// getStatusFromResources gets status from desired or observed XR +func (f *Function) getStatusFromResources(oxr, dxr *resource.Composite) map[string]interface{} { + xrStatus := make(map[string]interface{}) + // First try to get status from desired XR (pipeline changes) if dxr.Resource.GetKind() != "" { - err = dxr.Resource.GetValueInto("status", &xrStatus) + err := dxr.Resource.GetValueInto("status", &xrStatus) if err == nil && len(xrStatus) > 0 { - return xrStatus, dxr, nil + return xrStatus } f.log.Debug("Cannot get status from Desired XR or it's empty") } // Fallback to observed XR status - err = oxr.Resource.GetValueInto("status", &xrStatus) + err := oxr.Resource.GetValueInto("status", &xrStatus) if err != nil { f.log.Debug("Cannot get status from Observed XR") } - return xrStatus, dxr, nil + return xrStatus } // checkStatusTargetHasData checks if the status target has data. @@ -1026,7 +1058,7 @@ func (f *Function) checkContextTargetHasData(req *fnv1.RunFunctionRequest, in *v return false } -// resolveGroupRef resolves the group name from a reference in status or context. +// resolveGroupRef resolves the group name from a reference in spec, status or context. func (f *Function) resolveGroupRef(req *fnv1.RunFunctionRequest, groupRef *string) (string, error) { if groupRef == nil || *groupRef == "" { return "", errors.New("empty groupRef provided") @@ -1040,6 +1072,8 @@ func (f *Function) resolveGroupRef(req *fnv1.RunFunctionRequest, groupRef *strin return f.resolveFromStatus(req, refKey) case strings.HasPrefix(refKey, "context."): return f.resolveFromContext(req, refKey) + case strings.HasPrefix(refKey, "spec."): + return f.resolveFromSpec(req, refKey) default: return "", errors.Errorf("unsupported groupRef format: %s", refKey) } @@ -1071,7 +1105,30 @@ func (f *Function) resolveFromContext(req *fnv1.RunFunctionRequest, refKey strin return value, nil } -// resolveStringArrayRef resolves a list of string values from a reference in status or context +// resolveFromSpec resolves a reference from XR spec +func (f *Function) resolveFromSpec(req *fnv1.RunFunctionRequest, refKey string) (string, error) { + // Use getXRAndStatus to ensure spec is copied to desired XR + _, dxr, err := f.getXRAndStatus(req) + if err != nil { + return "", errors.Wrap(err, "cannot get XR status and desired XR") + } + + // Get spec from the desired XR (which now has the spec copied from observed) + xrSpec := make(map[string]interface{}) + err = dxr.Resource.GetValueInto("spec", &xrSpec) + if err != nil { + return "", errors.Wrap(err, "cannot get XR spec") + } + + specField := strings.TrimPrefix(refKey, "spec.") + value, ok := GetNestedKey(xrSpec, specField) + if !ok { + return "", errors.Errorf("cannot resolve groupRef: %s not found", refKey) + } + return value, nil +} + +// resolveStringArrayRef resolves a list of string values from a reference in spec, status or context func (f *Function) resolveStringArrayRef(req *fnv1.RunFunctionRequest, ref *string, refType string) ([]*string, error) { if ref == nil || *ref == "" { return nil, errors.Errorf("empty %s provided", refType) @@ -1090,6 +1147,8 @@ func (f *Function) resolveStringArrayRef(req *fnv1.RunFunctionRequest, ref *stri result, err = f.resolveStringArrayFromStatus(req, refKey) case strings.HasPrefix(refKey, "context."): result, err = f.resolveStringArrayFromContext(req, refKey) + case strings.HasPrefix(refKey, "spec."): + result, err = f.resolveStringArrayFromSpec(req, refKey) default: return nil, errors.Errorf("unsupported %s format: %s", refType, refKey) } @@ -1122,6 +1181,25 @@ func (f *Function) resolveStringArrayFromContext(req *fnv1.RunFunctionRequest, r return f.extractStringArrayFromMap(contextMap, contextField, refKey) } +// resolveStringArrayFromSpec resolves a list of string values from XR spec +func (f *Function) resolveStringArrayFromSpec(req *fnv1.RunFunctionRequest, refKey string) ([]*string, error) { + // Use getXRAndStatus to ensure spec is copied to desired XR + _, dxr, err := f.getXRAndStatus(req) + if err != nil { + return nil, errors.Wrap(err, "cannot get XR status and desired XR") + } + + // Get spec from the desired XR (which now has the spec copied from observed) + xrSpec := make(map[string]interface{}) + err = dxr.Resource.GetValueInto("spec", &xrSpec) + if err != nil { + return nil, errors.Wrap(err, "cannot get XR spec") + } + + specField := strings.TrimPrefix(refKey, "spec.") + return f.extractStringArrayFromMap(xrSpec, specField, refKey) +} + // resolveGroupsRef resolves a list of group names from a reference in status or context func (f *Function) resolveGroupsRef(req *fnv1.RunFunctionRequest, groupsRef *string) ([]*string, error) { return f.resolveStringArrayRef(req, groupsRef, "groupsRef") diff --git a/fn_test.go b/fn_test.go index fba87a2..e8eb210 100644 --- a/fn_test.go +++ b/fn_test.go @@ -30,7 +30,7 @@ func strPtr(s string) *string { return &s } -// TestResolveGroupsRef tests the functionality of resolving groupsRef from context or status +// TestResolveGroupsRef tests the functionality of resolving groupsRef from context, status, or spec func TestResolveGroupsRef(t *testing.T) { var ( xr = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` @@ -196,6 +196,94 @@ func TestResolveGroupsRef(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, + "status": { + "groupObjectIDs": [ + { + "id": "group-id-1", + "displayName": "Developers", + "description": "Development team" + }, + { + "id": "group-id-2", + "displayName": "Operations", + "description": "Operations team" + }, + { + "id": "group-id-3", + "displayName": "All Company", + "description": "All company group" + } + ] + }}`), + }, + }, + }, + }, + }, + "GroupsRefFromSpec": { + reason: "The Function should resolve groupsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groupsRef": "spec.groupConfig.groupNames", + "target": "status.groupObjectIDs" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": ["Developers", "Operations", "All Company"] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "GroupObjectIDs"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "groupNames": ["Developers", "Operations", "All Company"] + } + }, "status": { "groupObjectIDs": [ { @@ -262,6 +350,9 @@ func TestResolveGroupsRef(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -328,7 +419,7 @@ func TestResolveGroupsRef(t *testing.T) { } } -// TestResolveGroupRef tests the functionality of resolving groupRef from context or status +// TestResolveGroupRef tests the functionality of resolving groupRef from context, status, or spec func TestResolveGroupRef(t *testing.T) { var ( xr = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` @@ -500,6 +591,92 @@ func TestResolveGroupRef(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, + "status": { + "groupMembers": [ + { + "id": "user-id-1", + "displayName": "Test User 1", + "mail": "user1@example.com", + "type": "user", + "userPrincipalName": "user1@example.com" + }, + { + "id": "sp-id-1", + "displayName": "Test Service Principal", + "appId": "sp-app-id-1", + "type": "servicePrincipal" + } + ] + }}`), + }, + }, + }, + }, + }, + "GroupRefFromSpec": { + reason: "The Function should resolve groupRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupMembership", + "groupRef": "spec.groupConfig.name", + "target": "status.groupMembers" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "name": "Developers" + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "GroupMembership"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "groupConfig": { + "name": "Developers" + } + }, "status": { "groupMembers": [ { @@ -564,6 +741,9 @@ func TestResolveGroupRef(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -619,7 +799,7 @@ func TestResolveGroupRef(t *testing.T) { } } -// TestResolveUsersRef tests the functionality of resolving usersRef from context or status +// TestResolveUsersRef tests the functionality of resolving usersRef from context, status, or spec func TestResolveUsersRef(t *testing.T) { var ( xr = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` @@ -788,6 +968,97 @@ func TestResolveUsersRef(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, + "status": { + "validatedUsers": [ + { + "id": "user-id-1", + "displayName": "User 1", + "userPrincipalName": "user1@example.com", + "mail": "user1@example.com" + }, + { + "id": "user-id-2", + "displayName": "User 2", + "userPrincipalName": "user2@example.com", + "mail": "user2@example.com" + }, + { + "id": "admin-id", + "displayName": "Admin User", + "userPrincipalName": "admin@example.onmicrosoft.com", + "mail": "admin@example.onmicrosoft.com" + } + ] + }}`), + }, + }, + }, + }, + }, + "UsersRefFromSpec": { + reason: "The Function should resolve usersRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "usersRef": "spec.userAccess.emails", + "target": "status.validatedUsers" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": ["user1@example.com", "user2@example.com", "admin@example.onmicrosoft.com"] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "UserValidation"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "userAccess": { + "emails": ["user1@example.com", "user2@example.com", "admin@example.onmicrosoft.com"] + } + }, "status": { "validatedUsers": [ { @@ -857,6 +1128,9 @@ func TestResolveUsersRef(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -934,7 +1208,7 @@ func TestResolveUsersRef(t *testing.T) { } } -// TestResolveServicePrincipalsRef tests the functionality of resolving servicePrincipalsRef from context or status +// TestResolveServicePrincipalsRef tests the functionality of resolving servicePrincipalsRef from context, status, or spec func TestResolveServicePrincipalsRef(t *testing.T) { var ( xr = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` @@ -1103,6 +1377,97 @@ func TestResolveServicePrincipalsRef(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, + "status": { + "servicePrincipals": [ + { + "id": "sp-id-1", + "appId": "app-id-1", + "displayName": "MyServiceApp", + "description": "Service application" + }, + { + "id": "sp-id-2", + "appId": "app-id-2", + "displayName": "ApiConnector", + "description": "API connector application" + }, + { + "id": "sp-id-3", + "appId": "app-id-3", + "displayName": "yury-upbound-oidc-provider", + "description": "OIDC provider application" + } + ] + }}`), + }, + }, + }, + }, + }, + "ServicePrincipalsRefFromSpec": { + reason: "The Function should resolve servicePrincipalsRef from XR spec", + args: args{ + ctx: context.Background(), + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipalsRef": "spec.servicePrincipalConfig.names", + "target": "status.servicePrincipals" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": ["MyServiceApp", "ApiConnector", "yury-upbound-oidc-provider"] + } + } + }`), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + "azure-creds": { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "FunctionSuccess", + Status: fnv1.Status_STATUS_CONDITION_TRUE, + Reason: "Success", + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_NORMAL, + Message: `QueryType: "ServicePrincipalDetails"`, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "spec": { + "servicePrincipalConfig": { + "names": ["MyServiceApp", "ApiConnector", "yury-upbound-oidc-provider"] + } + }, "status": { "servicePrincipals": [ { @@ -1172,6 +1537,9 @@ func TestResolveServicePrincipalsRef(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -1398,6 +1766,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -1456,6 +1827,9 @@ func TestRunFunction(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, "status": { "validatedUsers": [ { @@ -1512,6 +1886,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -1570,6 +1947,9 @@ func TestRunFunction(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, "status": { "groupMembers": [ { @@ -1633,6 +2013,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -1691,6 +2074,9 @@ func TestRunFunction(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, "status": { "groupObjectIDs": [ { @@ -1751,6 +2137,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -1809,6 +2198,9 @@ func TestRunFunction(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, "status": { "servicePrincipals": [ { @@ -1865,6 +2257,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -2011,6 +2406,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, From 28c026c2f006d5e0213a24a1d439b5542674b7c6 Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Apr 2025 14:29:55 +0200 Subject: [PATCH 10/11] Extend XRD for e2e testing for spec refs Signed-off-by: Yury Tsarev --- example/definition.yaml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/example/definition.yaml b/example/definition.yaml index 6a3f3b7..f46ef00 100644 --- a/example/definition.yaml +++ b/example/definition.yaml @@ -23,6 +23,36 @@ spec: queryResourceType: description: resource type for az resource query construction type: string + groupConfig: + description: Configuration for group references + type: object + properties: + name: + description: Name of a single group to reference with groupRef + type: string + names: + description: List of group names to reference with groupsRef + type: array + items: + type: string + userAccess: + description: Configuration for user references + type: object + properties: + emails: + description: List of user emails to reference with usersRef + type: array + items: + type: string + servicePrincipalConfig: + description: Configuration for service principal references + type: object + properties: + names: + description: List of service principal names to reference with servicePrincipalsRef + type: array + items: + type: string status: description: XRStatus defines the observed state of XR. type: object From 95a083fa3eaca54a7636bea6b6ba5042d79e26ad Mon Sep 17 00:00:00 2001 From: Yury Tsarev Date: Wed, 30 Apr 2025 15:57:54 +0200 Subject: [PATCH 11/11] Remove typo in functions.yaml and extend README with Ref fields Signed-off-by: Yury Tsarev --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++ example/functions.yaml | 1 - 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4d152c5..17738c9 100644 --- a/README.md +++ b/README.md @@ -189,9 +189,13 @@ spec: |-------|------|-------------| | `queryType` | string | Required. Type of query to perform. Valid values: `UserValidation`, `GroupMembership`, `GroupObjectIDs`, `ServicePrincipalDetails` | | `users` | []string | List of user principal names (email IDs) for user validation | +| `usersRef` | string | Reference to resolve a list of user names from `spec`, `status` or `context` (e.g., `spec.userAccess.emails`) | | `group` | string | Single group name for group membership queries | +| `groupRef` | string | Reference to resolve a single group name from `spec`, `status` or `context` (e.g., `spec.groupConfig.name`) | | `groups` | []string | List of group names for group object ID queries | +| `groupsRef` | string | Reference to resolve a list of group names from `spec`, `status` or `context` (e.g., `spec.groupConfig.names`) | | `servicePrincipals` | []string | List of service principal names | +| `servicePrincipalsRef` | string | Reference to resolve a list of service principal names from `spec`, `status` or `context` (e.g., `spec.servicePrincipalConfig.names`) | | `target` | string | Required. Where to store the query results. Can be `status.` or `context.` | | `skipQueryWhenTargetHasData` | bool | Optional. When true, will skip the query if the target already has data | @@ -213,6 +217,50 @@ target: "context.results" target: "context.[apiextensions.crossplane.io/environment].results" ``` +## Using Reference Fields + +You can reference values from XR spec, status, or context instead of hardcoding them: + +### Using groupRef from spec + +```yaml +apiVersion: msgraph.fn.crossplane.io/v1alpha1 +kind: Input +queryType: GroupMembership +groupRef: "spec.groupConfig.name" # Get group name from XR spec +target: "status.groupMembers" +``` + +### Using groupsRef from spec + +```yaml +apiVersion: msgraph.fn.crossplane.io/v1alpha1 +kind: Input +queryType: GroupObjectIDs +groupsRef: "spec.groupConfig.names" # Get group names from XR spec +target: "status.groupObjectIDs" +``` + +### Using usersRef from spec + +```yaml +apiVersion: msgraph.fn.crossplane.io/v1alpha1 +kind: Input +queryType: UserValidation +usersRef: "spec.userAccess.emails" # Get user emails from XR spec +target: "status.validatedUsers" +``` + +### Using servicePrincipalsRef from spec + +```yaml +apiVersion: msgraph.fn.crossplane.io/v1alpha1 +kind: Input +queryType: ServicePrincipalDetails +servicePrincipalsRef: "spec.servicePrincipalConfig.names" # Get service principal names from XR spec +target: "status.servicePrincipals" +``` + ## References - [Microsoft Graph API Overview](https://learn.microsoft.com/en-us/graph/api/overview?view=graph-rest-1.0) diff --git a/example/functions.yaml b/example/functions.yaml index fde62e6..fa2f095 100644 --- a/example/functions.yaml +++ b/example/functions.yaml @@ -9,7 +9,6 @@ metadata: spec: package: xpkg.upbound.io/upbound/function-msgraph:v0.1.0 --- ---- apiVersion: pkg.crossplane.io/v1beta1 kind: Function metadata: