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
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`) |
Expand Down
38 changes: 33 additions & 5 deletions fn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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.
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion input/v1beta1/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions package/input/msgraph.fn.crossplane.io_inputs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions user_lookup_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}