diff --git a/NOTES.txt b/NOTES.txt index cfe6c74..9ae2128 100644 --- a/NOTES.txt +++ b/NOTES.txt @@ -1,9 +1,41 @@ -To get started: - -1. Replace `function-template-go` with your function in `go.mod`, - `package/crossplane.yaml`, and any Go imports. (You can also do this - automatically by running the `./init.sh ` script.) -2. Update `input/v1beta1/` to reflect your desired input (and run `go generate`) -3. Add your logic to `RunFunction` in `fn.go` -4. Add tests for your logic in `fn_test.go` -5. Update `README.md`, to be about your function! \ No newline at end of file +FUNCTION-APPROVE IMPLEMENTATION NOTES + +This function implements a serverless manual approval workflow for Crossplane, as requested by the customer. + +Key Components: + +1. Hash Calculation: + - Computes a hash of specified field (e.g., spec.resources) + - Supports md5, sha256, and sha512 algorithms + - Stores current hash in status.newHash + +2. Hash Comparison: + - Compares new hash with previously approved hash (status.oldHash) + - If different, indicates a change requiring approval + +3. Approval Mechanism: + - Uses status.approved (boolean) for manual approval + - When changes detected, pauses reconciliation until approved + - After approval, updates oldHash to newHash for future comparisons + +4. Reconciliation Pausing: + - Adds crossplane.io/paused annotation when changes detected + - Removes annotation when changes approved + - Prevents drift of managed resources + +5. Condition Reporting: + - Sets ApprovalRequired condition when changes detected + - Provides detailed information about changes and approval steps + +Usage Flow: +1. Resource created or updated (hash calculated & stored) +2. If not previously approved or hash changed: + - Set ApprovalRequired condition + - Add pause annotation +3. Admin approves by setting status.approved = true +4. Next reconciliation: + - Store approved hash + - Remove pause annotation + - Resume reconciliation + +No external dependencies required - all functionality implemented using Crossplane native mechanisms. \ No newline at end of file diff --git a/README.md b/README.md index 17738c9..48ee079 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ -# function-msgraph +# function-approve -A Crossplane composition function for querying the Microsoft Graph API. +A Crossplane Composition Function for implementing manual approval workflows. ## Overview -The `function-msgraph` provides read-only access to Microsoft Graph API endpoints, allowing Crossplane compositions to: +The `function-approve` provides a serverless approval mechanism at the Crossplane level that: -1. Validate Azure AD User Existence -2. Get Group Membership -3. Get Group Object IDs -4. Get Service Principal Details +1. Tracks changes to a specified field by computing a hash +2. Pauses reconciliation when changes are detected +3. Requires explicit approval before allowing reconciliation to continue +4. Prevents drift by storing previously approved state -The function supports throttling mitigation with the `skipQueryWhenTargetHasData` flag to avoid unnecessary API calls. +This function implements the approval workflow using entirely Crossplane-native mechanisms without external dependencies, making it lightweight and reliable. ## Usage @@ -21,250 +21,173 @@ Add the function to your Crossplane installation: apiVersion: pkg.crossplane.io/v1beta1 kind: Function metadata: - name: function-msgraph + name: function-approve spec: - package: xpkg.upbound.io/upbound/function-msgraph:v0.1.0 + package: xpkg.upbound.io/upbound/function-approve:v0.1.0 ``` -### Azure Credentials +### How It Works -Create an Azure service principal with appropriate permissions to access Microsoft Graph API: +1. When a resource is created or updated, `function-approve` calculates a hash of the monitored field (e.g., `spec.resources`). +2. The function stores this hash in `status.newHash` (or specified field). +3. If there's a previous approved hash (`status.oldHash`) and it doesn't match the new hash, reconciliation is paused. +4. An operator must approve the change by setting `status.approved = true`. +5. After approval, the new hash is stored as the approved hash, the approval flag is reset, and reconciliation resumes. +6. If a customer modifies an existing claim after approval, this will generate a new hash, requiring another approval. -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: azure-account-creds - namespace: crossplane-system -type: Opaque -stringData: - credentials: | - { - "clientId": "your-client-id", - "clientSecret": "your-client-secret", - "subscriptionId": "your-subscription-id", - "tenantId": "your-tenant-id" - } -``` - -The service principal needs the following Microsoft Graph API permissions: -- User.Read.All (for user validation) -- Group.Read.All (for group operations) -- Application.Read.All (for service principal details) - -## Examples - -### Validate Azure AD Users +## Example ```yaml apiVersion: example.crossplane.io/v1 kind: Composition metadata: - name: user-validation-example + name: approval-workflow-example spec: compositeTypeRef: apiVersion: example.crossplane.io/v1 kind: XR pipeline: - - step: validate-user + - step: require-approval functionRef: - name: function-msgraph + name: function-approve input: - apiVersion: msgraph.fn.crossplane.io/v1alpha1 + apiVersion: approve.fn.crossplane.io/v1alpha1 kind: Input - queryType: UserValidation - users: - - "user1@yourdomain.com" - - "user2@yourdomain.com" - target: "status.validatedUsers" - skipQueryWhenTargetHasData: true - credentials: - - name: azure-creds - source: Secret - secretRef: - namespace: crossplane-system - name: azure-account-creds + dataField: "spec.resources" # Field to monitor for changes + approvalField: "status.approved" + newHashField: "status.newHash" + oldHashField: "status.oldHash" + pauseAnnotation: "crossplane.io/paused" + detailedCondition: true ``` -### Get Group Membership +## Input Configuration Options -```yaml -apiVersion: example.crossplane.io/v1 -kind: Composition -metadata: - name: group-membership-example -spec: - compositeTypeRef: - apiVersion: example.crossplane.io/v1 - kind: XR - pipeline: - - step: get-group-members - functionRef: - name: function-msgraph - input: - apiVersion: msgraph.fn.crossplane.io/v1alpha1 - kind: Input - queryType: GroupMembership - group: "Developers" - # 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: crossplane-system - name: azure-account-creds -``` +| Field | Type | Description | +|-------|------|-------------| +| `dataField` | string | **Required**. Field to monitor for changes (e.g., `spec.resources`) | +| `hashAlgorithm` | string | Algorithm to use for hash calculation. Supported values: `md5`, `sha256`, `sha512`. Default: `sha256` | +| `approvalField` | string | Status field to check for approval. Default: `status.approved` | +| `oldHashField` | string | Status field to store previously approved hash. Default: `status.oldHash` | +| `newHashField` | string | Status field to store current hash. Default: `status.newHash` | +| `pauseAnnotation` | string | Annotation to use for pausing reconciliation. Default: `crossplane.io/paused` | +| `detailedCondition` | bool | Whether to add detailed information to conditions. Default: `true` | +| `approvalMessage` | string | Message to display when approval is required. Default: `Changes detected. Approval required.` | -### Get Group Object IDs +## Using with Custom Resources -```yaml -apiVersion: example.crossplane.io/v1 -kind: Composition -metadata: - name: group-objectids-example -spec: - compositeTypeRef: - apiVersion: example.crossplane.io/v1 - kind: XR - pipeline: - - step: get-group-objectids - functionRef: - name: function-msgraph - input: - apiVersion: msgraph.fn.crossplane.io/v1alpha1 - kind: Input - queryType: GroupObjectIDs - groups: - - "Developers" - - "Operations" - - "Security" - target: "status.groupObjectIDs" - skipQueryWhenTargetHasData: true - credentials: - - name: azure-creds - source: Secret - secretRef: - namespace: crossplane-system - name: azure-account-creds -``` - -### Get Service Principal Details +Your XR definition must include the status fields used by the function: ```yaml -apiVersion: example.crossplane.io/v1 -kind: Composition +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition metadata: - name: service-principal-example + name: xapprovals.example.crossplane.io spec: - compositeTypeRef: - apiVersion: example.crossplane.io/v1 - kind: XR - pipeline: - - step: get-service-principal-details - functionRef: - name: function-msgraph - input: - apiVersion: msgraph.fn.crossplane.io/v1alpha1 - kind: Input - queryType: ServicePrincipalDetails - servicePrincipals: - - "MyServiceApp" - - "ApiConnector" - target: "status.servicePrincipalDetails" - skipQueryWhenTargetHasData: true - credentials: - - name: azure-creds - source: Secret - secretRef: - namespace: crossplane-system - name: azure-account-creds + group: example.crossplane.io + names: + kind: XApproval + plural: xapprovals + versions: + - name: v1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + resources: + type: object + x-kubernetes-preserve-unknown-fields: true + status: + type: object + properties: + approved: + type: boolean + description: "Whether the current changes are approved" + oldHash: + type: string + description: "Hash of previously approved resource state" + newHash: + type: string + description: "Hash of current resource state" ``` -## Input Configuration Options - -| Field | Type | Description | -|-------|------|-------------| -| `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 | - -## Result Targets +## Approving Changes -Results can be stored in either XR Status or Composition Context: +When changes are detected, the resource's reconciliation is paused, and its condition will show `ApprovalRequired` status. To approve the changes, patch the resource's status: ```yaml -# Store in XR Status -target: "status.results" - -# Store in nested XR Status -target: "status.nested.field.results" - -# Store in Composition Context -target: "context.results" - -# Store in Environment -target: "context.[apiextensions.crossplane.io/environment].results" +kubectl patch xapproval example --type=merge --subresource=status -p '{"status":{"approved":true}}' ``` -## Using Reference Fields +After approval, the function will: +1. Record the new state as the approved state +2. Remove the pause annotation +3. Allow reconciliation to continue -You can reference values from XR spec, status, or context instead of hardcoding them: +## Resetting Approval State -### Using groupRef from spec +If you need to reset the approval state, you can clear the `oldHash` field: ```yaml -apiVersion: msgraph.fn.crossplane.io/v1alpha1 -kind: Input -queryType: GroupMembership -groupRef: "spec.groupConfig.name" # Get group name from XR spec -target: "status.groupMembers" +kubectl patch xapproval example --type=merge --subresource=status -p '{"status":{"oldHash":""}}' ``` -### Using groupsRef from spec +## Security Considerations -```yaml -apiVersion: msgraph.fn.crossplane.io/v1alpha1 -kind: Input -queryType: GroupObjectIDs -groupsRef: "spec.groupConfig.names" # Get group names from XR spec -target: "status.groupObjectIDs" -``` +- Use RBAC to control who can approve changes by restricting access to the status subresource +- Consider implementing additional verification steps or multi-party approval in your workflow -### Using usersRef from spec +## Complete Example -```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 +Here's a complete example of a composition using `function-approve`: ```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" +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: approval-required-cluster +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XCluster + pipeline: + - step: require-approval + functionRef: + name: function-approve + input: + apiVersion: approve.fn.crossplane.io/v1alpha1 + kind: Input + dataField: "spec.resources" + approvalField: "status.approved" + hashAlgorithm: "sha256" + newHashField: "status.currentHash" + oldHashField: "status.approvedHash" + detailedCondition: true + approvalMessage: "Cluster changes require admin approval" + - step: create-resources + functionRef: + name: function-patch-and-transform + input: + apiVersion: pt.fn.crossplane.io/v1alpha1 + kind: Resources + resources: + - name: cluster + base: + apiVersion: eks.aws.upbound.io/v1beta1 + kind: Cluster + spec: + forProvider: + region: us-west-2 + version: "1.25" ``` -## References +## Metrics and Monitoring -- [Microsoft Graph API Overview](https://learn.microsoft.com/en-us/graph/api/overview?view=graph-rest-1.0) -- [User validation](https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0&tabs=go) -- [Group membership](https://learn.microsoft.com/en-us/graph/api/group-list-members?view=graph-rest-1.0&tabs=go) -- [Group listing](https://learn.microsoft.com/en-us/graph/api/group-list?view=graph-rest-1.0&tabs=go) -- [Service principal listing](https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list?view=graph-rest-1.0&tabs=http) +- Monitor resources with the `ApprovalRequired` condition to track pending approvals +- Implement alerting based on condition status for timely approvals +- Consider tracking approval times and frequencies to optimize your workflows \ No newline at end of file diff --git a/example/claim.yaml b/example/claim.yaml new file mode 100644 index 0000000..e205ee9 --- /dev/null +++ b/example/claim.yaml @@ -0,0 +1,14 @@ +apiVersion: example.crossplane.io/v1 +kind: XApproval +metadata: + name: approval-example +spec: + resources: + data: + key1: value1 + key2: value2 + config: | + # Example configuration + port: 8080 + debug: true + environment: development \ No newline at end of file diff --git a/example/composition.yaml b/example/composition.yaml new file mode 100644 index 0000000..03c2db1 --- /dev/null +++ b/example/composition.yaml @@ -0,0 +1,43 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xapprovals.example.crossplane.io +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XApproval + pipeline: + - step: require-approval + functionRef: + name: function-approve + input: + apiVersion: approve.fn.crossplane.io/v1alpha1 + kind: Input + dataField: "spec.resources" + approvalField: "status.approved" + newHashField: "status.newHash" + oldHashField: "status.oldHash" + pauseAnnotation: "crossplane.io/paused" + detailedCondition: true + approvalMessage: "Changes detected. Administrative approval required." + - step: render-resources + functionRef: + name: function-patch-and-transform + input: + apiVersion: pt.fn.crossplane.io/v1alpha1 + kind: Resources + resources: + - name: example-configmap + base: + apiVersion: v1 + kind: ConfigMap + metadata: + name: example-configmap + data: + key1: value1 + patches: + - type: FromCompositeFieldPath + fromFieldPath: spec.resources.data + toFieldPath: data + policy: + fromFieldPath: Required \ No newline at end of file diff --git a/example/definition.yaml b/example/definition.yaml index f46ef00..8320691 100644 --- a/example/definition.yaml +++ b/example/definition.yaml @@ -1,95 +1,47 @@ apiVersion: apiextensions.crossplane.io/v1 kind: CompositeResourceDefinition metadata: - name: xrs.example.crossplane.io + name: xapprovals.example.crossplane.io spec: group: example.crossplane.io names: categories: - crossplane - kind: XR - plural: xrs + kind: XApproval + plural: xapprovals versions: - name: v1 referenceable: true schema: openAPIV3Schema: - description: XR is the Schema for the XR API. + description: XApproval is the Schema for the XApproval API. properties: spec: - description: XRSpec defines the desired state of XR. + description: XApprovalSpec defines the desired state of XApproval. type: object properties: - 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 + resources: + description: Resources that require approval before provisioning type: object - properties: - names: - description: List of service principal names to reference with servicePrincipalsRef - type: array - items: - type: string + x-kubernetes-preserve-unknown-fields: true status: - description: XRStatus defines the observed state of XR. + description: XApprovalStatus defines the observed state of XApproval. type: object properties: - 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 + approved: + description: Whether the current changes are approved + type: boolean + oldHash: + description: Hash of previously approved resource state + type: string + newHash: + description: Hash of current resource state + type: string + resourceStatus: + description: Status of the underlying resources + type: object x-kubernetes-preserve-unknown-fields: true required: - spec type: object - served: true -status: - controllers: - compositeResourceClaimType: - apiVersion: "" - kind: "" - compositeResourceType: - apiVersion: "" - kind: "" + served: true \ No newline at end of file diff --git a/example/functions.yaml b/example/functions.yaml index b8f77ab..157ef14 100644 --- a/example/functions.yaml +++ b/example/functions.yaml @@ -2,16 +2,15 @@ apiVersion: pkg.crossplane.io/v1beta1 kind: Function metadata: - name: function-msgraph + name: function-approve annotations: - # This tells crossplane beta render to connect to the function locally. render.crossplane.io/runtime: Development spec: - package: xpkg.upbound.io/upbound/function-msgraph:v0.2.0 + package: xpkg.upbound.io/upbound/function-approve:v0.1.0 --- apiVersion: pkg.crossplane.io/v1beta1 kind: Function metadata: - name: crossplane-contrib-function-environment-configs + name: function-patch-and-transform spec: - package: xpkg.upbound.io/crossplane-contrib/function-environment-configs:v0.3.0 + package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.9.0 \ No newline at end of file diff --git a/fn.go b/fn.go index e584700..dd87f52 100644 --- a/fn.go +++ b/fn.go @@ -2,41 +2,27 @@ package main import ( "context" + "crypto/md5" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" "encoding/json" - "fmt" - "reflect" - "regexp" + "hash" "strings" - "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - azauth "github.com/microsoft/kiota-authentication-azure-go" - msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go" - "github.com/microsoftgraph/msgraph-sdk-go/groups" - "github.com/microsoftgraph/msgraph-sdk-go/models" - "github.com/microsoftgraph/msgraph-sdk-go/serviceprincipals" - "github.com/microsoftgraph/msgraph-sdk-go/users" - "github.com/upbound/function-msgraph/input/v1beta1" - "google.golang.org/protobuf/types/known/structpb" - "github.com/crossplane/crossplane-runtime/pkg/errors" "github.com/crossplane/crossplane-runtime/pkg/logging" fnv1 "github.com/crossplane/function-sdk-go/proto/v1" "github.com/crossplane/function-sdk-go/request" "github.com/crossplane/function-sdk-go/resource" "github.com/crossplane/function-sdk-go/response" + "github.com/upbound/function-approve/input/v1beta1" ) -// GraphQueryInterface defines the methods required for querying Microsoft Graph API. -type GraphQueryInterface interface { - graphQuery(ctx context.Context, azureCreds map[string]string, in *v1beta1.Input) (interface{}, error) -} - -// Function returns whatever response you ask it to. +// Function implements the manual approval workflow function. type Function struct { fnv1.UnimplementedFunctionRunnerServiceServer - graphQuery GraphQueryInterface - log logging.Logger } @@ -46,60 +32,144 @@ func (f *Function) RunFunction(ctx context.Context, req *fnv1.RunFunctionRequest rsp := response.To(req, response.DefaultTTL) + // Parse input and get defaults + in, err := f.parseInput(req, rsp) + if err != nil { + return rsp, nil //nolint:nilerr // errors are handled in rsp + } + // 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 } - // Parse input and get credentials - in, azureCreds, err := f.parseInputAndCredentials(req, rsp) + // Extract data to hash + dataToHash, err := f.extractDataToHash(req, in, rsp) if err != nil { return rsp, nil //nolint:nilerr // errors are handled in rsp } - // Validate and prepare input - if !f.validateAndPrepareInput(ctx, req, in, rsp) { - return rsp, nil // Early return if validation failed or query should be skipped + // Calculate hash + newHash := f.calculateHash(dataToHash, in) + + // Get old hash from status + oldHash, err := f.getOldHash(req, in, rsp) + if err != nil { + return rsp, nil //nolint:nilerr // errors are handled in rsp } - // Execute the query and process results - if !f.executeAndProcessQuery(ctx, req, in, azureCreds, rsp) { - return rsp, nil // Error already handled in response + // Save new hash to status + if err := f.saveNewHash(req, in, newHash, rsp); err != nil { + return rsp, nil //nolint:nilerr // errors are handled in rsp + } + + // Check approval status + approved, err := f.checkApprovalStatus(req, in, rsp) + if err != nil { + return rsp, nil //nolint:nilerr // errors are handled in rsp + } + + // Reconciliation pause logic + if !approved || (oldHash != "" && oldHash != newHash) { + // Changes detected and not approved, pause reconciliation + if err := f.pauseReconciliation(req, in, rsp); err != nil { + return rsp, nil //nolint:nilerr // errors are handled in rsp + } + + // Set condition to show approval is needed + msg := "Changes detected. Approval required." + if in.ApprovalMessage != nil { + msg = *in.ApprovalMessage + } + + condition := response.ConditionFalse(rsp, "ApprovalRequired", "WaitingForApproval"). + WithMessage(msg). + TargetCompositeAndClaim() + + if in.DetailedCondition != nil && *in.DetailedCondition { + // Add detailed information about what changed and what needs approval + detailedMsg := msg + "\nCurrent hash: " + newHash + "\n" + + "Approved hash: " + oldHash + "\n" + + "Approve this change by setting " + *in.ApprovalField + " to true" + condition = condition.WithMessage(detailedMsg) + } + + // Return early, pausing reconciliation + return rsp, nil + } + + // If we got here, the changes are approved or there are no changes + // Update the old hash with the new hash + if err := f.saveOldHash(req, in, newHash, rsp); err != nil { + return rsp, nil //nolint:nilerr // errors are handled in rsp + } + + // Remove pause annotation if it exists + if err := f.resumeReconciliation(req, in, rsp); err != nil { + return rsp, nil //nolint:nilerr // errors are handled in rsp } // Set success condition response.ConditionTrue(rsp, "FunctionSuccess", "Success"). + WithMessage("Approved successfully"). TargetCompositeAndClaim() return rsp, nil } -// parseInputAndCredentials parses the input and gets the credentials. -func (f *Function) parseInputAndCredentials(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctionResponse) (*v1beta1.Input, map[string]string, error) { +// parseInput parses the function input and sets defaults. +func (f *Function) parseInput(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctionResponse) (*v1beta1.Input, error) { in := &v1beta1.Input{} if err := request.GetInput(req, in); err != nil { - response.ConditionFalse(rsp, "FunctionSuccess", "InternalError"). - WithMessage("Something went wrong."). - TargetCompositeAndClaim() + response.Fatal(rsp, errors.Wrap(err, "cannot get Function input")) + return nil, err + } - response.Warning(rsp, errors.New("something went wrong")). - TargetCompositeAndClaim() + // Set default values if not provided + if in.HashAlgorithm == nil { + defaultAlgo := "sha256" + in.HashAlgorithm = &defaultAlgo + } - response.Fatal(rsp, errors.Wrapf(err, "cannot get Function input from %T", req)) - return nil, nil, err + if in.ApprovalField == nil { + defaultField := "status.approved" + in.ApprovalField = &defaultField } - azureCreds, err := getCreds(req) - if err != nil { - response.Fatal(rsp, err) - return nil, nil, err + if in.OldHashField == nil { + defaultField := "status.oldHash" + in.OldHashField = &defaultField + } + + if in.NewHashField == nil { + defaultField := "status.newHash" + in.NewHashField = &defaultField } - if f.graphQuery == nil { - f.graphQuery = &GraphQuery{} + if in.PauseAnnotation == nil { + defaultAnnotation := "crossplane.io/paused" + in.PauseAnnotation = &defaultAnnotation } - return in, azureCreds, nil + if in.DetailedCondition == nil { + defaultValue := true + in.DetailedCondition = &defaultValue + } + + return in, nil +} + +// 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 } // getXRAndStatus retrieves status and desired XR, handling initialization if needed @@ -174,505 +244,51 @@ func (f *Function) getStatusFromResources(oxr, dxr *resource.Composite) map[stri return xrStatus } -// checkStatusTargetHasData checks if the status target has data. -func (f *Function) checkStatusTargetHasData(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { - xrStatus, _, err := f.getXRAndStatus(req) - if err != nil { - response.Fatal(rsp, err) - return true - } - - statusField := strings.TrimPrefix(in.Target, "status.") - if hasData, _ := targetHasData(xrStatus, statusField); hasData { - f.log.Info("Target already has data, skipping query", "target", in.Target) - response.ConditionTrue(rsp, "FunctionSkip", "SkippedQuery"). - WithMessage("Target already has data, skipped query to avoid throttling"). - TargetCompositeAndClaim() - return true - } - return false -} - -// 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) +// propagateDesiredXR ensures the desired XR is properly propagated without changing existing data +func (f *Function) propagateDesiredXR(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctionResponse) error { + xrStatus, dxr, err := f.getXRAndStatus(req) if err != nil { response.Fatal(rsp, err) - f.log.Info("FAILURE: ", "failure", fmt.Sprint(err)) - return nil, err + return err } - // Print the obtained query results - f.log.Info("Query Type:", "queryType", in.QueryType) - f.log.Info("Results:", "results", fmt.Sprint(results)) - response.Normalf(rsp, "QueryType: %q", in.QueryType) - - return results, nil -} - -// processResults processes the query results. -func (f *Function) processResults(req *fnv1.RunFunctionRequest, in *v1beta1.Input, results interface{}, rsp *fnv1.RunFunctionResponse) error { - switch { - case strings.HasPrefix(in.Target, "status."): - err := f.putQueryResultToStatus(req, rsp, in, results) - if err != nil { - response.Fatal(rsp, err) - return err - } - case strings.HasPrefix(in.Target, "context."): - err := putQueryResultToContext(req, rsp, in, results, f) - if err != nil { - response.Fatal(rsp, err) + // Write any existing status back to dxr + if len(xrStatus) > 0 { + if err := dxr.Resource.SetValue("status", xrStatus); err != nil { + f.log.Info("Error setting status in Desired XR", "error", err) return err } - default: - // This should never happen because we check for valid targets earlier - response.Fatal(rsp, errors.Errorf("Unrecognized target field: %s", in.Target)) - return errors.New("unrecognized target field") - } - return nil -} - -func getCreds(req *fnv1.RunFunctionRequest) (map[string]string, error) { - var azureCreds map[string]string - rawCreds := req.GetCredentials() - - if credsData, ok := rawCreds["azure-creds"]; ok { - credsData := credsData.GetCredentialData().GetData() - if credsJSON, ok := credsData["credentials"]; ok { - err := json.Unmarshal(credsJSON, &azureCreds) - if err != nil { - return nil, errors.Wrap(err, "cannot parse json credentials") - } - } - } else { - return nil, errors.New("failed to get azure-creds credentials") - } - - return azureCreds, nil -} - -// GraphQuery is a concrete implementation of the GraphQueryInterface -// that interacts with Microsoft Graph API. -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) { - tenantID := azureCreds["tenantId"] - clientID := azureCreds["clientId"] - clientSecret := azureCreds["clientSecret"] - - // Create Azure credential for Microsoft Graph - cred, err := azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, nil) - if err != nil { - return nil, errors.Wrap(err, "failed to obtain credentials") - } - - // Create authentication provider - authProvider, err := azauth.NewAzureIdentityAuthenticationProviderWithScopes(cred, []string{"https://graph.microsoft.com/.default"}) - if err != nil { - return nil, errors.Wrap(err, "failed to create auth provider") - } - - // Create adapter - adapter, err := msgraphsdk.NewGraphRequestAdapter(authProvider) - if err != nil { - return nil, errors.Wrap(err, "failed to create graph adapter") - } - - // Initialize Microsoft Graph client - return msgraphsdk.NewGraphServiceClient(adapter), nil -} - -// graphQuery is a concrete implementation that interacts with Microsoft Graph API. -func (g *GraphQuery) graphQuery(ctx context.Context, azureCreds map[string]string, in *v1beta1.Input) (interface{}, error) { - // Create the Microsoft Graph client - client, err := g.createGraphClient(azureCreds) - if err != nil { - return nil, err } - // Route based on query type - switch in.QueryType { - case "UserValidation": - return g.validateUsers(ctx, client, in) - case "GroupMembership": - return g.getGroupMembers(ctx, client, in) - case "GroupObjectIDs": - return g.getGroupObjectIDs(ctx, client, in) - case "ServicePrincipalDetails": - return g.getServicePrincipalDetails(ctx, client, in) - default: - return nil, errors.Errorf("unsupported query type: %s", in.QueryType) - } -} - -// validateUsers validates if the provided user principal names (emails) exist -func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { - if len(in.Users) == 0 { - return nil, errors.New("no users provided for validation") - } - - var results []interface{} - - for _, userPrincipalName := range in.Users { - if userPrincipalName == nil { - continue - } - - // Create request configuration - requestConfig := &users.UsersRequestBuilderGetRequestConfiguration{ - QueryParameters: &users.UsersRequestBuilderGetQueryParameters{}, - } - - // Build filter expression - filterValue := fmt.Sprintf("userPrincipalName eq '%s'", *userPrincipalName) - requestConfig.QueryParameters.Filter = &filterValue - - // Use standard fields for user validation - requestConfig.QueryParameters.Select = []string{"id", "displayName", "userPrincipalName", "mail"} - - // Execute the query - result, err := client.Users().Get(ctx, requestConfig) - if err != nil { - return nil, errors.Wrapf(err, "failed to validate user %s", *userPrincipalName) - } - - // Process results - if result.GetValue() != nil { - for _, user := range result.GetValue() { - userMap := map[string]interface{}{ - "id": user.GetId(), - "displayName": user.GetDisplayName(), - "userPrincipalName": user.GetUserPrincipalName(), - "mail": user.GetMail(), - } - results = append(results, userMap) - } - } - } - - return results, nil -} - -// findGroupByName finds a group by its display name and returns its ID -func (g *GraphQuery) findGroupByName(ctx context.Context, client *msgraphsdk.GraphServiceClient, groupName string) (*string, error) { - // Create filter by displayName - filterValue := fmt.Sprintf("displayName eq '%s'", groupName) - groupRequestConfig := &groups.GroupsRequestBuilderGetRequestConfiguration{ - QueryParameters: &groups.GroupsRequestBuilderGetQueryParameters{ - Filter: &filterValue, - }, - } - - // Query for the group - groupResult, err := client.Groups().Get(ctx, groupRequestConfig) - if err != nil { - return nil, errors.Wrap(err, "failed to find group") - } - - // Verify we found a group - if groupResult.GetValue() == nil || len(groupResult.GetValue()) == 0 { - return nil, errors.Errorf("group not found: %s", groupName) - } - - // Return the group ID - return groupResult.GetValue()[0].GetId(), nil -} - -// 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) { - // 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 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) - } - - // 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 -func (g *GraphQuery) extractDisplayName(member models.DirectoryObjectable, memberID string) string { - additionalData := member.GetAdditionalData() - - // Try to get from additional data first - if displayNameVal, exists := additionalData["displayName"]; exists && displayNameVal != nil { - if displayName, ok := displayNameVal.(string); ok { - return displayName - } - } - - // Try to use reflection to call GetDisplayName if it exists - memberValue := reflect.ValueOf(member) - displayNameMethod := memberValue.MethodByName("GetDisplayName") - if displayNameMethod.IsValid() && displayNameMethod.Type().NumIn() == 0 { - results := displayNameMethod.Call(nil) - if len(results) > 0 && !results[0].IsNil() { - // Check if the result is a *string - if displayNamePtr, ok := results[0].Interface().(*string); ok && displayNamePtr != nil { - return *displayNamePtr - } - } - } - - // Use fallback display name - return fmt.Sprintf("Member %s", memberID) -} - -// extractStringProperty safely extracts a string property from additionalData -func (g *GraphQuery) extractStringProperty(additionalData map[string]interface{}, key string) (string, bool) { - if val, exists := additionalData[key]; exists && val != nil { - if strVal, ok := val.(string); ok { - return strVal, true - } - } - return "", false -} - -// extractUserProperties extracts user-specific properties from additionalData -func (g *GraphQuery) extractUserProperties(additionalData map[string]interface{}, memberMap map[string]interface{}) { - // Extract mail property - if mail, ok := g.extractStringProperty(additionalData, "mail"); ok { - memberMap["mail"] = mail - } - - // Extract userPrincipalName property - if upn, ok := g.extractStringProperty(additionalData, "userPrincipalName"); ok { - memberMap["userPrincipalName"] = upn - } -} - -// extractServicePrincipalProperties extracts service principal specific properties -func (g *GraphQuery) extractServicePrincipalProperties(additionalData map[string]interface{}, memberMap map[string]interface{}) { - // Extract appId property - if appID, ok := g.extractStringProperty(additionalData, "appId"); ok { - memberMap["appId"] = appID - } -} - -// 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, - } - - // Determine member type - memberType := unknownType - - // Check properties that indicate user type - _, hasUserPrincipalName := g.extractStringProperty(additionalData, "userPrincipalName") - _, hasMail := g.extractStringProperty(additionalData, "mail") - if hasUserPrincipalName || hasMail { - memberType = userType - } - - // Check properties that indicate service principal type - _, hasAppID := g.extractStringProperty(additionalData, "appId") - if hasAppID { - memberType = servicePrincipalType - } - - // Try interface type checking for more accuracy - if _, ok := member.(models.Userable); ok { - memberType = userType - } - if _, ok := member.(models.ServicePrincipalable); ok { - memberType = servicePrincipalType - } - - // Add type to member info - memberMap["type"] = memberType - - // Extract display name - memberMap["displayName"] = g.extractDisplayName(member, *memberID) - - // Extract type-specific properties - switch memberType { - case userType: - g.extractUserProperties(additionalData, memberMap) - case servicePrincipalType: - g.extractServicePrincipalProperties(additionalData, memberMap) - } - - 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) { - // 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, groupName) - if err != nil { - return nil, err - } - - // Fetch the members - 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 { - memberMap := g.processMember(member) - members = append(members, memberMap) - } - - return members, nil -} - -// getGroupObjectIDs retrieves object IDs for the specified group names -func (g *GraphQuery) getGroupObjectIDs(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { - if len(in.Groups) == 0 { - return nil, errors.New("no group names provided") - } - - var results []interface{} - - for _, groupName := range in.Groups { - if groupName == nil { - continue - } - - // Create request configuration - requestConfig := &groups.GroupsRequestBuilderGetRequestConfiguration{ - QueryParameters: &groups.GroupsRequestBuilderGetQueryParameters{}, - } - - // Find the group by displayName - filterValue := fmt.Sprintf("displayName eq '%s'", *groupName) - requestConfig.QueryParameters.Filter = &filterValue - - // Use standard fields for group object IDs - requestConfig.QueryParameters.Select = []string{"id", "displayName", "description"} - - groupResult, err := client.Groups().Get(ctx, requestConfig) - if err != nil { - return nil, errors.Wrapf(err, "failed to find group %s", *groupName) - } - - if groupResult.GetValue() != nil && len(groupResult.GetValue()) > 0 { - for _, group := range groupResult.GetValue() { - groupMap := map[string]interface{}{ - "id": group.GetId(), - "displayName": group.GetDisplayName(), - "description": group.GetDescription(), - } - results = append(results, groupMap) - } - } + // Save the desired XR in the response + if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set desired composite resource in %T", rsp)) + return err } - return results, nil + f.log.Info("Successfully propagated Desired XR") + return nil } -// getServicePrincipalDetails retrieves details about service principals by name -func (g *GraphQuery) getServicePrincipalDetails(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { - if len(in.ServicePrincipals) == 0 { - return nil, errors.New("no service principal names provided") - } - - var results []interface{} - - for _, spName := range in.ServicePrincipals { - if spName == nil { - continue - } - - // Create request configuration - requestConfig := &serviceprincipals.ServicePrincipalsRequestBuilderGetRequestConfiguration{ - QueryParameters: &serviceprincipals.ServicePrincipalsRequestBuilderGetQueryParameters{}, - } - - // Find service principal by displayName - filterValue := fmt.Sprintf("displayName eq '%s'", *spName) - requestConfig.QueryParameters.Filter = &filterValue - - // Use standard fields for service principals - requestConfig.QueryParameters.Select = []string{"id", "appId", "displayName", "description"} - - spResult, err := client.ServicePrincipals().Get(ctx, requestConfig) - if err != nil { - return nil, errors.Wrapf(err, "failed to find service principal %s", *spName) - } - - if spResult.GetValue() != nil && len(spResult.GetValue()) > 0 { - for _, sp := range spResult.GetValue() { - spMap := map[string]interface{}{ - "id": sp.GetId(), - "appId": sp.GetAppId(), - "displayName": sp.GetDisplayName(), - "description": sp.GetDescription(), - } - results = append(results, spMap) - } - } +// preserveContext ensures the context is preserved in the response +func (f *Function) preserveContext(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctionResponse) { + // Get the existing context from the request + existingContext := req.GetContext() + if existingContext != nil { + // Copy the existing context to the response + rsp.Context = existingContext + f.log.Info("Preserved existing context in response") } - - return results, nil } // ParseNestedKey enables the bracket and dot notation to key reference func ParseNestedKey(key string) ([]string, error) { var parts []string // Regular expression to extract keys, supporting both dot and bracket notation - regex := regexp.MustCompile(`\[([^\[\]]+)\]|([^.\[\]]+)`) - matches := regex.FindAllStringSubmatch(key, -1) - for _, match := range matches { - if match[1] != "" { - parts = append(parts, match[1]) // Bracket notation - } else if match[2] != "" { - parts = append(parts, match[2]) // Dot notation + // For simplicity in this implementation, we'll use a simpler approach + for _, part := range strings.Split(key, ".") { + if part != "" { + parts = append(parts, part) } } @@ -682,14 +298,14 @@ func ParseNestedKey(key string) ([]string, error) { return parts, nil } -// GetNestedKey retrieves a nested string value from a map using dot notation keys. -func GetNestedKey(context map[string]interface{}, key string) (string, bool) { +// GetNestedValue retrieves a nested value from a map using dot notation keys. +func GetNestedValue(data map[string]interface{}, key string) (interface{}, bool, error) { parts, err := ParseNestedKey(key) if err != nil { - return "", false + return nil, false, err } - currentValue := interface{}(context) + currentValue := interface{}(data) for _, k := range parts { // Check if the current value is a map if nestedMap, ok := currentValue.(map[string]interface{}); ok { @@ -697,22 +313,18 @@ func GetNestedKey(context map[string]interface{}, key string) (string, bool) { if nextValue, exists := nestedMap[k]; exists { currentValue = nextValue } else { - return "", false + return nil, false, nil } } else { - return "", false + return nil, false, nil } } - // Convert the final value to a string - if result, ok := currentValue.(string); ok { - return result, true - } - return "", false + return currentValue, true, nil } -// SetNestedKey sets a value to a nested key from a map using dot notation keys. -func SetNestedKey(root map[string]interface{}, key string, value interface{}) error { +// SetNestedValue sets a value to a nested key from a map using dot notation keys. +func SetNestedValue(root map[string]interface{}, key string, value interface{}) error { parts, err := ParseNestedKey(key) if err != nil { return err @@ -731,7 +343,7 @@ func SetNestedKey(root map[string]interface{}, key string, value interface{}) er if nextMap, ok := next.(map[string]interface{}); ok { current = nextMap } else { - return fmt.Errorf("key %q exists but is not a map", part) + return errors.Errorf("key %q exists but is not a map", part) } } else { // Create a new map if the path doesn't exist @@ -744,510 +356,260 @@ func SetNestedKey(root map[string]interface{}, key string, value interface{}) er return nil } -// putQueryResultToStatus processes the query results to status -func (f *Function) putQueryResultToStatus(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctionResponse, in *v1beta1.Input, results interface{}) error { - xrStatus, dxr, err := f.getXRAndStatus(req) +// extractDataToHash extracts the data to hash from the specified field +func (f *Function) extractDataToHash(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) (interface{}, error) { + oxr, err := request.GetObservedCompositeResource(req) if err != nil { - return err + response.Fatal(rsp, errors.Wrap(err, "cannot get observed composite resource")) + return nil, err } - // Update the specific status field - statusField := strings.TrimPrefix(in.Target, "status.") - err = SetNestedKey(xrStatus, statusField, results) - if err != nil { - return errors.Wrapf(err, "cannot set status field %s to %v", statusField, results) + // Determine if we're looking in spec or status + parts := strings.SplitN(in.DataField, ".", 2) + if len(parts) != 2 { + response.Fatal(rsp, errors.Errorf("invalid DataField format: %s, expected section.field (e.g. spec.resources)", in.DataField)) + return nil, errors.New("invalid DataField format") } - // Write the updated status field back into the composite resource - if err := dxr.Resource.SetValue("status", xrStatus); err != nil { - return errors.Wrap(err, "cannot write updated status back into composite resource") + section, field := parts[0], parts[1] + + var sectionData map[string]interface{} + if err := oxr.Resource.GetValueInto(section, §ionData); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot get %s from observed XR", section)) + return nil, err } - // Save the updated desired composite resource - if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil { - return errors.Wrapf(err, "cannot set desired composite resource in %T", rsp) + data, exists, err := GetNestedValue(sectionData, field) + if err != nil { + response.Fatal(rsp, errors.Wrapf(err, "error accessing field %s", field)) + return nil, err } - return nil -} -func putQueryResultToContext(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctionResponse, in *v1beta1.Input, results interface{}, f *Function) error { - contextField := strings.TrimPrefix(in.Target, "context.") - data, err := structpb.NewValue(results) - if err != nil { - return errors.Wrap(err, "cannot convert results data to structpb.Value") + if !exists { + response.Fatal(rsp, errors.Errorf("field %s.%s not found in resource", section, field)) + return nil, errors.New("field not found") } - // Convert existing context into a map[string]interface{} - contextMap := req.GetContext().AsMap() + return data, nil +} - err = SetNestedKey(contextMap, contextField, data.AsInterface()) +// calculateHash calculates hash for the given data using the specified algorithm +func (f *Function) calculateHash(data interface{}, in *v1beta1.Input) string { + // Create a JSON representation of the data + jsonData, err := json.Marshal(data) if err != nil { - return errors.Wrap(err, "failed to update context key") + f.log.Debug("Error marshaling data to JSON", "error", err) + return "" } - f.log.Debug("Updating Composition Pipeline Context", "key", contextField, "data", results) - - // Convert the updated context back into structpb.Struct - updatedContext, err := structpb.NewStruct(contextMap) - if err != nil { - return errors.Wrap(err, "failed to serialize updated context") + // Choose hash algorithm + var h hash.Hash + switch *in.HashAlgorithm { + case "md5": + h = md5.New() + case "sha512": + h = sha512.New() + default: + // Default to sha256 + h = sha256.New() } - // Set the updated context - rsp.Context = updatedContext - return nil + // Calculate hash + h.Write(jsonData) + return hex.EncodeToString(h.Sum(nil)) } -// targetHasData checks if a target field already has data -func targetHasData(data map[string]interface{}, key string) (bool, error) { - parts, err := ParseNestedKey(key) +// getOldHash retrieves the previously approved hash +func (f *Function) getOldHash(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) (string, error) { + xrStatus, _, err := f.getXRAndStatus(req) if err != nil { - return false, err - } - - currentValue := interface{}(data) - for _, k := range parts { - // Check if the current value is a map - if nestedMap, ok := currentValue.(map[string]interface{}); ok { - // Get the next value in the nested map - if nextValue, exists := nestedMap[k]; exists { - currentValue = nextValue - } else { - // Key doesn't exist, so no data - return false, nil - } - } else { - // Not a map, so can't traverse further - return false, nil - } + response.Fatal(rsp, err) + return "", err } - // If we've reached here, the key exists - // Check if it has meaningful data (not nil and not empty) - if currentValue == nil { - return false, nil + // Remove status. prefix if present + hashField := *in.OldHashField + if strings.HasPrefix(hashField, "status.") { + hashField = strings.TrimPrefix(hashField, "status.") } - // Check for empty maps - if nestedMap, ok := currentValue.(map[string]interface{}); ok { - return len(nestedMap) > 0, nil + // Get the old hash from status + value, exists, err := GetNestedValue(xrStatus, hashField) + if err != nil { + response.Fatal(rsp, errors.Wrapf(err, "error accessing old hash field %s", hashField)) + return "", err } - // Check for empty slices - if slice, ok := currentValue.([]interface{}); ok { - return len(slice) > 0, nil + if !exists { + // Not an error, just means this is the first time we're seeing this resource + return "", nil } - // For strings, check if empty - if str, ok := currentValue.(string); ok { - return str != "", nil + strValue, ok := value.(string) + if !ok { + response.Fatal(rsp, errors.Errorf("old hash field %s is not a string", hashField)) + return "", errors.New("old hash field is not a string") } - // For other types (numbers, booleans), consider them as having data - return true, nil + return strValue, nil } -// propagateDesiredXR ensures the desired XR is properly propagated without changing existing data -func (f *Function) propagateDesiredXR(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctionResponse) error { +// saveNewHash saves the new hash to the status +func (f *Function) saveNewHash(req *fnv1.RunFunctionRequest, in *v1beta1.Input, hash string, rsp *fnv1.RunFunctionResponse) error { xrStatus, dxr, err := f.getXRAndStatus(req) if err != nil { response.Fatal(rsp, err) return err } - // Write any existing status back to dxr - if len(xrStatus) > 0 { - if err := dxr.Resource.SetValue("status", xrStatus); err != nil { - f.log.Info("Error setting status in Desired XR", "error", err) - return err - } + // Remove status. prefix if present + hashField := *in.NewHashField + if strings.HasPrefix(hashField, "status.") { + hashField = strings.TrimPrefix(hashField, "status.") } - // Save the desired XR in the response - if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil { - response.Fatal(rsp, errors.Wrapf(err, "cannot set desired composite resource in %T", rsp)) + // Set the new hash in status + if err := SetNestedValue(xrStatus, hashField, hash); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set new hash field %s", hashField)) return err } - f.log.Info("Successfully propagated Desired XR") - return nil -} - -// preserveContext ensures the context is preserved in the response -func (f *Function) preserveContext(req *fnv1.RunFunctionRequest, rsp *fnv1.RunFunctionResponse) { - // Get the existing context from the request - existingContext := req.GetContext() - if existingContext != nil { - // Copy the existing context to the response - rsp.Context = existingContext - f.log.Info("Preserved existing context in response") - } -} - -// 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 { + // Write updated status back to dxr + if err := dxr.Resource.SetValue("status", xrStatus); err != nil { + response.Fatal(rsp, errors.Wrap(err, "cannot write updated status back into composite resource")) 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 + // Save the updated desired composite resource + if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set desired composite resource in %T", rsp)) + return err } - 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 + return nil } -// 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) +// saveOldHash updates the old hash with the new hash after approval +func (f *Function) saveOldHash(req *fnv1.RunFunctionRequest, in *v1beta1.Input, hash string, rsp *fnv1.RunFunctionResponse) error { + xrStatus, dxr, err := f.getXRAndStatus(req) 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 + return err } - groupNames, err := f.resolveGroupsRef(req, in.GroupsRef) - if err != nil { - response.Fatal(rsp, err) - return false + // Remove status. prefix if present + hashField := *in.OldHashField + if strings.HasPrefix(hashField, "status.") { + hashField = strings.TrimPrefix(hashField, "status.") } - 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 + // Set the old hash in status + if err := SetNestedValue(xrStatus, hashField, hash); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set old hash field %s", hashField)) + return err } - userNames, err := f.resolveUsersRef(req, in.UsersRef) - if err != nil { - response.Fatal(rsp, err) - return false + // Write updated status back to dxr + if err := dxr.Resource.SetValue("status", xrStatus); err != nil { + response.Fatal(rsp, errors.Wrap(err, "cannot write updated status back into composite resource")) + return err } - 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 + // Save the updated desired composite resource + if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set desired composite resource in %T", rsp)) + return err } - 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 + return nil } -// 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) +// checkApprovalStatus checks if the current changes are approved +func (f *Function) checkApprovalStatus(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) (bool, error) { + xrStatus, _, err := f.getXRAndStatus(req) 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.") -} - -// shouldSkipQuery checks if the query should be skipped. -func (f *Function) shouldSkipQuery(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { - // Determine if we should skip the query when target has data - var shouldSkipQueryWhenTargetHasData = false // Default to false to ensure continuous reconciliation - if in.SkipQueryWhenTargetHasData != nil { - shouldSkipQueryWhenTargetHasData = *in.SkipQueryWhenTargetHasData - } - - if !shouldSkipQueryWhenTargetHasData { - return false - } - - switch { - case strings.HasPrefix(in.Target, "status."): - return f.checkStatusTargetHasData(req, in, rsp) - case strings.HasPrefix(in.Target, "context."): - return f.checkContextTargetHasData(req, in, rsp) - } - - return false -} - -// checkContextTargetHasData checks if the context target has data. -func (f *Function) checkContextTargetHasData(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) bool { - contextMap := req.GetContext().AsMap() - contextField := strings.TrimPrefix(in.Target, "context.") - if hasData, _ := targetHasData(contextMap, contextField); hasData { - f.log.Info("Target already has data, skipping query", "target", in.Target) - - // Set success condition and return - response.ConditionTrue(rsp, "FunctionSkip", "SkippedQuery"). - WithMessage("Target already has data, skipped query to avoid throttling"). - TargetCompositeAndClaim() - return true - } - 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") + response.Fatal(rsp, err) + return false, err } - 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) + // Remove status. prefix if present + approvalField := *in.ApprovalField + if strings.HasPrefix(approvalField, "status.") { + approvalField = strings.TrimPrefix(approvalField, "status.") } -} -// resolveFromStatus resolves a reference from XR status -func (f *Function) resolveFromStatus(req *fnv1.RunFunctionRequest, refKey string) (string, error) { - xrStatus, _, err := f.getXRAndStatus(req) + // Get the approval status + value, exists, err := GetNestedValue(xrStatus, approvalField) if err != nil { - return "", errors.Wrap(err, "cannot get XR status") + response.Fatal(rsp, errors.Wrapf(err, "error accessing approval field %s", approvalField)) + return false, err } - statusField := strings.TrimPrefix(refKey, "status.") - value, ok := GetNestedKey(xrStatus, statusField) - if !ok { - return "", errors.Errorf("cannot resolve groupRef: %s not found", refKey) + if !exists { + // Not explicitly approved + return false, nil } - 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) + boolValue, ok := value.(bool) if !ok { - return "", errors.Errorf("cannot resolve groupRef: %s not found", refKey) + response.Fatal(rsp, errors.Errorf("approval field %s is not a boolean", approvalField)) + return false, errors.New("approval field is not a boolean") } - return value, nil + + return boolValue, 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 +// pauseReconciliation adds a pause annotation to the resource +func (f *Function) pauseReconciliation(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) error { _, 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) + response.Fatal(rsp, err) + return err } - // 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)) + // Get current annotations + annotations := dxr.Resource.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} } - return result, err -} + // Add pause annotation + annotations[*in.PauseAnnotation] = "true" + dxr.Resource.SetAnnotations(annotations) -// 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") + // Save the updated desired composite resource + if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set desired composite resource in %T", rsp)) + return err } - 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) + return nil } -// 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 +// resumeReconciliation removes the pause annotation from the resource +func (f *Function) resumeReconciliation(req *fnv1.RunFunctionRequest, in *v1beta1.Input, rsp *fnv1.RunFunctionResponse) error { _, 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") + response.Fatal(rsp, err) + return err } - 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") + // Get current annotations + annotations := dxr.Resource.GetAnnotations() + if annotations == nil || annotations[*in.PauseAnnotation] == "" { + // No pause annotation, nothing to do + return nil } - 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) - } - } + // Remove pause annotation + delete(annotations, *in.PauseAnnotation) + dxr.Resource.SetAnnotations(annotations) - // 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 - } + // Save the updated desired composite resource + if err := response.SetDesiredCompositeResource(rsp, dxr); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set desired composite resource in %T", rsp)) + return err } - return nil, errors.Errorf("cannot resolve groupsRef: %s not a string array or empty", refKey) -} + return nil +} \ No newline at end of file diff --git a/fn_test.go b/fn_test.go index e8eb210..3fc4317 100644 --- a/fn_test.go +++ b/fn_test.go @@ -2,2506 +2,271 @@ package main import ( "context" - "fmt" "testing" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/upbound/function-msgraph/input/v1beta1" - "google.golang.org/protobuf/testing/protocmp" - "google.golang.org/protobuf/types/known/durationpb" - - "github.com/crossplane/crossplane-runtime/pkg/errors" "github.com/crossplane/crossplane-runtime/pkg/logging" fnv1 "github.com/crossplane/function-sdk-go/proto/v1" "github.com/crossplane/function-sdk-go/resource" - "github.com/crossplane/function-sdk-go/response" ) -type MockGraphQuery struct { - GraphQueryFunc func(ctx context.Context, azureCreds map[string]string, in *v1beta1.Input) (interface{}, error) -} - -func (m *MockGraphQuery) graphQuery(ctx context.Context, azureCreds map[string]string, in *v1beta1.Input) (interface{}, error) { - return m.GraphQueryFunc(ctx, azureCreds, in) -} - -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 +func TestFunction_MalformedInput(t *testing.T) { + f := &Function{ + log: logging.NewNopLogger(), } - 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 - } - }`), - }, - }, - }, + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "fn-approval"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "approve.fn.crossplane.io/v1alpha1", + "kind": "Input" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "test-xr" + } + }`), }, }, } - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - // Create mock responders for each type of query - mockQuery := &MockGraphQuery{ - GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { - if in.QueryType == "GroupObjectIDs" { - if len(in.Groups) == 0 { - return nil, errors.New("no group names provided") - } + rsp, err := f.RunFunction(context.Background(), req) - 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) - }, - } + if err != nil { + t.Errorf("expected no error but got: %v", err) + } - f := &Function{ - graphQuery: mockQuery, - log: logging.NewNopLogger(), - } - rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) + if rsp == nil { + t.Fatal("expected response but got nil") + } - 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) - } + // Should have a fatal error result + if len(rsp.Results) == 0 { + t.Fatal("expected at least one result but got none") + } - 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) - } - }) + // The first result should be a fatal error + if rsp.Results[0].Severity != fnv1.Severity_SEVERITY_FATAL { + t.Errorf("expected SEVERITY_FATAL but got: %v", rsp.Results[0].Severity) } } -// 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 +func TestFunction_FirstRunNeedsApproval(t *testing.T) { + f := &Function{ + log: logging.NewNopLogger(), } - 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" + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "fn-approval"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "approve.fn.crossplane.io/v1alpha1", + "kind": "Input", + "dataField": "spec.resources", + "approvalField": "status.approved", + "oldHashField": "status.oldHash", + "newHashField": "status.newHash", + "detailedCondition": true + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "test-xr" + }, + "spec": { + "resources": { + "test": "data" } - }`), - 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) + rsp, err := f.RunFunction(context.Background(), 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) - } - }) + if err != nil { + t.Errorf("expected no error but got: %v", err) } -} - -// 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 + if rsp == nil { + t.Fatal("expected response but got nil") } - 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 - } - }`), - }, - }, - }, - }, - }, + // Should have no error results + if len(rsp.Results) > 0 { + t.Errorf("expected no results but got: %v", rsp.Results) } - 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) - }, - } + // Should have an approval required condition + if len(rsp.Conditions) == 0 { + t.Fatal("expected at least one condition but got none") + } - f := &Function{ - graphQuery: mockQuery, - log: logging.NewNopLogger(), - } - rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) + // The first condition should be ApprovalRequired + if rsp.Conditions[0].Type != "ApprovalRequired" { + t.Errorf("expected ApprovalRequired condition but got: %v", rsp.Conditions[0].Type) + } - 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 rsp.Conditions[0].Status != fnv1.Status_STATUS_CONDITION_FALSE { + t.Errorf("expected STATUS_CONDITION_FALSE but got: %v", rsp.Conditions[0].Status) + } - 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) - } - }) + if rsp.Conditions[0].Reason != "WaitingForApproval" { + t.Errorf("expected WaitingForApproval reason but got: %v", rsp.Conditions[0].Reason) } } -// 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 +func TestFunction_AlreadyApproved(t *testing.T) { + f := &Function{ + log: logging.NewNopLogger(), } - 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(), - }, + const hashValue = "e02c6d35c585a43c62dc2ae14a5385b8a86168b36be7a0d985c0c09afca4ffbe" + + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "fn-approval"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "approve.fn.crossplane.io/v1alpha1", + "kind": "Input", + "dataField": "spec.resources", + "approvalField": "status.approved", + "oldHashField": "status.oldHash", + "newHashField": "status.newHash" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "test-xr", + "annotations": { + "crossplane.io/paused": "true" + } }, - Desired: &fnv1.State{ - Composite: &fnv1.Resource{ - Resource: resource.MustStructJSON(`{ - "apiVersion": "example.org/v1", - "kind": "XR", - "metadata": { - "name": "cool-xr" - }, - "spec": { - "count": 2 - } - }`), - }, + "spec": { + "resources": { + "test": "data" + } }, - }, + "status": { + "approved": true, + "newHash": "` + hashValue + `" + } + }`), }, }, } - 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") - } + rsp, err := f.RunFunction(context.Background(), req) - var results []interface{} - for i, sp := range in.ServicePrincipals { - if sp == nil { - continue - } + if err != nil { + t.Errorf("expected no error but got: %v", err) + } - var ( - spID string - appID string - description string - ) + if rsp == nil { + t.Fatal("expected response but got nil") + } - // 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" - } + // Should have no error results + if len(rsp.Results) > 0 { + t.Errorf("expected no results but got: %v", rsp.Results) + } - 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) - }, - } + // Should have a success condition + if len(rsp.Conditions) == 0 { + t.Fatal("expected at least one condition but got none") + } - f := &Function{ - graphQuery: mockQuery, - log: logging.NewNopLogger(), - } - rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) + // The first condition should be FunctionSuccess + if rsp.Conditions[0].Type != "FunctionSuccess" { + t.Errorf("expected FunctionSuccess condition but got: %v", rsp.Conditions[0].Type) + } - 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 rsp.Conditions[0].Status != fnv1.Status_STATUS_CONDITION_TRUE { + t.Errorf("expected STATUS_CONDITION_TRUE but got: %v", rsp.Conditions[0].Status) + } - 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) - } - }) + if rsp.Conditions[0].Reason != "Success" { + t.Errorf("expected Success reason but got: %v", rsp.Conditions[0].Reason) } } -func TestRunFunction(t *testing.T) { +func TestFunction_ChangesRequireApproval(t *testing.T) { + f := &Function{ + log: logging.NewNopLogger(), + } - 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-cliend-id", -"clientSecret": "test-client-secret", -"subscriptionId": "test-subscription-id", -"tenantId": "test-tenant-id" -}`), + const oldHash = "e02c6d35c585a43c62dc2ae14a5385b8a86168b36be7a0d985c0c09afca4ffbe" + + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "fn-approval"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "approve.fn.crossplane.io/v1alpha1", + "kind": "Input", + "dataField": "spec.resources", + "approvalField": "status.approved", + "oldHashField": "status.oldHash", + "newHashField": "status.newHash" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": { + "name": "test-xr" + }, + "spec": { + "resources": { + "test": "updated-data" + } + }, + "status": { + "approved": false, + "oldHash": "` + oldHash + `" + } + }`), }, - } - ) + }, + } + + rsp, err := f.RunFunction(context.Background(), req) - type args struct { - ctx context.Context - req *fnv1.RunFunctionRequest + if err != nil { + t.Errorf("expected no error but got: %v", err) } - type want struct { - rsp *fnv1.RunFunctionResponse - err error + + if rsp == nil { + t.Fatal("expected response but got nil") } - cases := map[string]struct { - reason string - args args - want want - }{ - "ResponseIsReturned": { - reason: "The Function should return a fatal result if no credentials were specified", - args: args{ - req: &fnv1.RunFunctionRequest{ - Meta: &fnv1.RequestMeta{Tag: "hello"}, - Input: resource.MustStructJSON(`{ - "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", - "kind": "Input", - "queryType": "UserValidation", - "users": ["user@example.com"] - }`), - }, - }, - want: want{ - rsp: &fnv1.RunFunctionResponse{ - Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, - Results: []*fnv1.Result{ - { - Severity: fnv1.Severity_SEVERITY_FATAL, - Message: "failed to get azure-creds credentials", - Target: fnv1.Target_TARGET_COMPOSITE.Enum(), - }, - }, - Desired: &fnv1.State{ - Composite: &fnv1.Resource{ - Resource: resource.MustStructJSON(`{ - "apiVersion": "", - "kind": "" - }`), - }, - }, - }, - }, - }, - "MissingUserValidationTarget": { - reason: "The Function should return a fatal result if no target is specified", - args: args{ - req: &fnv1.RunFunctionRequest{ - Meta: &fnv1.RequestMeta{Tag: "hello"}, - Input: resource.MustStructJSON(`{ - "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", - "kind": "Input", - "queryType": "UserValidation", - "users": ["user@example.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)}, - Results: []*fnv1.Result{ - { - Severity: fnv1.Severity_SEVERITY_FATAL, - Message: "Unrecognized target field: ", - Target: fnv1.Target_TARGET_COMPOSITE.Enum(), - }, - }, - Desired: &fnv1.State{ - Composite: &fnv1.Resource{ - Resource: resource.MustStructJSON(`{ - "apiVersion": "", - "kind": "" - }`), - }, - }, - }, - }, - }, - "UserValidationMissingUsers": { - reason: "The Function should handle UserValidation with missing users", - 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", - "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: "no users provided for validation", - 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 - } - }`), - }, - }, - }, - }, - }, - "SuccessfulUserValidation": { - reason: "The Function should handle a successful UserValidation query", - 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", - "users": ["user@example.com"], - "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)}, - 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", - "metadata": { - "name": "cool-xr" - }, - "spec": { - "count": 2 - }, - "status": { - "validatedUsers": [ - { - "id": "test-user-id", - "displayName": "Test User", - "userPrincipalName": "user@example.com", - "mail": "user@example.com" - } - ] - }}`), - }, - }, - }, - }, - }, - "GroupMembershipMissingGroup": { - reason: "The Function should handle GroupMembership with missing group", - 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", - "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: "no group name provided", - 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 - } - }`), - }, - }, - }, - }, - }, - "SuccessfulGroupMembership": { - reason: "The Function should handle a successful GroupMembership query", - 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", - "group": "Developers", - "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)}, - 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", - "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" - } - ] - }}`), - }, - }, - }, - }, - }, - "GroupObjectIDsMissingGroups": { - reason: "The Function should handle GroupObjectIDs with missing groups", - 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", - "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: "no group names provided", - 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 - } - }`), - }, - }, - }, - }, - }, - "SuccessfulGroupObjectIDs": { - reason: "The Function should handle a successful GroupObjectIDs query", - 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", - "groups": ["Developers", "Operations"], - "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)}, - 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", - "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" - } - ] - }}`), - }, - }, - }, - }, - }, - "ServicePrincipalDetailsMissingNames": { - reason: "The Function should handle ServicePrincipalDetails with missing names", - 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", - "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: "no service principal names provided", - 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 - } - }`), - }, - }, - }, - }, - }, - "SuccessfulServicePrincipalDetails": { - reason: "The Function should handle a successful ServicePrincipalDetails query", - 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", - "servicePrincipals": ["MyServiceApp"], - "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)}, - 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", - "metadata": { - "name": "cool-xr" - }, - "spec": { - "count": 2 - }, - "status": { - "servicePrincipals": [ - { - "id": "sp-id-1", - "appId": "app-id-1", - "displayName": "MyServiceApp", - "description": "Service application" - } - ] - }}`), - }, - }, - }, - }, - }, - "InvalidQueryType": { - reason: "The Function should handle an invalid query type", - 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": "InvalidType", - "target": "status.invalidResult" - }`), - 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: "unsupported query type: InvalidType", - 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 - } - }`), - }, - }, - }, - }, - }, - "ShouldSkipQueryWhenStatusTargetHasData": { - reason: "The Function should skip query when status target already has data", - 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", - "users": ["user@example.com"], - "target": "status.validatedUsers", - "skipQueryWhenTargetHasData": true - }`), - Observed: &fnv1.State{ - Composite: &fnv1.Resource{ - Resource: resource.MustStructJSON(`{ - "apiVersion": "example.org/v1", - "kind": "XR", - "status": { - "validatedUsers": [ - { - "id": "existing-user-id", - "displayName": "Existing User", - "userPrincipalName": "existing@example.com", - "mail": "existing@example.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: "FunctionSkip", - Message: strPtr("Target already has data, skipped query to avoid throttling"), - Status: fnv1.Status_STATUS_CONDITION_TRUE, - Reason: "SkippedQuery", - Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), - }, - { - Type: "FunctionSuccess", - Status: fnv1.Status_STATUS_CONDITION_TRUE, - Reason: "Success", - Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), - }, - }, - Desired: &fnv1.State{ - Composite: &fnv1.Resource{ - Resource: resource.MustStructJSON(`{ - "apiVersion": "example.org/v1", - "kind": "XR", - "status": { - "validatedUsers": [ - { - "id": "existing-user-id", - "displayName": "Existing User", - "userPrincipalName": "existing@example.com", - "mail": "existing@example.com" - } - ] - }}`), - }, - }, - }, - }, - }, - "QueryToContextField": { - reason: "The Function should store results in context field", - 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", - "users": ["user@example.com"], - "target": "context.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)}, - 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( - `{ - "validatedUsers": [ - { - "id": "test-user-id", - "displayName": "Test User", - "userPrincipalName": "user@example.com", - "mail": "user@example.com" - } - ] - }`, - ), - Desired: &fnv1.State{ - Composite: &fnv1.Resource{ - Resource: resource.MustStructJSON(`{ - "apiVersion": "example.org/v1", - "kind": "XR", - "metadata": { - "name": "cool-xr" - }, - "spec": { - "count": 2 - } - }`), - }, - }, - }, - }, - }, + // Should have no error results + if len(rsp.Results) > 0 { + t.Errorf("expected no results but got: %v", rsp.Results) } - 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) { - switch in.QueryType { - case "UserValidation": - if len(in.Users) == 0 { - return nil, errors.New("no users provided for validation") - } - return []interface{}{ - map[string]interface{}{ - "id": "test-user-id", - "displayName": "Test User", - "userPrincipalName": "user@example.com", - "mail": "user@example.com", - }, - }, nil - case "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 - case "GroupObjectIDs": - if len(in.Groups) == 0 { - return nil, errors.New("no group names provided") - } - return []interface{}{ - map[string]interface{}{ - "id": "group-id-1", - "displayName": "Developers", - "description": "Development team", - }, - map[string]interface{}{ - "id": "group-id-2", - "displayName": "Operations", - "description": "Operations team", - }, - }, nil - case "ServicePrincipalDetails": - if len(in.ServicePrincipals) == 0 { - return nil, errors.New("no service principal names provided") - } - return []interface{}{ - map[string]interface{}{ - "id": "sp-id-1", - "appId": "app-id-1", - "displayName": "MyServiceApp", - "description": "Service application", - }, - }, nil - default: - return nil, errors.Errorf("unsupported query type: %s", in.QueryType) - } - }, - } + // Should have an approval required condition + if len(rsp.Conditions) == 0 { + t.Fatal("expected at least one condition but got none") + } - f := &Function{ - graphQuery: mockQuery, - log: logging.NewNopLogger(), - } - rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) + // The first condition should be ApprovalRequired + if rsp.Conditions[0].Type != "ApprovalRequired" { + t.Errorf("expected ApprovalRequired condition but got: %v", rsp.Conditions[0].Type) + } - 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 rsp.Conditions[0].Status != fnv1.Status_STATUS_CONDITION_FALSE { + t.Errorf("expected STATUS_CONDITION_FALSE but got: %v", rsp.Conditions[0].Status) + } - 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) - } - }) + if rsp.Conditions[0].Reason != "WaitingForApproval" { + t.Errorf("expected WaitingForApproval reason but got: %v", rsp.Conditions[0].Reason) } } diff --git a/go.mod b/go.mod index b986775..8787f6c 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,15 @@ -module github.com/upbound/function-msgraph +module github.com/upbound/function-approve go 1.24.0 toolchain go1.24.3 require ( - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 github.com/alecthomas/kong v1.11.0 github.com/crossplane/crossplane-runtime v1.19.0 github.com/crossplane/function-sdk-go v0.4.0 github.com/google/go-cmp v0.7.0 - github.com/microsoft/kiota-authentication-azure-go v1.3.0 - github.com/microsoftgraph/msgraph-sdk-go v1.72.0 + github.com/upbound/function-msgraph v0.2.0 google.golang.org/protobuf v1.36.6 k8s.io/apimachinery v0.33.1 sigs.k8s.io/controller-tools v0.18.0 @@ -19,9 +17,6 @@ require ( require ( dario.cat/mergo v1.0.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect @@ -29,49 +24,30 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 // indirect github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/microsoft/kiota-abstractions-go v1.9.2 // indirect - github.com/microsoft/kiota-http-go v1.5.2 // indirect - github.com/microsoft/kiota-serialization-form-go v1.1.2 // indirect - github.com/microsoft/kiota-serialization-json-go v1.1.2 // indirect - github.com/microsoft/kiota-serialization-multipart-go v1.1.2 // indirect - github.com/microsoft/kiota-serialization-text-go v1.1.2 // indirect - github.com/microsoftgraph/msgraph-sdk-go-core v1.3.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 // indirect - github.com/stretchr/testify v1.10.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.38.0 // indirect golang.org/x/mod v0.24.0 // indirect golang.org/x/net v0.40.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect diff --git a/go.sum b/go.sum index 28bc865..63c58e8 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,5 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 h1:j8BorDEigD8UFOSZQiSqAMOOleyQOOQPnUAwV+Ls1gA= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= -github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= -github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -44,8 +32,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= @@ -62,11 +48,8 @@ github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 h1:xcuWappghOVI8iNWoF2OKahVejd1LSVi/v4JED44Amo= github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= @@ -83,8 +66,6 @@ github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -131,8 +112,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= -github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -142,8 +121,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -151,24 +128,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/microsoft/kiota-abstractions-go v1.9.2 h1:3U5VgN2YGe3lsu1pyuS0t5jxv1llxX2ophwX8ewE6wQ= -github.com/microsoft/kiota-abstractions-go v1.9.2/go.mod h1:f06pl3qSyvUHEfVNkiRpXPkafx7khZqQEb71hN/pmuU= -github.com/microsoft/kiota-authentication-azure-go v1.3.0 h1:PWH6PgtzhJjnmvR6N1CFjriwX09Kv7S5K3vL6VbPVrg= -github.com/microsoft/kiota-authentication-azure-go v1.3.0/go.mod h1:l/MPGUVvD7xfQ+MYSdZaFPv0CsLDqgSOp8mXwVgArIs= -github.com/microsoft/kiota-http-go v1.5.2 h1:xqvo4ssWwSvCJw2yuRocKFTxm3Y1iN+a4rrhuTYtBWg= -github.com/microsoft/kiota-http-go v1.5.2/go.mod h1:L+5Ri+SzwELnUcNA0cpbFKp/pBbvypLh3Cd1PR6sjx0= -github.com/microsoft/kiota-serialization-form-go v1.1.2 h1:SD6MATqNw+Dc5beILlsb/D87C36HKC/Zw7l+N9+HY2A= -github.com/microsoft/kiota-serialization-form-go v1.1.2/go.mod h1:m4tY2JT42jAZmgbqFwPy3zGDF+NPJACuyzmjNXeuHio= -github.com/microsoft/kiota-serialization-json-go v1.1.2 h1:eJrPWeQ665nbjO0gsHWJ0Bw6V/ZHHU1OfFPaYfRG39k= -github.com/microsoft/kiota-serialization-json-go v1.1.2/go.mod h1:deaGt7fjZarywyp7TOTiRsjfYiyWxwJJPQZytXwYQn8= -github.com/microsoft/kiota-serialization-multipart-go v1.1.2 h1:1pUyA1QgIeKslQwbk7/ox1TehjlCUUT3r1f8cNlkvn4= -github.com/microsoft/kiota-serialization-multipart-go v1.1.2/go.mod h1:j2K7ZyYErloDu7Kuuk993DsvfoP7LPWvAo7rfDpdPio= -github.com/microsoft/kiota-serialization-text-go v1.1.2 h1:7OfKFlzdjpPygca/+OtqafkEqCWR7+94efUFGC28cLw= -github.com/microsoft/kiota-serialization-text-go v1.1.2/go.mod h1:QNTcswkBPFY3QVBFmzfk00UMNViKQtV0AQKCrRw5ibM= -github.com/microsoftgraph/msgraph-sdk-go v1.72.0 h1:yKYJ46dJgX6XdI79zI2kotiBPIYktMQs1ad2DVz81rk= -github.com/microsoftgraph/msgraph-sdk-go v1.72.0/go.mod h1:5ncg4aauxM5XKHo/xvAq7Cjl6+Dqu6lOtoihSGKtDt4= -github.com/microsoftgraph/msgraph-sdk-go-core v1.3.2 h1:5jCUSosTKaINzPPQXsz7wsHWwknyBmJSu8+ZWxx3kdQ= -github.com/microsoftgraph/msgraph-sdk-go-core v1.3.2/go.mod h1:iD75MK3LX8EuwjDYCmh0hkojKXK6VKME33u4daCo3cE= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= @@ -194,8 +153,6 @@ github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -209,10 +166,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= -github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= @@ -220,8 +175,6 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 h1:7hth9376EoQEd1hH4lAp3vnaLP2UMyxuMMghLKzDHyU= -github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -235,6 +188,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tmccombs/hcl2json v0.3.3 h1:+DLNYqpWE0CsOQiEZu+OZm5ZBImake3wtITYxQ8uLFQ= github.com/tmccombs/hcl2json v0.3.3/go.mod h1:Y2chtz2x9bAeRTvSibVRVgbLJhLJXKlUeIvjeVdnm4w= +github.com/upbound/function-msgraph v0.2.0 h1:aN8qn6xIzht/BRBVy+RoSpINM04IEDPSrbF9uqAMSL8= +github.com/upbound/function-msgraph v0.2.0/go.mod h1:BLHuJAXBYbsUSJJFkj6a17icKRWO1mqAA89m68Q5ApE= github.com/upbound/provider-aws v1.14.0 h1:DDUdlMp+dNlFXXlhsGdCvQD7qFdT1AsEcaqlRU3BO14= github.com/upbound/provider-aws v1.14.0/go.mod h1:IvyvgGlhRVr737E4P75tyD/i53hxnyO7KPM8bbXH+SU= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= @@ -251,14 +206,10 @@ github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -268,8 +219,6 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -293,7 +242,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 3c3b5b1..c6d3321 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -1,6 +1,6 @@ // Package v1beta1 contains the input type for this Function // +kubebuilder:object:generate=true -// +groupName=msgraph.fn.crossplane.io +// +groupName=approve.fn.crossplane.io // +versionName=v1alpha1 package v1beta1 @@ -19,51 +19,43 @@ type Input struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - // QueryType defines the type of Microsoft Graph API query to perform - // Supported values: UserValidation, GroupMembership, GroupObjectIDs, ServicePrincipalDetails - QueryType string `json:"queryType"` + // DataField defines the object field to hash and store for tracking changes + // For example: "spec.resources" + DataField string `json:"dataField"` - // Users is a list of userPrincipalName (email IDs) for user validation + // HashAlgorithm defines which hash algorithm to use for calculating hashes + // Supported values: "md5", "sha256", "sha512" + // Default is "sha256" // +optional - Users []*string `json:"users,omitempty"` + HashAlgorithm *string `json:"hashAlgorithm,omitempty"` - // UsersRef is a reference to retrieve the user names (e.g., from status or context) - // Overrides Users field if used + // ApprovalField defines the status field to check for the approval decision + // Default is "status.approved" // +optional - UsersRef *string `json:"usersRef,omitempty"` + ApprovalField *string `json:"approvalField,omitempty"` - // Groups is a list of group names for group object ID queries + // OldHashField defines where to store the previous (approved) hash value + // Default is "status.oldHash" // +optional - Groups []*string `json:"groups,omitempty"` + OldHashField *string `json:"oldHashField,omitempty"` - // GroupsRef is a reference to retrieve the group names (e.g., from status or context) - // Overrides Groups field if used + // NewHashField defines where to store the current hash value + // Default is "status.newHash" // +optional - GroupsRef *string `json:"groupsRef,omitempty"` + NewHashField *string `json:"newHashField,omitempty"` - // Group is a single group name for group membership queries + // PauseAnnotation defines which annotation to use for pausing reconciliation + // Default is "crossplane.io/paused" // +optional - Group *string `json:"group,omitempty"` + PauseAnnotation *string `json:"pauseAnnotation,omitempty"` - // GroupRef is a reference to retrieve the group name (e.g., from status or context) - // Overrides Group field if used + // DetailedCondition adds a detailed condition about approval status + // Default is true // +optional - GroupRef *string `json:"groupRef,omitempty"` + DetailedCondition *bool `json:"detailedCondition,omitempty"` - // ServicePrincipals is a list of service principal names + // ApprovalMessage sets a message to display when approval is required + // Default is "Changes detected. Approval required." // +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"` - - // SkipQueryWhenTargetHasData controls whether to skip the query when the target already has data - // Default is false to ensure continuous reconciliation - // +optional - SkipQueryWhenTargetHasData *bool `json:"skipQueryWhenTargetHasData,omitempty"` + ApprovalMessage *string `json:"approvalMessage,omitempty"` } diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index a12618f..838f313 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -13,69 +13,41 @@ func (in *Input) DeepCopyInto(out *Input) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - if in.Users != nil { - in, out := &in.Users, &out.Users - *out = make([]*string, len(*in)) - for i := range *in { - if (*in)[i] != nil { - in, out := &(*in)[i], &(*out)[i] - *out = new(string) - **out = **in - } - } - } - if in.UsersRef != nil { - in, out := &in.UsersRef, &out.UsersRef + if in.HashAlgorithm != nil { + in, out := &in.HashAlgorithm, &out.HashAlgorithm *out = new(string) **out = **in } - if in.Groups != nil { - in, out := &in.Groups, &out.Groups - *out = make([]*string, len(*in)) - for i := range *in { - if (*in)[i] != nil { - in, out := &(*in)[i], &(*out)[i] - *out = new(string) - **out = **in - } - } - } - if in.GroupsRef != nil { - in, out := &in.GroupsRef, &out.GroupsRef + if in.ApprovalField != nil { + in, out := &in.ApprovalField, &out.ApprovalField *out = new(string) **out = **in } - if in.Group != nil { - in, out := &in.Group, &out.Group + if in.OldHashField != nil { + in, out := &in.OldHashField, &out.OldHashField *out = new(string) **out = **in } - if in.GroupRef != nil { - in, out := &in.GroupRef, &out.GroupRef + if in.NewHashField != nil { + in, out := &in.NewHashField, &out.NewHashField *out = new(string) **out = **in } - if in.ServicePrincipals != nil { - in, out := &in.ServicePrincipals, &out.ServicePrincipals - *out = make([]*string, len(*in)) - for i := range *in { - if (*in)[i] != nil { - in, out := &(*in)[i], &(*out)[i] - *out = new(string) - **out = **in - } - } - } - if in.ServicePrincipalsRef != nil { - in, out := &in.ServicePrincipalsRef, &out.ServicePrincipalsRef + if in.PauseAnnotation != nil { + in, out := &in.PauseAnnotation, &out.PauseAnnotation *out = new(string) **out = **in } - if in.SkipQueryWhenTargetHasData != nil { - in, out := &in.SkipQueryWhenTargetHasData, &out.SkipQueryWhenTargetHasData + if in.DetailedCondition != nil { + in, out := &in.DetailedCondition, &out.DetailedCondition *out = new(bool) **out = **in } + if in.ApprovalMessage != nil { + in, out := &in.ApprovalMessage, &out.ApprovalMessage + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Input. diff --git a/main.go b/main.go index 079397c..c630e0e 100644 --- a/main.go +++ b/main.go @@ -1,4 +1,4 @@ -// Package main implements a Composition Function for Microsoft Graph API. +// Package main implements a Composition Function for manual approval in Crossplane. package main import ( @@ -26,8 +26,7 @@ func (c *CLI) Run() error { } return function.Serve(&Function{ - log: log, - graphQuery: &GraphQuery{}, + log: log, }, function.Listen(c.Network, c.Address), function.MTLSCertificates(c.TLSCertsDir), @@ -36,6 +35,6 @@ func (c *CLI) Run() error { } func main() { - ctx := kong.Parse(&CLI{}, kong.Description("A Crossplane Composition Function for Microsoft Graph API queries.")) + ctx := kong.Parse(&CLI{}, kong.Description("A Crossplane Composition Function for manual approval workflow.")) ctx.FatalIfErrorf(ctx.Run()) } diff --git a/package/crossplane.yaml b/package/crossplane.yaml index 2e2dc21..34d19ec 100644 --- a/package/crossplane.yaml +++ b/package/crossplane.yaml @@ -2,15 +2,22 @@ apiVersion: meta.pkg.crossplane.io/v1beta1 kind: Function metadata: - name: function-msgraph + name: function-approve annotations: meta.crossplane.io/maintainer: Upbound - meta.crossplane.io/source: github.com/upbound/function-msgraph + meta.crossplane.io/source: github.com/upbound/function-approve meta.crossplane.io/license: Apache-2.0 - meta.crossplane.io/description: A composition function for querying Microsoft Graph API. + meta.crossplane.io/description: A composition function for implementing manual approval workflows in Crossplane. meta.crossplane.io/readme: | - This composition function allows you to query [Microsoft Graph API](https://learn.microsoft.com/en-us/graph/api/overview?view=graph-rest-1.0) - to validate Azure AD users, get group memberships, group object IDs, and service principal details. + This composition function implements a manual approval workflow for Crossplane resources. + It tracks changes to specified fields using hash comparison, pauses reconciliation + when changes are detected, and requires explicit approval before allowing + reconciliation to continue. - The secret for Azure credentials is compatible with the [Official Azure Provider](https://marketplace.upbound.io/providers/upbound/provider-family-azure/latest). + Key features: + - Field-level change detection using configurable hash algorithms + - Pausing reconciliation via annotations + - Status-based approval mechanism + - Detailed condition reporting + - No external dependencies spec: {} diff --git a/package/input/approve.fn.crossplane.io_inputs.yaml b/package/input/approve.fn.crossplane.io_inputs.yaml new file mode 100644 index 0000000..b154222 --- /dev/null +++ b/package/input/approve.fn.crossplane.io_inputs.yaml @@ -0,0 +1,86 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: inputs.approve.fn.crossplane.io +spec: + group: approve.fn.crossplane.io + names: + categories: + - crossplane + kind: Input + listKind: InputList + plural: inputs + singular: input + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Input can be used to provide input to this Function. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + approvalField: + description: |- + ApprovalField defines the status field to check for the approval decision + Default is "status.approved" + type: string + approvalMessage: + description: |- + ApprovalMessage sets a message to display when approval is required + Default is "Changes detected. Approval required." + type: string + dataField: + description: |- + DataField defines the object field to hash and store for tracking changes + For example: "spec.resources" + type: string + detailedCondition: + description: |- + DetailedCondition adds a detailed condition about approval status + Default is true + type: boolean + hashAlgorithm: + description: |- + HashAlgorithm defines which hash algorithm to use for calculating hashes + Supported values: "md5", "sha256", "sha512" + Default is "sha256" + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + newHashField: + description: |- + NewHashField defines where to store the current hash value + Default is "status.newHash" + type: string + oldHashField: + description: |- + OldHashField defines where to store the previous (approved) hash value + Default is "status.oldHash" + type: string + pauseAnnotation: + description: |- + PauseAnnotation defines which annotation to use for pausing reconciliation + Default is "crossplane.io/paused" + type: string + required: + - dataField + type: object + served: true + storage: true diff --git a/package/input/msgraph.fn.crossplane.io_inputs.yaml b/package/input/msgraph.fn.crossplane.io_inputs.yaml deleted file mode 100644 index 02cb48f..0000000 --- a/package/input/msgraph.fn.crossplane.io_inputs.yaml +++ /dev/null @@ -1,98 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.17.3 - name: inputs.msgraph.fn.crossplane.io -spec: - group: msgraph.fn.crossplane.io - names: - categories: - - crossplane - kind: Input - listKind: InputList - plural: inputs - singular: input - scope: Namespaced - versions: - - name: v1alpha1 - schema: - openAPIV3Schema: - description: Input can be used to provide input to this Function. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - 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. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - queryType: - description: |- - QueryType defines the type of Microsoft Graph API query to perform - Supported values: UserValidation, GroupMembership, GroupObjectIDs, ServicePrincipalDetails - type: string - servicePrincipals: - description: ServicePrincipals is a list of service principal names - 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 - Default is false to ensure continuous reconciliation - type: boolean - target: - description: Target where to store the Query Result - type: string - users: - description: Users is a list of userPrincipalName (email IDs) for user - validation - 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 - type: object - served: true - storage: true