Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions example/user-validation-additional-fields-example.yaml
Original file line number Diff line number Diff line change
@@ -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_<appId>_<name>).
# 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
138 changes: 120 additions & 18 deletions fn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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, ","))},
},
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
Expand Down
Loading