From 75824b34b54f8fc1f336a996ae257403c391e962 Mon Sep 17 00:00:00 2001 From: Yevhen Artemenko Date: Mon, 27 Jul 2026 08:08:21 +0300 Subject: [PATCH] Add 'additionalFields' Field to extract additional data from MSGraph Signed-off-by: Yevhen Artemenko --- README.md | 40 +++ ...-validation-additional-fields-example.yaml | 49 ++++ fn.go | 138 +++++++-- fn_test.go | 264 +++++++++++++++++- input/v1beta1/input.go | 10 + input/v1beta1/zz_generated.deepcopy.go | 5 + .../msgraph.fn.crossplane.io_inputs.yaml | 12 + 7 files changed, 493 insertions(+), 25 deletions(-) create mode 100644 example/user-validation-additional-fields-example.yaml diff --git a/README.md b/README.md index 6b4cdfd..892fcb2 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,45 @@ spec: name: azure-account-creds ``` +### Additional Fields + +Use `additionalFields` to include extra Microsoft Graph attributes beyond the default set. Supported for all query types. + +| queryType | Default fields | Valid additional fields (examples) | +|---|---|---| +| `UserValidation` | `id`, `displayName`, `userPrincipalName`, `mail` | `city`, `country`, `department`, `jobTitle`, `officeLocation`, `employeeType`, `usageLocation` | +| `GroupObjectIDs` | `id`, `displayName`, `description` | `mailNickname`, `securityEnabled` | +| `ServicePrincipalDetails` | `id`, `appId`, `displayName`, `description` | `servicePrincipalType`, `homepage` | +| `GroupMembership` | `id`, `displayName`, `type`, `mail`, `userPrincipalName`, `appId` | `department`, `jobTitle` (user members only) | + +> **Note:** Field names must match the Microsoft Graph API property name exactly (camelCase). Unknown field names are skipped with an `Info` log entry in the function pod. Fields that exist in Graph API but have no value for a given object are silently skipped (visible at `Debug` log level). + +```yaml +apiVersion: msgraph.fn.crossplane.io/v1alpha1 +kind: Input +queryType: UserValidation +usersRef: "spec.owners" +target: "status.validatedUsers" +additionalFields: + - city + - department + - jobTitle +``` + +Result: + +```yaml +status: + validatedUsers: + - id: 1bbbbbbb-... + displayName: Some Name + userPrincipalName: someName@example.com + mail: someName@example.com + city: Kyiv + department: Ops + jobTitle: Staff Engineer +``` + ### Get Group Membership ```yaml @@ -277,6 +316,7 @@ spec: | `skipQueryWhenTargetHasData` | bool | Optional. When true, will skip the query if the target already has data | | `queryInterval` | string | Optional. Minimum interval between queries as a Go duration string (e.g. `10m`, `1h`, `90s`). Skips querying Microsoft Graph until the interval has elapsed since the last successful query, independent of reconcile frequency. Only effective in Composition mode with a `status.` target. | | `FailOnEmpty` | bool | Optional. When true, the function will fail if the `users`, `groups`, or `servicePrincipals` lists are empty, or if their respective reference fields are empty lists. | +| `additionalFields` | []string | Optional. Extra Microsoft Graph fields to include in results. Supported for all query types. Appended to the default field set for each type (see [Additional Fields](#additional-fields) section). | | `identity.type` | string | Optional. Type of identity credentials to use. Valid values: `AzureServicePrincipalCredentials`, `AzureWorkloadIdentityCredentials`. Default is `AzureServicePrincipalCredentials` | ## Result Targets diff --git a/example/user-validation-additional-fields-example.yaml b/example/user-validation-additional-fields-example.yaml new file mode 100644 index 0000000..12be20e --- /dev/null +++ b/example/user-validation-additional-fields-example.yaml @@ -0,0 +1,49 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: user-validation-additional-fields-example +# Demonstrates the additionalFields feature for UserValidation. +# The extra fields are appended to the default set (id, displayName, +# userPrincipalName, mail) and returned in status.validatedUsers. +# +# Required Azure AD app registration permissions: +# - User.Read.All +# - Directory.Read.All +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: validate-user-with-extra-fields + functionRef: + name: function-msgraph + input: + apiVersion: msgraph.fn.crossplane.io/v1alpha1 + kind: Input + queryType: UserValidation + # Replace with actual user principal names from your directory + users: + - "user@example.onmicrosoft.com" + target: "status.validatedUsers" + skipQueryWhenTargetHasData: true + # Extra Microsoft Graph user properties to include in the result. + # These are appended to the default fields: id, displayName, + # userPrincipalName, mail. + # Supported values: any standard Graph user property (camelCase) + # or OData extension attribute (e.g. extension__). + # If a field has no value set in Entra ID it is silently omitted + # from the result (Debug log emitted). If the field name is wrong + # an Info log is emitted and the field is omitted. + additionalFields: + - city + - country + - department + - jobTitle + - usageLocation + credentials: + - name: azure-creds + source: Secret + secretRef: + namespace: crossplane-system + name: azure-account-creds diff --git a/fn.go b/fn.go index df9a5ed..8fb8a36 100644 --- a/fn.go +++ b/fn.go @@ -81,6 +81,90 @@ const ( unknownType = "unknown" ) +// callTypedGetter invokes a no-argument getter method on obj by reflection. +// Returns (value, found, hasGetter): +// - hasGetter=false: no method with the expected name/signature exists. +// - hasGetter=true, found=false: method exists but returned nil/zero (field not set). +// - hasGetter=true, found=true: method returned a non-nil/non-zero value. +func callTypedGetter(obj interface{}, methodName string) (interface{}, bool, bool) { + m := reflect.ValueOf(obj).MethodByName(methodName) + if !m.IsValid() || m.Type().NumIn() != 0 || m.Type().NumOut() != 1 { + return nil, false, false + } + rv := m.Call(nil)[0] + if rv.Kind() == reflect.Pointer && !rv.IsNil() { + return rv.Elem().Interface(), true, true + } + if rv.IsValid() && !rv.IsZero() { + return rv.Interface(), true, true + } + return nil, false, true +} + +// lookupAdditionalData checks whether field is present in the object's OData +// additional data bag (used for extension attributes not modelled as typed fields). +func lookupAdditionalData(obj interface{}, field string) (interface{}, bool) { + type additionalDataProvider interface { + GetAdditionalData() map[string]interface{} + } + if adder, ok := obj.(additionalDataProvider); ok { + val, exists := adder.GetAdditionalData()[field] + return val, exists + } + return nil, false +} + +// applyAdditionalFields extracts each requested field from obj and stores +// found values in m. It is a convenience wrapper around extractTypedOrAdditionalField. +func (g *GraphQuery) applyAdditionalFields(m map[string]interface{}, obj interface{}, objectID string, fields []string) { + for _, field := range fields { + if val, ok := g.extractTypedOrAdditionalField(obj, field, objectID); ok { + m[field] = val + } + } +} + +// extractTypedOrAdditionalField resolves a field value from a Microsoft Graph +// SDK model object. It first attempts to call the typed getter derived from +// the field name (e.g. "city" → GetCity(), "jobTitle" → GetJobTitle()) using +// reflection. This is necessary because the kiota-generated SDK deserializes +// known properties into typed struct fields, not into GetAdditionalData(). +// If no typed getter exists (e.g. for OData extension attributes), the +// method falls back to GetAdditionalData(). +// +// Logging behaviour: +// - Debug: field is a known SDK property but has no value set for this object +// (e.g. user.city is empty in Entra ID) — expected, no action needed. +// - Info: field is not found via typed getter OR additionalData — likely a +// typo or unsupported field name in additionalFields config. +func (g *GraphQuery) extractTypedOrAdditionalField(obj interface{}, field, objectID string) (interface{}, bool) { + if len(field) == 0 { + return nil, false + } + // Build the expected getter name: "city" → "GetCity", "jobTitle" → "GetJobTitle" + methodName := "Get" + strings.ToUpper(field[:1]) + field[1:] + if val, found, hasGetter := callTypedGetter(obj, methodName); hasGetter { + if found { + return val, true + } + // Typed getter exists but returned nil/zero — field is known but not set. + if g.log != nil { + g.log.Debug("additionalFields: field is a valid Graph property but has no value for this object", + "field", field, "objectID", objectID) + } + return nil, false + } + if val, found := lookupAdditionalData(obj, field); found { + return val, true + } + // Field not found anywhere — likely a typo or unsupported field name. + if g.log != nil { + g.log.Info("additionalFields: field not found in Graph SDK model or additionalData — verify the field name in additionalFields config", + "field", field, "objectID", objectID) + } + return nil, false +} + // 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) @@ -543,8 +627,11 @@ func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.Graph filterValue := fmt.Sprintf("userPrincipalName eq '%s'", *userPrincipalName) requestConfig.QueryParameters.Filter = &filterValue - // Use standard fields for user validation - requestConfig.QueryParameters.Select = []string{"id", fieldDisplayName, fieldUserPrincipalName, fieldMail} + // Use standard fields for user validation, appending any extra fields requested + selectFields := make([]string, 0, 4+len(in.AdditionalFields)) + selectFields = append(selectFields, "id", fieldDisplayName, fieldUserPrincipalName, fieldMail) + selectFields = append(selectFields, in.AdditionalFields...) + requestConfig.QueryParameters.Select = selectFields // Execute the query result, err := client.Users().Get(ctx, requestConfig) @@ -561,6 +648,7 @@ func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.Graph fieldUserPrincipalName: ptr.Deref(user.GetUserPrincipalName(), ""), fieldMail: ptr.Deref(user.GetMail(), ""), } + g.applyAdditionalFields(userMap, user, ptr.Deref(user.GetId(), "unknown"), in.AdditionalFields) results = append(results, userMap) } } @@ -594,18 +682,23 @@ func (g *GraphQuery) findGroupByName(ctx context.Context, client *msgraphsdk.Gra 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 +// fetchGroupMembers fetches all members of a group by group ID. +// additionalFields extends the nested $select inside the $expand expression. +func (g *GraphQuery) fetchGroupMembers(ctx context.Context, client *msgraphsdk.GraphServiceClient, groupID string, groupName string, additionalFields []string) ([]models.DirectoryObjectable, error) { + // Build the nested $select list for the $expand workaround. + // The workaround is required because service principals are not listed as + // group members via the standard /members endpoint in v1.0. // See: https://developer.microsoft.com/en-us/graph/known-issues/?search=25984 + memberSelectFields := append( + []string{"id", "displayName", "mail", "userPrincipalName", "appId"}, + additionalFields..., + ) requestConfig := &groups.GroupItemRequestBuilderGetRequestConfiguration{ QueryParameters: &groups.GroupItemRequestBuilderGetQueryParameters{ - // Explicitly select the standard member fields via a nested $select so - // that user properties such as mail and userPrincipalName are returned - // for the expanded members (see issue #115). - Expand: []string{"members($select=id,displayName,mail,userPrincipalName,appId)"}, + // Explicitly select member fields via a nested $select so that user + // properties such as mail and userPrincipalName are returned for the + // expanded members (see issue #115). + Expand: []string{fmt.Sprintf("members($select=%s)", strings.Join(memberSelectFields, ","))}, }, } @@ -781,16 +874,17 @@ func (g *GraphQuery) getGroupMembers(ctx context.Context, client *msgraphsdk.Gra return nil, err } - // Fetch the members - memberObjects, err := g.fetchGroupMembers(ctx, client, *groupID, groupName) + // Fetch the members, forwarding any extra fields for the nested $select + memberObjects, err := g.fetchGroupMembers(ctx, client, *groupID, groupName, in.AdditionalFields) if err != nil { return nil, err } - // Process the members + // Process the members and attach any additional fields from additionalData members := make([]interface{}, 0, len(memberObjects)) for _, member := range memberObjects { memberMap := g.processMember(member) + g.applyAdditionalFields(memberMap, member, ptr.Deref(member.GetId(), "unknown"), in.AdditionalFields) members = append(members, memberMap) } @@ -819,8 +913,11 @@ func (g *GraphQuery) getGroupObjectIDs(ctx context.Context, client *msgraphsdk.G filterValue := fmt.Sprintf("displayName eq '%s'", *groupName) requestConfig.QueryParameters.Filter = &filterValue - // Use standard fields for group object IDs - requestConfig.QueryParameters.Select = []string{"id", fieldDisplayName, fieldDescription} + // Use standard fields for group object IDs, appending any extra fields requested + selectFields := make([]string, 0, 3+len(in.AdditionalFields)) + selectFields = append(selectFields, "id", fieldDisplayName, fieldDescription) + selectFields = append(selectFields, in.AdditionalFields...) + requestConfig.QueryParameters.Select = selectFields groupResult, err := client.Groups().Get(ctx, requestConfig) if err != nil { @@ -834,6 +931,7 @@ func (g *GraphQuery) getGroupObjectIDs(ctx context.Context, client *msgraphsdk.G fieldDisplayName: ptr.Deref(group.GetDisplayName(), ""), fieldDescription: ptr.Deref(group.GetDescription(), ""), } + g.applyAdditionalFields(groupMap, group, ptr.Deref(group.GetId(), "unknown"), in.AdditionalFields) results = append(results, groupMap) } } @@ -864,8 +962,11 @@ func (g *GraphQuery) getServicePrincipalDetails(ctx context.Context, client *msg filterValue := fmt.Sprintf("displayName eq '%s'", *spName) requestConfig.QueryParameters.Filter = &filterValue - // Use standard fields for service principals - requestConfig.QueryParameters.Select = []string{"id", fieldAppID, fieldDisplayName, fieldDescription} + // Use standard fields for service principals, appending any extra fields requested + selectFields := make([]string, 0, 4+len(in.AdditionalFields)) + selectFields = append(selectFields, "id", fieldAppID, fieldDisplayName, fieldDescription) + selectFields = append(selectFields, in.AdditionalFields...) + requestConfig.QueryParameters.Select = selectFields spResult, err := client.ServicePrincipals().Get(ctx, requestConfig) if err != nil { @@ -880,6 +981,7 @@ func (g *GraphQuery) getServicePrincipalDetails(ctx context.Context, client *msg fieldDisplayName: ptr.Deref(sp.GetDisplayName(), ""), fieldDescription: ptr.Deref(sp.GetDescription(), ""), } + g.applyAdditionalFields(spMap, sp, ptr.Deref(sp.GetId(), "unknown"), in.AdditionalFields) results = append(results, spMap) } } diff --git a/fn_test.go b/fn_test.go index 9b53a3e..eb74a70 100644 --- a/fn_test.go +++ b/fn_test.go @@ -22,13 +22,16 @@ import ( const ( // Repeated fixture values used across the table-driven tests. - testRequestTag = "hello" - testCredentialsKey = "credentials" - testAzureCredsName = "azure-creds" - testSPID1 = "sp-id-1" - testUser1Email = "user1@example.com" - testUser2Email = "user2@example.com" - watchedResourceKey = "ops.crossplane.io/watched-resource" + testRequestTag = "hello" + testCredentialsKey = "credentials" + testAzureCredsName = "azure-creds" + testSPID1 = "sp-id-1" + testUser1Email = "user1@example.com" + testUser2Email = "user2@example.com" + testExampleEmail = "user@example.com" + testUserDisplayName = "Test User" + testUserID1 = "user-id-1" + watchedResourceKey = "ops.crossplane.io/watched-resource" // Condition fields asserted on successful function responses. condTypeFunctionSuccess = "FunctionSuccess" @@ -4208,6 +4211,253 @@ func TestIdentityType(t *testing.T) { } } +// TestAdditionalFields verifies that AdditionalFields is propagated to the graph +// query and that extra fields appear in the RunFunction response for every +// supported queryType (UserValidation, GroupObjectIDs, ServicePrincipalDetails, +// GroupMembership). +func TestAdditionalFields(t *testing.T) { + xr := `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` + creds := &fnv1.CredentialData{ + Data: map[string][]byte{ + testCredentialsKey: []byte(`{ +"clientId": "test-client-id", +"clientSecret": "test-client-secret", +"subscriptionId": "test-subscription-id", +"tenantId": "test-tenant-id" +}`), + }, + } + + cases := map[string]struct { + reason string + inputJSON string + wantExtraFields []string + wantStatusPayload string + // mockResult is the value the mock graphQuery returns for this case + mockResult func(in *v1beta1.Input) (interface{}, error) + }{ + // ── UserValidation ──────────────────────────────────────────────────────── + "UserValidationWithAdditionalFields": { + reason: "additionalFields must be forwarded and appear in UserValidation results", + inputJSON: `{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "users": ["user@example.com"], + "target": "status.validatedUsers", + "additionalFields": ["city", "department"] + }`, + wantExtraFields: []string{"city", "department"}, + wantStatusPayload: `{"validatedUsers":[{ + "id":"test-user-id","displayName":"Test User", + "userPrincipalName":"user@example.com","mail":"user@example.com", + "city":"Kyiv","department":"Tech Ops" + }]}`, + mockResult: func(in *v1beta1.Input) (interface{}, error) { + m := map[string]interface{}{ + "id": "test-user-id", fieldDisplayName: testUserDisplayName, + fieldUserPrincipalName: testExampleEmail, fieldMail: testExampleEmail, + } + for _, f := range in.AdditionalFields { + switch f { + case "city": + m["city"] = "Kyiv" + case "department": + m["department"] = "Tech Ops" + } + } + return []interface{}{m}, nil + }, + }, + "UserValidationBackwardCompat": { + reason: "omitting additionalFields must yield only the default four fields", + inputJSON: `{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "UserValidation", + "users": ["user@example.com"], + "target": "status.validatedUsers" + }`, + wantExtraFields: nil, + wantStatusPayload: `{"validatedUsers":[{ + "id":"test-user-id","displayName":"Test User", + "userPrincipalName":"user@example.com","mail":"user@example.com" + }]}`, + mockResult: func(_ *v1beta1.Input) (interface{}, error) { + return []interface{}{map[string]interface{}{ + "id": "test-user-id", fieldDisplayName: testUserDisplayName, + fieldUserPrincipalName: testExampleEmail, fieldMail: testExampleEmail, + }}, nil + }, + }, + // ── GroupObjectIDs ─────────────────────────────────────────────────────── + "GroupObjectIDsWithAdditionalFields": { + reason: "additionalFields must be forwarded and appear in GroupObjectIDs results", + inputJSON: `{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupObjectIDs", + "groups": ["Developers"], + "target": "status.groupObjectIDs", + "additionalFields": ["mailNickname", "visibility"] + }`, + wantExtraFields: []string{"mailNickname", "visibility"}, + wantStatusPayload: `{"groupObjectIDs":[{ + "id":"group-id-1","displayName":"Developers","description":"Dev team", + "mailNickname":"devs","visibility":"Private" + }]}`, + mockResult: func(in *v1beta1.Input) (interface{}, error) { + m := map[string]interface{}{ + "id": "group-id-1", fieldDisplayName: "Developers", fieldDescription: "Dev team", + } + for _, f := range in.AdditionalFields { + switch f { + case "mailNickname": + m["mailNickname"] = "devs" + case "visibility": + m["visibility"] = "Private" + } + } + return []interface{}{m}, nil + }, + }, + // ── ServicePrincipalDetails ────────────────────────────────────────────── + "ServicePrincipalDetailsWithAdditionalFields": { + reason: "additionalFields must be forwarded and appear in ServicePrincipalDetails results", + inputJSON: `{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "ServicePrincipalDetails", + "servicePrincipals": ["MyApp"], + "target": "status.spDetails", + "additionalFields": ["servicePrincipalType", "homepage"] + }`, + wantExtraFields: []string{"servicePrincipalType", "homepage"}, + wantStatusPayload: `{"spDetails":[{ + "id":"sp-id-1","appId":"app-id-1","displayName":"MyApp","description":"", + "servicePrincipalType":"Application","homepage":"https://myapp.example.com" + }]}`, + mockResult: func(in *v1beta1.Input) (interface{}, error) { + m := map[string]interface{}{ + "id": "sp-id-1", fieldAppID: "app-id-1", + fieldDisplayName: "MyApp", fieldDescription: "", + } + for _, f := range in.AdditionalFields { + switch f { + case "servicePrincipalType": + m["servicePrincipalType"] = "Application" + case "homepage": + m["homepage"] = "https://myapp.example.com" + } + } + return []interface{}{m}, nil + }, + }, + // ── GroupMembership ────────────────────────────────────────────────────── + "GroupMembershipWithAdditionalFields": { + reason: "additionalFields must be forwarded and appear in GroupMembership member results", + inputJSON: `{ + "apiVersion": "msgraph.fn.crossplane.io/v1alpha1", + "kind": "Input", + "queryType": "GroupMembership", + "group": "Developers", + "target": "status.members", + "additionalFields": ["department", "jobTitle"] + }`, + wantExtraFields: []string{"department", "jobTitle"}, + wantStatusPayload: `{"members":[{ + "id":"user-id-1","displayName":"Test User","type":"user", + "mail":"user1@example.com","userPrincipalName":"user1@example.com", + "department":"Engineering","jobTitle":"Staff Engineer" + }]}`, + mockResult: func(in *v1beta1.Input) (interface{}, error) { + m := map[string]interface{}{ + "id": testUserID1, fieldDisplayName: testUserDisplayName, fieldType: userType, + fieldMail: testUser1Email, fieldUserPrincipalName: testUser1Email, + } + for _, f := range in.AdditionalFields { + switch f { + case "department": + m["department"] = "Engineering" + case "jobTitle": + m["jobTitle"] = "Staff Engineer" + } + } + return []interface{}{m}, nil + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var capturedAdditionalFields []string + + mockQuery := &MockGraphQuery{ + GraphQueryFunc: func(_ context.Context, _ map[string]string, in *v1beta1.Input) (interface{}, error) { + capturedAdditionalFields = in.AdditionalFields + return tc.mockResult(in) + }, + } + + f := &Function{ + graphQuery: mockQuery, + timer: &MockTimer{}, + log: logging.NewNopLogger(), + } + + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: testRequestTag}, + Input: resource.MustStructJSON(tc.inputJSON), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Credentials: map[string]*fnv1.Credentials{ + testAzureCredsName: { + Source: &fnv1.Credentials_CredentialData{CredentialData: creds}, + }, + }, + } + + rsp, err := f.RunFunction(context.Background(), req) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tc.reason, err) + } + + // AdditionalFields must be forwarded to the mock as-is. + if diff := cmp.Diff(tc.wantExtraFields, capturedAdditionalFields, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\nAdditionalFields forwarded to query: -want +got:\n%s", tc.reason, diff) + } + + // FunctionSuccess condition must be true. + var successCond *fnv1.Condition + for _, c := range rsp.GetConditions() { + if c.GetType() == condTypeFunctionSuccess { + successCond = c + break + } + } + if successCond == nil || successCond.GetStatus() != fnv1.Status_STATUS_CONDITION_TRUE { + t.Errorf("%s: expected FunctionSuccess=True condition, got: %v", tc.reason, rsp.GetConditions()) + } + + // Desired XR status must match expected payload. + wantDesired := resource.MustStructJSON(`{ + "apiVersion": "example.org/v1", + "kind": "XR", + "metadata": {"name": "cool-xr"}, + "spec": {"count": 2}, + "status": ` + tc.wantStatusPayload + ` + }`) + gotDesired := rsp.GetDesired().GetComposite().GetResource() + if diff := cmp.Diff(wantDesired, gotDesired, protocmp.Transform()); diff != "" { + t.Errorf("%s\ndesired XR: -want +got:\n%s", tc.reason, diff) + } + }) + } +} + // newTestUser builds a typed user directory object, mirroring how the Graph SDK // deserializes expanded group members (the values land on the typed struct, not // in additionalData). diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index e290b13..c1c078f 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -86,6 +86,16 @@ type Input struct { // +optional FailOnEmpty *bool `json:"failOnEmpty,omitempty"` + // AdditionalFields is a list of extra Microsoft Graph fields to include in query results. + // Supported for: UserValidation, GroupObjectIDs, ServicePrincipalDetails, GroupMembership. + // Fields are appended to the default set for each query type, e.g.: + // UserValidation default: id, displayName, userPrincipalName, mail + // GroupObjectIDs default: id, displayName, description + // ServicePrincipalDetails default: id, appId, displayName, description + // GroupMembership default: id, displayName, type, mail, userPrincipalName, appId + // +optional + AdditionalFields []string `json:"additionalFields,omitempty"` + // Identity defines the type of identity used for authentication to the Microsoft Graph API. Identity *Identity `json:"identity,omitempty"` } diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index 0031c7b..a1f3def 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -101,6 +101,11 @@ func (in *Input) DeepCopyInto(out *Input) { *out = new(bool) **out = **in } + if in.AdditionalFields != nil { + in, out := &in.AdditionalFields, &out.AdditionalFields + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.Identity != nil { in, out := &in.Identity, &out.Identity *out = new(Identity) diff --git a/package/input/msgraph.fn.crossplane.io_inputs.yaml b/package/input/msgraph.fn.crossplane.io_inputs.yaml index c1c25f5..b8bec4f 100644 --- a/package/input/msgraph.fn.crossplane.io_inputs.yaml +++ b/package/input/msgraph.fn.crossplane.io_inputs.yaml @@ -21,6 +21,18 @@ spec: openAPIV3Schema: description: Input can be used to provide input to this Function. properties: + additionalFields: + description: |- + AdditionalFields is a list of extra Microsoft Graph fields to include in query results. + Supported for: UserValidation, GroupObjectIDs, ServicePrincipalDetails, GroupMembership. + Fields are appended to the default set for each query type, e.g.: + UserValidation default: id, displayName, userPrincipalName, mail + GroupObjectIDs default: id, displayName, description + ServicePrincipalDetails default: id, appId, displayName, description + GroupMembership default: id, displayName, type, mail, userPrincipalName, appId + items: + type: string + type: array apiVersion: description: |- APIVersion defines the versioned schema of this representation of an object.