Skip to content

Commit 95e9191

Browse files
author
Yevhen Artemenko
committed
Add 'additionalFields' Field to extract additional data from MSGraph
Signed-off-by: Yevhen Artemenko <eartemenko@playtika.com>
1 parent 6912382 commit 95e9191

7 files changed

Lines changed: 492 additions & 24 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,46 @@ confirmed, `activeAccount: true` excludes the user.
197197
> (rather than `x-kubernetes-preserve-unknown-fields`) need to add
198198
> `accountEnabled` to that schema, otherwise the API server silently prunes it.
199199

200+
### Additional Fields
201+
202+
Use `additionalFields` to include extra Microsoft Graph attributes beyond the default set. Supported for all query types.
203+
204+
| queryType | Default fields | Valid additional fields (examples) |
205+
|---|---|---|
206+
| `UserValidation` | `id`, `displayName`, `userPrincipalName`, `mail` | `city`, `country`, `department`, `jobTitle`, `officeLocation`, `employeeType`, `usageLocation` |
207+
| `GroupObjectIDs` | `id`, `displayName`, `description` | `mailNickname`, `securityEnabled` |
208+
| `ServicePrincipalDetails` | `id`, `appId`, `displayName`, `description` | `servicePrincipalType`, `homepage` |
209+
| `GroupMembership` | `id`, `displayName`, `type`, `mail`, `userPrincipalName`, `appId` | `department`, `jobTitle` (user members only) |
210+
211+
> **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).
212+
213+
```yaml
214+
apiVersion: msgraph.fn.crossplane.io/v1alpha1
215+
kind: Input
216+
queryType: UserValidation
217+
usersRef: "spec.owners"
218+
target: "status.validatedUsers"
219+
additionalFields:
220+
- city
221+
- department
222+
- jobTitle
223+
```
224+
225+
Result:
226+
227+
```yaml
228+
status:
229+
validatedUsers:
230+
- id: 1bbbbbbb-...
231+
displayName: Some Name
232+
userPrincipalName: someName@example.com
233+
mail: someName@example.com
234+
city: Kyiv
235+
department: Ops
236+
jobTitle: Staff Engineer
237+
```
238+
>>>>>>> 75824b3 (Add 'additionalFields' Field to extract additional data from MSGraph)
239+
200240
### Get Group Membership
201241

202242
```yaml
@@ -312,6 +352,7 @@ spec:
312352
| `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. |
313353
| `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. |
314354
| `activeAccount` | bool | Optional. `UserValidation` only. When true, only users whose Entra ID `accountEnabled` attribute is true are stored at the target; disabled users, and users whose account state Graph does not report, are omitted. |
355+
| `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). |
315356
| `identity.type` | string | Optional. Type of identity credentials to use. Valid values: `AzureServicePrincipalCredentials`, `AzureWorkloadIdentityCredentials`. Default is `AzureServicePrincipalCredentials` |
316357

317358
## Result Targets
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
apiVersion: apiextensions.crossplane.io/v1
2+
kind: Composition
3+
metadata:
4+
name: user-validation-additional-fields-example
5+
# Demonstrates the additionalFields feature for UserValidation.
6+
# The extra fields are appended to the default set (id, displayName,
7+
# userPrincipalName, mail) and returned in status.validatedUsers.
8+
#
9+
# Required Azure AD app registration permissions:
10+
# - User.Read.All
11+
# - Directory.Read.All
12+
spec:
13+
compositeTypeRef:
14+
apiVersion: example.crossplane.io/v1
15+
kind: XR
16+
mode: Pipeline
17+
pipeline:
18+
- step: validate-user-with-extra-fields
19+
functionRef:
20+
name: function-msgraph
21+
input:
22+
apiVersion: msgraph.fn.crossplane.io/v1alpha1
23+
kind: Input
24+
queryType: UserValidation
25+
# Replace with actual user principal names from your directory
26+
users:
27+
- "user@example.onmicrosoft.com"
28+
target: "status.validatedUsers"
29+
skipQueryWhenTargetHasData: true
30+
# Extra Microsoft Graph user properties to include in the result.
31+
# These are appended to the default fields: id, displayName,
32+
# userPrincipalName, mail.
33+
# Supported values: any standard Graph user property (camelCase)
34+
# or OData extension attribute (e.g. extension_<appId>_<name>).
35+
# If a field has no value set in Entra ID it is silently omitted
36+
# from the result (Debug log emitted). If the field name is wrong
37+
# an Info log is emitted and the field is omitted.
38+
additionalFields:
39+
- city
40+
- country
41+
- department
42+
- jobTitle
43+
- usageLocation
44+
credentials:
45+
- name: azure-creds
46+
source: Secret
47+
secretRef:
48+
namespace: crossplane-system
49+
name: azure-account-creds

