Logic API
Implement the user resolver
Tell Logic API who each caller is, which API roles they hold and the values of the user properties defined in API Studio.
The user resolver connects your sign-in system to the API you designed. API Studio defines what the API knows about a caller: the roles under Context > Roles and the user properties under Context > User. The resolver supplies the values for each caller, from their token or from a lookup. Authorization policies, preconditions and query conditions then use those values.
You write the resolver as a class that implements IWebUserResolver and register it with AddUserResolver<T>().
How it’s called
Section titled “How it’s called”public interface IWebUserResolver{ Task<IWebUser> ResolveUser(IWebUserType webUserType, ClaimsPrincipal claimsPrincipal);}- When: once per request, the first time the API needs to know the caller. The result is kept for the rest of that request.
claimsPrincipal: the signed-in caller, as ASP.NET Core authentication produced it from the request (HttpContext.User). For a request without a valid token, it isn’t authenticated. Authentication must run beforeUseRestServer(), see Create the host project.webUserType: the caller description from your API: its user properties and their types. You don’t build the result yourself; you askwebUserTypeto create it withNew(...).- Lifetime: the resolver is created for each use, and you can inject services into its constructor (a Dataverse connection, a cache, an HTTP client).
What you return
Section titled “What you return”Create the result with one of the New methods of webUserType:
| Method | Use it for |
|---|---|
New(isAuthenticated) |
Anonymous callers (false), or signed-in callers you don’t need to look up. |
New(isAuthenticated, isRegistered) |
Signed-in callers with no user properties or roles. |
New(isAuthenticated, isRegistered, properties) |
Callers with user property values but no roles. |
New(isAuthenticated, isRegistered, properties, roles) |
The full form: user property values and API roles. |
isAuthenticated and isRegistered
Section titled “isAuthenticated and isRegistered”| Meaning | Used by | |
|---|---|---|
isAuthenticated |
The caller signed in. Pass claimsPrincipal.Identity.IsAuthenticated. |
Demand Anonymous User admits only callers with false. Demand Authenticated User requires true. |
isRegistered |
You recognize the caller in your system, for example a contact, an employee or a customer account exists for them. | Demand Authenticated User requires true: a caller who signed in but isn’t registered is refused. |
So a valid token alone isn’t enough for endpoints with Demand Authenticated User. Your resolver decides whether the person behind the token is someone the API serves.
Return the API roles the caller holds as a list of role names, exactly as they’re named under Context > Roles in API Studio (names are compared without regard to case). Demand User Role then admits callers whose list contains that role. API roles are your own names, not Dataverse security roles, so the resolver decides where they come from:
- from the token, for example app roles in Microsoft Entra ID (the
rolesclaim, which ASP.NET Core maps so thatclaimsPrincipal.IsInRole("PartnerAdmin")works); - from your own data, for example “every registered partner contact is a
Partner”; - or a mix of both.
A role name that isn’t defined in the API has no effect.
User properties
Section titled “User properties”Return the user property values as a dictionary keyed by the user property name, exactly as it’s defined under Context > User. Each value must be the .NET type that matches the property’s type:
| User property type | .NET value |
|---|---|
| Text | string |
| Whole Number | int |
| Decimal Number | decimal (an int is also accepted) |
| Currency | decimal (an int is also accepted) |
| Floating Point Number | double |
| Yes/No | bool |
| Date and Time | DateTime |
| Unique Identifier | Guid |
| Lookup | EntityReference (Microsoft.Xrm.Sdk) for a row of the property’s table |
| Choice | int: the option’s value, which must be one of the choice’s options |
Rules to keep in mind:
- An unknown name or a wrong type fails the request. If the dictionary contains a name that isn’t a user property, or a value of the wrong type (for example a
stringfor a Unique Identifier property), creating the user throws an error and the call fails with a server error. Convert claim values (which are always text) to the right type first, for example withGuid.Parse. - Leave out what you don’t know. A user property that isn’t in the dictionary gets its type’s default, empty value. Preconditions should then refuse the call (for example “
accountIdis not empty and equals the record’s account”). - Return only what the caller may see. User property values decide which records a caller can reach through preconditions and query conditions, so take them from trusted sources: your token issuer or your own data, never from request headers or the request body.
A complete resolver
Section titled “A complete resolver”This resolver serves the Partner API example: partner users sign in with Microsoft Entra ID, and each one is a contact in Dataverse whose parent account is the partner company.
using System.Security.Claims;using Flowon.Xrm;using Flowon.Xrm.Api;using Flowon.Xrm.Security;using Microsoft.Extensions.Caching.Memory;using Microsoft.PowerPlatform.Dataverse.Client;using Microsoft.Xrm.Sdk.Query;
namespace PartnerApiHost;
/// <summary>/// Tells Logic API who is calling: whether the caller signed in, whether they are a known/// partner contact, which API roles they hold, and the user properties defined in API Studio./// </summary>public sealed class PartnerUserResolver(IOrganizationServiceAsync2 dataverse, IMemoryCache cache) : IWebUserResolver{ private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
public async Task<IWebUser> ResolveUser(IWebUserType webUserType, ClaimsPrincipal principal) { // 1. Anonymous caller: no roles, no properties. if (principal.Identity?.IsAuthenticated != true) { return webUserType.New(isAuthenticated: false); }
// 2. Signed-in caller: find the matching partner contact (cached per user). var email = principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue(ClaimTypes.Email); var partner = email is null ? null : await cache.GetOrCreateAsync($"partner:{email}", entry => { entry.AbsoluteExpirationRelativeToNow = CacheDuration; return FindPartnerContact(email); });
// 3. Signed in, but not a registered partner. if (partner is null) { return webUserType.New(isAuthenticated: true, isRegistered: false); }
// 4. Registered partner: user properties (names and types as defined in API Studio) and roles. var properties = new Dictionary<string, object> { ["contactId"] = partner.ContactId, ["accountId"] = partner.AccountId, };
var roles = new List<string> { "Partner" }; if (principal.IsInRole("PartnerAdmin")) { roles.Add("PartnerAdmin"); }
return webUserType.New(isAuthenticated: true, isRegistered: true, properties, roles); }
private async Task<PartnerContact?> FindPartnerContact(string email) { var query = new QueryExpression("contact") { ColumnSet = new ColumnSet("contactid", "parentcustomerid"), TopCount = 1, Criteria = { Conditions = { new ConditionExpression("emailaddress1", ConditionOperator.Equal, email), new ConditionExpression("statecode", ConditionOperator.Equal, 0), new ConditionExpression("parentcustomerid", ConditionOperator.NotNull), }, }, };
var result = await dataverse.RetrieveMultipleAsync(query); var contact = result.Entities.FirstOrDefault(); if (contact is null) { return null; }
var account = contact.GetAttributeValue<Microsoft.Xrm.Sdk.EntityReference>("parentcustomerid"); return new PartnerContact(contact.Id, account.Id); }
private sealed record PartnerContact(Guid ContactId, Guid AccountId);}How it maps to the API design:
| In API Studio | In the resolver |
|---|---|
Role Partner |
Every registered partner contact gets it. |
Role PartnerAdmin |
Callers with the PartnerAdmin app role in their token. |
User property contactId (Unique Identifier) |
The contact’s ID, as a Guid. |
User property accountId (Unique Identifier) |
The contact’s parent account, as a Guid. |
| Demand Authenticated User | Admits only callers found as active partner contacts (isRegistered: true). |
The Dataverse connection injected here is the one you created in AddFlowonApi; the resolver reuses it. The lookup result is cached for ten minutes per user, so a busy caller costs one Dataverse query, not one per request. A contact who is deactivated keeps access until the cached entry expires; choose a duration that suits your security requirements.
Common patterns
Section titled “Common patterns”Everything from the token. When your identity provider already issues the values (for example a custom account_id claim), skip the lookup:
var accountId = principal.FindFirstValue("account_id");if (accountId is null){ return webUserType.New(isAuthenticated: true, isRegistered: false);}
return webUserType.New( isAuthenticated: true, isRegistered: true, new Dictionary<string, object> { ["accountId"] = Guid.Parse(accountId) }, principal.FindAll(ClaimTypes.Role).Select(c => c.Value));Public endpoints only. An API whose endpoints all use Demand Anonymous User, or no policy, doesn’t need a resolver: without one, every caller is treated as signed in or not, with no roles and no user properties.
Dataverse calls as the caller
Section titled “Dataverse calls as the caller”By default the host runs every call against Dataverse with its own connection, and your authorization policies, preconditions and query conditions decide what each caller may do. For APIs whose callers are Dataverse users themselves (for example an app for your own technicians), you can make Dataverse apply their own security too. Register the resolver with a factory that returns a connection for the signed-in caller:
builder.Services.AddHttpContextAccessor();
builder.Services .AddFlowonApi("Field Service", "Technician API", _ => new ServiceClient(dataverse)) .AddRestServer() .AddUserResolver<TechnicianUserResolver>(sp => { // Signed-in callers: run Dataverse calls as that Dataverse user. var user = sp.GetRequiredService<IHttpContextAccessor>().HttpContext!.User; var objectId = user.FindFirstValue("http://schemas.microsoft.com/identity/claims/objectidentifier") ?? user.FindFirstValue("oid");
// Clone the host's connection: it reuses the sign-in to Dataverse. var client = ((ServiceClient)sp.GetRequiredService<IOrganizationServiceAsync2>()).Clone(); if (Guid.TryParse(objectId, out var callerObjectId)) { client.CallerAADObjectId = callerObjectId; }
return client; }) .AddMetadataWatcher();- The factory is used only for signed-in callers; anonymous callers keep the host’s own connection.
CallerAADObjectIdmakes Dataverse act as the user with that Microsoft Entra object ID. The caller must exist as a user in the environment, and the host’s application user needs the privilege to act on behalf of other users (Act on Behalf of Another User).
Test the resolver
Section titled “Test the resolver”- Call an endpoint with Demand Anonymous User without a token: it should succeed. Call it with a token: it should be refused, because the caller is signed in.
- Sign in as someone who isn’t a partner contact and call an endpoint with Demand Authenticated User: expect
401withUNAUTHORIZAD_ACCESS. - Sign in as a partner contact: the same call succeeds, and a precondition that compares
accountIdwith the record’s account admits only that partner’s records. - If a call fails with a server error right after you change the resolver, check the user property names and value types against Context > User.