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/README.md b/example/README.md index 80ff73c..91536ab 100644 --- a/example/README.md +++ b/example/README.md @@ -42,6 +42,20 @@ 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 +``` + +```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: @@ -50,6 +64,20 @@ 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 +``` + +```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: @@ -58,6 +86,20 @@ 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 +``` + +```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: @@ -65,3 +107,17 @@ 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 +``` + +```shell +crossplane render xr.yaml service-principal-example-spec-ref.yaml functions.yaml --function-credentials=./secrets/azure-creds.yaml -rc +``` diff --git a/example/definition.yaml b/example/definition.yaml index 7db1e37..f46ef00 100644 --- a/example/definition.yaml +++ b/example/definition.yaml @@ -23,19 +23,64 @@ 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 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 diff --git a/example/envconfig.yaml b/example/envconfig.yaml new file mode 100644 index 0000000..77ace3a --- /dev/null +++ b/example/envconfig.yaml @@ -0,0 +1,13 @@ +apiVersion: apiextensions.crossplane.io/v1alpha1 +kind: EnvironmentConfig +metadata: + name: example-config +data: + group: + name: test-fn-msgraph + groups: + - test-fn-msgraph + users: + - yury@upbound.io + servicePrincipalNames: + - yury-upbound-oidc-provider diff --git a/example/functions.yaml b/example/functions.yaml index 759aa99..fa2f095 100644 --- a/example/functions.yaml +++ b/example/functions.yaml @@ -8,3 +8,10 @@ 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..75ea5f5 --- /dev/null +++ b/example/group-membership-example-context-ref.yaml @@ -0,0 +1,39 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: group-membership-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-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-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-membership-example-status-ref.yaml b/example/group-membership-example-status-ref.yaml new file mode 100644 index 0000000..b5d7660 --- /dev/null +++ b/example/group-membership-example-status-ref.yaml @@ -0,0 +1,28 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: group-membership-example-status-ref +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/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-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-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/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/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-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-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/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/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/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-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/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 25472b9..41c5dd5 100644 --- a/example/xr.yaml +++ b/example/xr.yaml @@ -1,6 +1,41 @@ -# 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: + - test-fn-msgraph + users: + - yury@upbound.io + servicePrincipalNames: + - yury-upbound-oidc-provider diff --git a/fn.go b/fn.go index 87fe3ce..e584700 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 + return rsp, nil //nolint:nilerr // errors are handled in rsp } - // Check if target is valid - if !f.isValidTarget(in.Target) { - response.Fatal(rsp, errors.Errorf("Unrecognized target field: %s", in.Target)) - return rsp, nil + // Validate and prepare input + if !f.validateAndPrepareInput(ctx, req, in, rsp) { + return rsp, nil // Early return if validation failed or query should be skipped } - // 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 - } - - // 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 - } - - // 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 @@ -121,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") @@ -132,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()) @@ -141,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. @@ -180,6 +195,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 +259,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,24 +389,35 @@ 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", + // 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) } - return result.GetValue(), nil + // Extract the members from the expanded result + var members []models.DirectoryObjectable + if group.GetMembers() != nil { + members = group.GetMembers() + } + + // Log basic information about the membership + if g.log != nil { + g.log.Debug("Retrieved group members", "groupName", groupName, "groupID", groupID, "memberCount", len(members)) + } + + return members, nil } // extractDisplayName attempts to extract the display name from a directory object @@ -446,82 +479,92 @@ 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"): - return "user" - case strings.Contains(odataType, "servicePrincipal"): - return "servicePrincipal" - case strings.Contains(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 - // Determine type from @odata.type - odataType, ok := g.extractStringProperty(additionalData, "@odata.type") - if !ok { - return memberType + // 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 } - // Get the member type - memberType = g.getMemberType(odataType) + // 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 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 } // Process the members members := make([]interface{}, 0, len(memberObjects)) - for _, member := range memberObjects { - memberID := member.GetId() - additionalData := member.GetAdditionalData() - - // Create basic member info - memberMap := map[string]interface{}{ - "id": memberID, - } - - // Extract display name - memberMap["displayName"] = g.extractDisplayName(member, *memberID) - - // Extract type-specific properties - memberType := g.extractMemberProperties(additionalData, memberMap) - memberMap["type"] = memberType - + memberMap := g.processMember(member) members = append(members, memberMap) } @@ -841,6 +884,137 @@ 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 references based on query type + if !f.processReferences(req, in, rsp) { + return false + } + + return true +} + +// 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 { + case "GroupMembership": + return f.processGroupRef(req, in, rsp) + case "GroupObjectIDs": + return f.processGroupsRef(req, in, rsp) + case "UserValidation": + return f.processUsersRef(req, in, rsp) + case "ServicePrincipalDetails": + return f.processServicePrincipalsRef(req, in, rsp) + } + return true +} + +// 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 +} + +// 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 + 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.") @@ -883,3 +1057,197 @@ func (f *Function) checkContextTargetHasData(req *fnv1.RunFunctionRequest, in *v } return false } + +// 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") + } + + 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) + case strings.HasPrefix(refKey, "spec."): + return f.resolveFromSpec(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 +} + +// 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) + } + + refKey := *ref + + var ( + result []*string + err error + ) + + // Use proper switch statement instead of if-else chain + switch { + case strings.HasPrefix(refKey, "status."): + 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) + } + + // 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 +} + +// 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") + } + + statusField := strings.TrimPrefix(refKey, "status.") + return f.extractStringArrayFromMap(xrStatus, statusField, refKey) +} + +// 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) +} + +// 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") +} + +// 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") +} + +// 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) + 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 9fc9287..e8eb210 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,1598 @@ func strPtr(s string) *string { return &s } +// 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}}` + 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" + }, + "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": [ + { + "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" + }, + "spec": { + "count": 2 + } + }`), + }, + }, + }, + }, + }, + } + + 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, status, or spec +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" + }, + "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": [ + { + "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" + }, + "spec": { + "count": 2 + } + }`), + }, + }, + }, + }, + }, + } + + 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) + } + }) + } +} + +// 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}}` + 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" + }, + "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": [ + { + "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" + }, + "spec": { + "count": 2 + } + }`), + }, + }, + }, + }, + }, + } + + 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) + } + }) + } +} + +// 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}}` + 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" + }, + "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": [ + { + "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" + }, + "spec": { + "count": 2 + } + }`), + }, + }, + }, + }, + }, + } + + 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 ( @@ -173,6 +1766,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -231,6 +1827,9 @@ func TestRunFunction(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, "status": { "validatedUsers": [ { @@ -287,6 +1886,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -345,6 +1947,9 @@ func TestRunFunction(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, "status": { "groupMembers": [ { @@ -408,6 +2013,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -466,6 +2074,9 @@ func TestRunFunction(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, "status": { "groupObjectIDs": [ { @@ -526,6 +2137,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -584,6 +2198,9 @@ func TestRunFunction(t *testing.T) { "metadata": { "name": "cool-xr" }, + "spec": { + "count": 2 + }, "status": { "servicePrincipals": [ { @@ -640,6 +2257,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, @@ -786,6 +2406,9 @@ func TestRunFunction(t *testing.T) { "kind": "XR", "metadata": { "name": "cool-xr" + }, + "spec": { + "count": 2 } }`), }, diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 8be3ec2..3c3b5b1 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -27,18 +27,38 @@ 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"` + // 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"` + // 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"` + // 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 d988bb6..a12618f 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)) @@ -35,11 +40,21 @@ 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) **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)) @@ -51,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 63d34f5..02cb48f 100644 --- a/package/input/msgraph.fn.crossplane.io_inputs.yaml +++ b/package/input/msgraph.fn.crossplane.io_inputs.yaml @@ -31,11 +31,21 @@ 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: 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. @@ -56,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 @@ -70,6 +85,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