fn.go

Lines changed: 126 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,90 @@ const (
8282
unknownType = "unknown"
8383
)
8484

85+
// callTypedGetter invokes a no-argument getter method on obj by reflection.
86+
// Returns (value, found, hasGetter):
87+
// - hasGetter=false: no method with the expected name/signature exists.
88+
// - hasGetter=true, found=false: method exists but returned nil/zero (field not set).
89+
// - hasGetter=true, found=true: method returned a non-nil/non-zero value.
90+
func callTypedGetter(obj interface{}, methodName string) (interface{}, bool, bool) {
91+
m := reflect.ValueOf(obj).MethodByName(methodName)
92+
if !m.IsValid() || m.Type().NumIn() != 0 || m.Type().NumOut() != 1 {
93+
return nil, false, false
94+
}
95+
rv := m.Call(nil)[0]
96+
if rv.Kind() == reflect.Pointer && !rv.IsNil() {
97+
return rv.Elem().Interface(), true, true
98+
}
99+
if rv.IsValid() && !rv.IsZero() {
100+
return rv.Interface(), true, true
101+
}
102+
return nil, false, true
103+
}
104+
105+
// lookupAdditionalData checks whether field is present in the object's OData
106+
// additional data bag (used for extension attributes not modelled as typed fields).
107+
func lookupAdditionalData(obj interface{}, field string) (interface{}, bool) {
108+
type additionalDataProvider interface {
109+
GetAdditionalData() map[string]interface{}
110+
}
111+
if adder, ok := obj.(additionalDataProvider); ok {
112+
val, exists := adder.GetAdditionalData()[field]
113+
return val, exists
114+
}
115+
return nil, false
116+
}
117+
118+
// applyAdditionalFields extracts each requested field from obj and stores
119+
// found values in m. It is a convenience wrapper around extractTypedOrAdditionalField.
120+
func (g *GraphQuery) applyAdditionalFields(m map[string]interface{}, obj interface{}, objectID string, fields []string) {
121+
for _, field := range fields {
122+
if val, ok := g.extractTypedOrAdditionalField(obj, field, objectID); ok {
123+
m[field] = val
124+
}
125+
}
126+
}
127+
128+
// extractTypedOrAdditionalField resolves a field value from a Microsoft Graph
129+
// SDK model object. It first attempts to call the typed getter derived from
130+
// the field name (e.g. "city" → GetCity(), "jobTitle" → GetJobTitle()) using
131+
// reflection. This is necessary because the kiota-generated SDK deserializes
132+
// known properties into typed struct fields, not into GetAdditionalData().
133+
// If no typed getter exists (e.g. for OData extension attributes), the
134+
// method falls back to GetAdditionalData().
135+
//
136+
// Logging behaviour:
137+
// - Debug: field is a known SDK property but has no value set for this object
138+
// (e.g. user.city is empty in Entra ID) — expected, no action needed.
139+
// - Info: field is not found via typed getter OR additionalData — likely a
140+
// typo or unsupported field name in additionalFields config.
141+
func (g *GraphQuery) extractTypedOrAdditionalField(obj interface{}, field, objectID string) (interface{}, bool) {
142+
if len(field) == 0 {
143+
return nil, false
144+
}
145+
// Build the expected getter name: "city" → "GetCity", "jobTitle" → "GetJobTitle"
146+
methodName := "Get" + strings.ToUpper(field[:1]) + field[1:]
147+
if val, found, hasGetter := callTypedGetter(obj, methodName); hasGetter {
148+
if found {
149+
return val, true
150+
}
151+
// Typed getter exists but returned nil/zero — field is known but not set.
152+
if g.log != nil {
153+
g.log.Debug("additionalFields: field is a valid Graph property but has no value for this object",
154+
"field", field, "objectID", objectID)
155+
}
156+
return nil, false
157+
}
158+
if val, found := lookupAdditionalData(obj, field); found {
159+
return val, true
160+
}
161+
// Field not found anywhere — likely a typo or unsupported field name.
162+
if g.log != nil {
163+
g.log.Info("additionalFields: field not found in Graph SDK model or additionalData — verify the field name in additionalFields config",
164+
"field", field, "objectID", objectID)
165+
}
166+
return nil, false
167+
}
168+
85169
// GraphQueryInterface defines the methods required for querying Microsoft Graph API.
86170
type GraphQueryInterface interface {
87171
graphQuery(ctx context.Context, azureCreds map[string]string, in *v1beta1.Input) (interface{}, error)
@@ -545,9 +629,12 @@ func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.Graph
545629
filterValue := fmt.Sprintf("userPrincipalName eq '%s'", *userPrincipalName)
546630
requestConfig.QueryParameters.Filter = &filterValue
547631

548-
// Use standard fields for user validation. accountEnabled is returned by
549-
// Microsoft Graph only when it is explicitly selected.
550-
requestConfig.QueryParameters.Select = []string{"id", fieldDisplayName, fieldUserPrincipalName, fieldMail, fieldAccountEnabled}
632+
// Use standard fields for user validation, appending any extra fields requested.
633+
// accountEnabled is returned by Microsoft Graph only when explicitly selected.
634+
selectFields := make([]string, 0, 5+len(in.AdditionalFields))
635+
selectFields = append(selectFields, "id", fieldDisplayName, fieldUserPrincipalName, fieldMail, fieldAccountEnabled)
636+
selectFields = append(selectFields, in.AdditionalFields...)
637+
requestConfig.QueryParameters.Select = selectFields
551638

552639
// Execute the query
553640
result, err := client.Users().Get(ctx, requestConfig)
@@ -556,7 +643,7 @@ func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.Graph
556643
}
557644

