From c5cc9a23b0debced7288e8145c012f72f30117ef Mon Sep 17 00:00:00 2001 From: Eric Barault Date: Tue, 20 Oct 2026 11:00:47 +0200 Subject: [PATCH 1/6] feat: support UserValidation lookups by mail (input/v1beta1/input.go) Signed-off-by: Eric Barault --- input/v1beta1/input.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index bb19a2a..f6d00be 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -23,10 +23,18 @@ type Input struct { // Supported values: UserValidation, GroupMembership, GroupObjectIDs, ServicePrincipalDetails QueryType string `json:"queryType"` - // Users is a list of userPrincipalName (email IDs) for user validation + // Users is a list of user identifiers for user validation, matched against UserLookupField. // +optional Users []*string `json:"users,omitempty"` + // UserLookupField selects the Microsoft Graph property used by UserValidation. + // Defaults to userPrincipalName when omitted. Set to mail to look up users + // whose mail differs from their UPN, such as B2B guests. Mail lookups fail + // when more than one user matches. This field does not affect other query types. + // +kubebuilder:validation:Enum=userPrincipalName;mail + // +optional + UserLookupField string `json:"userLookupField,omitempty"` + // UsersRef is a reference to retrieve the user names (e.g., from status or context) // Overrides Users field if used // +optional From 823f1f820f615d4a7c8dd54f4c910c4a4fa2470e Mon Sep 17 00:00:00 2001 From: Eric Barault Date: Tue, 20 Oct 2026 11:00:51 +0200 Subject: [PATCH 2/6] feat: support UserValidation lookups by mail (fn.go) Signed-off-by: Eric Barault --- fn.go | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/fn.go b/fn.go index 1830c1f..38b0bca 100644 --- a/fn.go +++ b/fn.go @@ -728,18 +728,40 @@ func (g *GraphQuery) graphQuery(ctx context.Context, azureCreds map[string]strin } } -// validateUsers validates if the provided user principal names (emails) exist +// resolveUserLookupField limits filters to supported Graph properties and keeps +// the existing UPN lookup as the default for inputs that omit the new field. +func resolveUserLookupField(field string) (string, error) { + switch field { + case "", fieldUserPrincipalName: + return fieldUserPrincipalName, nil + case fieldMail: + return fieldMail, nil + default: + return "", errors.Errorf("unsupported userLookupField %q: expected userPrincipalName or mail", field) + } +} + +func ambiguousMailLookup(field string, result models.UserCollectionResponseable) bool { + return field == fieldMail && (len(result.GetValue()) > 1 || ptr.Deref(result.GetOdataNextLink(), "") != "") +} + +// validateUsers validates whether the supplied user identifiers exist. func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.GraphServiceClient, in *v1beta1.Input) (interface{}, error) { if in.FailOnEmpty != nil && *in.FailOnEmpty && len(in.Users) == 0 { return nil, errors.New("no users provided for validation") } + lookupField, err := resolveUserLookupField(in.UserLookupField) + if err != nil { + return nil, err + } + results := make([]interface{}, 0) requireActiveAccount := ptr.Deref(in.ActiveAccount, false) validExtra := g.filterSelectFields(in.AdditionalFields, (*models.User)(nil)) - for _, userPrincipalName := range in.Users { - if userPrincipalName == nil { + for _, identifier := range in.Users { + if identifier == nil { continue } @@ -749,7 +771,7 @@ func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.Graph } // Build filter expression - filterValue := fmt.Sprintf("userPrincipalName eq '%s'", *userPrincipalName) + filterValue := fmt.Sprintf("%s eq '%s'", lookupField, strings.ReplaceAll(*identifier, "'", "''")) requestConfig.QueryParameters.Filter = &filterValue // Use standard fields for user validation, appending any extra fields requested. @@ -762,7 +784,13 @@ func (g *GraphQuery) validateUsers(ctx context.Context, client *msgraphsdk.Graph // Execute the query result, err := client.Users().Get(ctx, requestConfig) if err != nil { - return nil, errors.Wrapf(err, "failed to validate user %s", *userPrincipalName) + return nil, errors.Wrapf(err, "failed to validate user %s", *identifier) + } + + // Unlike UPNs, mail addresses are not unique directory identifiers. Never + // turn an ambiguous mail lookup into a grant to multiple principals. + if ambiguousMailLookup(lookupField, result) { + return nil, errors.Errorf("mail lookup for %q matched multiple users", *identifier) } // Process results From 17cd7490000a582ea2966706bcd898c18bb0ed09 Mon Sep 17 00:00:00 2001 From: Eric Barault Date: Tue, 20 Oct 2026 11:00:55 +0200 Subject: [PATCH 3/6] feat: support UserValidation lookups by mail (package/input/msgraph.fn.crossplane.io_inputs.yaml) Signed-off-by: Eric Barault --- package/input/msgraph.fn.crossplane.io_inputs.yaml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/package/input/msgraph.fn.crossplane.io_inputs.yaml b/package/input/msgraph.fn.crossplane.io_inputs.yaml index 9301ada..57859b8 100644 --- a/package/input/msgraph.fn.crossplane.io_inputs.yaml +++ b/package/input/msgraph.fn.crossplane.io_inputs.yaml @@ -130,9 +130,19 @@ spec: target: description: Target where to store the Query Result type: string + userLookupField: + description: |- + UserLookupField selects the Microsoft Graph property used by UserValidation. + Defaults to userPrincipalName when omitted. Set to mail to look up users + whose mail differs from their UPN, such as B2B guests. Mail lookups fail + when more than one user matches. This field does not affect other query types. + enum: + - userPrincipalName + - mail + type: string users: - description: Users is a list of userPrincipalName (email IDs) for user - validation + description: Users is a list of user identifiers for user validation, + matched against UserLookupField. items: type: string type: array From 88e9bc9eccfa4a7d136821d640640c08545df707 Mon Sep 17 00:00:00 2001 From: Eric Barault Date: Tue, 20 Oct 2026 11:00:58 +0200 Subject: [PATCH 4/6] feat: support UserValidation lookups by mail (go.mod) Signed-off-by: Eric Barault --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e99f9a9..606e432 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/alecthomas/kong v1.15.0 github.com/crossplane/function-sdk-go v0.7.1 github.com/google/go-cmp v0.7.0 + github.com/microsoft/kiota-abstractions-go v1.9.3 github.com/microsoft/kiota-authentication-azure-go v1.3.1 github.com/microsoftgraph/msgraph-sdk-go v1.96.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af @@ -58,7 +59,6 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/microsoft/kiota-abstractions-go v1.9.3 // indirect github.com/microsoft/kiota-http-go v1.5.4 // indirect github.com/microsoft/kiota-serialization-form-go v1.1.2 // indirect github.com/microsoft/kiota-serialization-json-go v1.1.2 // indirect From 4b553695eb155e21b381a27c35169756f4b2e037 Mon Sep 17 00:00:00 2001 From: Eric Barault Date: Tue, 20 Oct 2026 11:01:02 +0200 Subject: [PATCH 5/6] feat: support UserValidation lookups by mail (README.md) Signed-off-by: Eric Barault --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 76aa89b..7a3819e 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,38 @@ spec: name: azure-account-creds ``` +#### Look up users by mail + +A B2B guest's `mail` can differ from their tenant-specific `userPrincipalName`. +Set `userLookupField: mail` to resolve the mail address directly without deriving +or maintaining a guest UPN: + +```yaml +queryType: UserValidation +userLookupField: mail +users: + - developer@example.com +target: status.validatedUsers +``` + +This also works with `usersRef`. The default remains `userPrincipalName`, and +the result retains both the actual `mail` and `userPrincipalName` properties. +Only `userPrincipalName` and `mail` are accepted lookup fields. Values are +escaped as OData string literals rather than interpolated as filter expressions. + +Mail addresses are not unique directory identifiers: if a mail query returns +multiple users, the function fails rather than returning multiple principals +for one requested owner. If no user matches, the result remains empty, as for +existing UPN lookups. `failOnEmpty` checks the input list, not missing matches; +consuming compositions must check for the expected number of resolved users +before applying grants. + +Existing `activeAccount`, `queryInterval` and `skipQueryWhenTargetHasData` +behavior is unchanged. Cached results are not automatically invalidated by a +change to the lookup field; when changing it on an existing XR, force a fresh +query (for example by disabling the skip flag for one reconcile and waiting +for any configured query interval). + #### Only Active (Enabled) Accounts By default a user is validated on existence alone, so a disabled (for example @@ -338,7 +370,8 @@ spec: | 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 | +| `users` | []string | List of user identifiers for validation, matched against `userLookupField` | +| `userLookupField` | string | `UserValidation` only. Property to match: `userPrincipalName` (default) or `mail`. Mail queries fail if multiple users match. | | `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`) | From cb356cbd7562b4b9da6dfb5a7045024cbb788d52 Mon Sep 17 00:00:00 2001 From: Eric Barault Date: Tue, 20 Oct 2026 11:01:06 +0200 Subject: [PATCH 6/6] test: cover mail and UPN lookups through Graph SDK Signed-off-by: Eric Barault --- user_lookup_test.go | 125 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 user_lookup_test.go diff --git a/user_lookup_test.go b/user_lookup_test.go new file mode 100644 index 0000000..bbe0574 --- /dev/null +++ b/user_lookup_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/microsoft/kiota-abstractions-go/authentication" + msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go" + "github.com/upbound/function-msgraph/input/v1beta1" + "k8s.io/utils/ptr" + + "github.com/crossplane/function-sdk-go/logging" +) + +// Exercise the actual SDK request and response path without Azure credentials or +// calls to Microsoft Graph. The returned guest's mail deliberately differs +// from its resource-tenant UPN. +const testGuestMail = "guest@example.com" + +func TestValidateUsersLookupField(t *testing.T) { + guest := `{"id":"guest-id","displayName":"Guest","mail":"guest@example.com","userPrincipalName":"guest_example.com#EXT#@tenant.onmicrosoft.com","accountEnabled":true}` + cases := map[string]struct { + field string + identifier string + filter string + body string + nextPage bool + wantCount int + wantError string + }{ + "DefaultUPN": { + identifier: testGuestMail, filter: "userPrincipalName eq 'guest@example.com'", body: guest, wantCount: 1, + }, + "ExplicitUPN": { + field: fieldUserPrincipalName, identifier: testGuestMail, filter: "userPrincipalName eq 'guest@example.com'", body: guest, wantCount: 1, + }, + "MailForB2BGuest": { + field: fieldMail, identifier: testGuestMail, filter: "mail eq 'guest@example.com'", body: guest, wantCount: 1, + }, + "EscapesMailLiteral": { + field: fieldMail, identifier: "o'connor@example.com", filter: "mail eq 'o''connor@example.com'", body: guest, wantCount: 1, + }, + "EscapesUPNLiteral": { + identifier: "o'connor@example.com", filter: "userPrincipalName eq 'o''connor@example.com'", body: guest, wantCount: 1, + }, + "NoMatchPreservesEmptyResult": { + field: fieldMail, identifier: "missing@example.com", filter: "mail eq 'missing@example.com'", + }, + "AmbiguousMail": { + field: fieldMail, identifier: testGuestMail, filter: "mail eq 'guest@example.com'", body: guest + "," + guest, wantError: "matched multiple users", + }, + "PaginatedMailIsNotProvenUnique": { + field: fieldMail, identifier: testGuestMail, filter: "mail eq 'guest@example.com'", body: guest, nextPage: true, wantError: "matched multiple users", + }, + "InvalidField": { + field: "displayName", identifier: "Guest", wantError: "unsupported userLookupField", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if got := r.URL.Query().Get("$filter"); got != tc.filter { + t.Errorf("filter: got %q, want %q", got, tc.filter) + } + if r.Method != http.MethodGet || r.URL.Path != "/users" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + body := `{"value":[` + tc.body + `]` + if tc.nextPage { + body += `,"@odata.nextLink":"https://graph.microsoft.com/v1.0/users?$skiptoken=next"` + } + if _, err := io.WriteString(w, body+`}`); err != nil { + t.Error(err) + } + })) + defer server.Close() + adapter, err := msgraphsdk.NewGraphRequestAdapter(&authentication.AnonymousAuthenticationProvider{}) + if err != nil { + t.Fatal(err) + } + adapter.SetBaseUrl(server.URL) + client := msgraphsdk.NewGraphServiceClient(adapter) + g := &GraphQuery{log: logging.NewNopLogger()} + result, err := g.validateUsers(context.Background(), client, &v1beta1.Input{ + Users: []*string{ptr.To(tc.identifier)}, UserLookupField: tc.field, + }) + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("got error %v, want %q", err, tc.wantError) + } + if tc.field == "displayName" && calls != 0 { + t.Fatal("invalid lookup field must fail before any Graph request") + } + return + } + if err != nil { + t.Fatal(err) + } + rows, ok := result.([]interface{}) + if !ok || len(rows) != tc.wantCount { + t.Fatalf("got %#v, want %d users", result, tc.wantCount) + } + if calls != 1 { + t.Fatalf("got %d Graph requests, want 1", calls) + } + if tc.wantCount == 1 { + want := map[string]interface{}{ + "id": "guest-id", "displayName": "Guest", fieldMail: testGuestMail, + "userPrincipalName": "guest_example.com#EXT#@tenant.onmicrosoft.com", "accountEnabled": true, + } + if diff := cmp.Diff(want, rows[0]); diff != "" { + t.Errorf("result mismatch (-want +got):\n%s", diff) + } + } + }) + } +}