558645
// Process results
559-
results = append(results, g.buildUserResults(result.GetValue(), requireActiveAccount)...)
646+
results = append(results, g.buildUserResults(result.GetValue(), requireActiveAccount, in.AdditionalFields)...)
560647
}
561648

562649
return results, nil
@@ -567,7 +654,7 @@ func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.Graph
567654
// attribute is not true are omitted. A nil attribute is treated as disabled,
568655
// because Microsoft Graph returns accountEnabled only when it is explicitly
569656
// selected, so an absent value means the account state is unconfirmed.
570-
func (g *GraphQuery) buildUserResults(graphUsers []models.Userable, requireActiveAccount bool) []interface{} {
657+
func (g *GraphQuery) buildUserResults(graphUsers []models.Userable, requireActiveAccount bool, additionalFields []string) []interface{} {
571658
results := make([]interface{}, 0, len(graphUsers))
572659

573660
for _, user := range graphUsers {
@@ -582,13 +669,15 @@ func (g *GraphQuery) buildUserResults(graphUsers []models.Userable, requireActiv
582669
continue
583670
}
584671

585-
results = append(results, map[string]interface{}{
672+
userMap := map[string]interface{}{
586673
"id": ptr.Deref(user.GetId(), ""),
587674
fieldDisplayName: ptr.Deref(user.GetDisplayName(), ""),
588675
fieldUserPrincipalName: ptr.Deref(user.GetUserPrincipalName(), ""),
589676
fieldMail: ptr.Deref(user.GetMail(), ""),
590677
fieldAccountEnabled: accountEnabled,
591-
})
678+
}
679+
g.applyAdditionalFields(userMap, user, ptr.Deref(user.GetId(), "unknown"), additionalFields)
680+
results = append(results, userMap)
592681
}
593682

594683
return results
@@ -619,18 +708,23 @@ func (g *GraphQuery) findGroupByName(ctx context.Context, client *msgraphsdk.Gra
619708
return groupResult.GetValue()[0].GetId(), nil
620709
}
621710

622-
// fetchGroupMembers fetches all members of a group by group ID
623-
func (g *GraphQuery) fetchGroupMembers(ctx context.Context, client *msgraphsdk.GraphServiceClient, groupID string, groupName string) ([]models.DirectoryObjectable, error) {
624-
// Create a request configuration that expands members
625-
// This is the workaround for the known issue where service principals
626-
// are not listed as group members in v1.0
711+
// fetchGroupMembers fetches all members of a group by group ID.
712+
// additionalFields extends the nested $select inside the $expand expression.
713+
func (g *GraphQuery) fetchGroupMembers(ctx context.Context, client *msgraphsdk.GraphServiceClient, groupID string, groupName string, additionalFields []string) ([]models.DirectoryObjectable, error) {
714+
// Build the nested $select list for the $expand workaround.
715+
// The workaround is required because service principals are not listed as
716+
// group members via the standard /members endpoint in v1.0.
627717
// See: https://developer.microsoft.com/en-us/graph/known-issues/?search=25984
718+
memberSelectFields := append(
719+
[]string{"id", "displayName", "mail", "userPrincipalName", "appId"},
720+
additionalFields...,
721+
)
628722
requestConfig := &groups.GroupItemRequestBuilderGetRequestConfiguration{
629723
QueryParameters: &groups.GroupItemRequestBuilderGetQueryParameters{
630-
// Explicitly select the standard member fields via a nested $select so
631-
// that user properties such as mail and userPrincipalName are returned
632-
// for the expanded members (see issue #115).
633-
Expand: []string{"members($select=id,displayName,mail,userPrincipalName,appId)"},
724+
// Explicitly select member fields via a nested $select so that user
725+
// properties such as mail and userPrincipalName are returned for the
726+
// expanded members (see issue #115).
727+
Expand: []string{fmt.Sprintf("members($select=%s)", strings.Join(memberSelectFields, ","))},
634728
},
635729
}
636730

@@ -806,16 +900,17 @@ func (g *GraphQuery) getGroupMembers(ctx context.Context, client *msgraphsdk.Gra
806900
return nil, err
807901
}
808902

809-
// Fetch the members
810-
memberObjects, err := g.fetchGroupMembers(ctx, client, *groupID, groupName)
903+
// Fetch the members, forwarding any extra fields for the nested $select
904+
memberObjects, err := g.fetchGroupMembers(ctx, client, *groupID, groupName, in.AdditionalFields)
811905
if err != nil {
812906
return nil, err
813907
}
814908

815-
// Process the members
909+
// Process the members and attach any additional fields from additionalData
816910
members := make([]interface{}, 0, len(memberObjects))
817911
for _, member := range memberObjects {
818912
memberMap := g.processMember(member)
913+
g.applyAdditionalFields(memberMap, member, ptr.Deref(member.GetId(), "unknown"), in.AdditionalFields)
819914
members = append(members, memberMap)
820915
}
821916

@@ -844,8 +939,11 @@ func (g *GraphQuery) getGroupObjectIDs(ctx context.Context, client *msgraphsdk.G
844939
filterValue := fmt.Sprintf("displayName eq '%s'", *groupName)
845940
requestConfig.QueryParameters.Filter = &filterValue
846941

847-
// Use standard fields for group object IDs
848-
requestConfig.QueryParameters.Select = []string{"id", fieldDisplayName, fieldDescription}
942+
// Use standard fields for group object IDs, appending any extra fields requested
943+
selectFields := make([]string, 0, 3+len(in.AdditionalFields))
944+
selectFields = append(selectFields, "id", fieldDisplayName, fieldDescription)
945+
selectFields = append(selectFields, in.AdditionalFields...)
946+
requestConfig.QueryParameters.Select = selectFields
849947

850948
groupResult, err := client.Groups().Get(ctx, requestConfig)
851949
if err != nil {
@@ -859,6 +957,7 @@ func (g *GraphQuery) getGroupObjectIDs(ctx context.Context, client *msgraphsdk.G
859957
fieldDisplayName: ptr.Deref(group.GetDisplayName(), ""),
860958
fieldDescription: ptr.Deref(group.GetDescription(), ""),
861959
}
960+
g.applyAdditionalFields(groupMap, group, ptr.Deref(group.GetId(), "unknown"), in.AdditionalFields)
862961
results = append(results, groupMap)
863962
}
864963
}
@@ -889,8 +988,11 @@ func (g *GraphQuery) getServicePrincipalDetails(ctx context.Context, client *msg
889988
filterValue := fmt.Sprintf("displayName eq '%s'", *spName)
890989
requestConfig.QueryParameters.Filter = &filterValue
891990

892-
// Use standard fields for service principals
893-
requestConfig.QueryParameters.Select = []string{"id", fieldAppID, fieldDisplayName, fieldDescription}
991+
// Use standard fields for service principals, appending any extra fields requested
992+
selectFields := make([]string, 0, 4+len(in.AdditionalFields))
993+
selectFields = append(selectFields, "id", fieldAppID, fieldDisplayName, fieldDescription)
994+
selectFields = append(selectFields, in.AdditionalFields...)
995+
requestConfig.QueryParameters.Select = selectFields
894996

895997
spResult, err := client.ServicePrincipals().Get(ctx, requestConfig)
896998
if err != nil {
@@ -905,6 +1007,7 @@ func (g *GraphQuery) getServicePrincipalDetails(ctx context.Context, client *msg
9051007
fieldDisplayName: ptr.Deref(sp.GetDisplayName(), ""),
9061008
fieldDescription: ptr.Deref(sp.GetDescription(), ""),
9071009
}
1010+
g.applyAdditionalFields(spMap, sp, ptr.Deref(sp.GetId(), "unknown"), in.AdditionalFields)
9081011
results = append(results, spMap)
9091012
}
9101013
}

0 commit comments

Comments
 (0)