Search API Sample

A sample is provided that implements the user and identity role search integration API protocol. It is implemented using ASP.NET Core, and uses a middleware that encapsulates most of the protocol details. The only custom code required is implementing two interfaces (one for users, and another for identity roles) that performs the actual search in the external store.

Hosting

The sample is based on an empty ASP.NET Core hosting application. It references the PolicyServer.Management.Api.Abstractions NuGet packages, which contains the middleware.

The Startup code then looks something like:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddPolicyServerSearchApi(new PolicyServerSearchApiOptions {
            Authority = "https://your_policy_server/",
        })
        .AddUserSearchService<YourUserSearchClassName>()
        .AddRoleSearchService<YourIdentityRoleSearchClassName>();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UsePolicyServerSearchApi();
    }
}

AddPolicyServerSearchApi will add the services to implement the search protocol. The PolicyServerSearchApiOptions parameter allows configuring the Authority which should be the base URL of the PolicyServer authority. This is used to validate the token passed for authentication.

AddUserSearchService and AddRoleSearchService allow configuring your implementations of the user and identity role search services.

UsePolicyServerSearchApi configures the middleware in the pipeline to handle requests for the paths /users and /roles.

Search Interfaces

To implement the user and identity role search, you would need to implement the IUserSearchService and IRoleSearchService interfaces, respectively. The definitions are:

public interface IUserSearchService
{
    Task<QueryResult<UserSearchResult>> SearchUsers(UserSearch query);
}

public interface IRoleSearchService
{
    Task<QueryResult<RoleSearchResult>> SearchRoles(RoleSearch query);
}

The UserSearch and RoleSearch hold the request parameters:

public class Query
{
    public Query(string filter, int page = 1, int count = 20);

    public string Filter { get; set; }
    public int Count { get; set; }
    public int Page { get; set; }
    public int Start { get; }
}

public class UserSearch : Query
{
    public string Tenant { get; }
}

public class RoleSearch : Query
{
    public string Tenant { get; }
}

The Query base class constructor handles the logic to determine the starting row number based on the page and count requested.

And the QueryResult<UserSearchResult> and QueryResult<RoleSearchResult> model the results:

public class QueryResult<T>
{
    public QueryResult(Query query, int totalCount, IEnumerable<T> results);

    public int TotalCount { get; set; }
    public int TotalPages { get; }
    public IEnumerable<T> Items { get; set; }
}

The QueryResult<T> constructor takes care of the logic of determining the total pages based on the query.

With the UserSearchResult and RoleSearchResult definitions:

public class UserSearchResult
{
    public string SubjectId { get; set; }
    public string DisplayName { get; set; }
}

public class RoleSearchResult
{
    public string RoleName { get; set; }
    public string Description { get; set; }
}

A sample implementation that uses ASP.NET Identity might look something like this:

public class AspNetIdentitySearch : IUserSearchService, IRoleSearchService
{
    private readonly IdentityDbContext _identityDb;

    public AspNetIdentitySearch(IdentityDbContext identityDb)
    {
        _identityDb = identityDb;
    }

    public async Task<QueryResult<UserSearchResult>> SearchUsers(UserSearch query)
    {
        var filter = query.Filter;
        var users = _identityDb.Users;

        var q =
           from user in users
           where user.UserName.Contains(filter) ||
                 user.Email.Contains(filter) ||
                 filter == null
           select new UserSearchResult
           {
               SubjectId = user.Id,
               DisplayName = $"{user.UserName} ({user.Email})",
           };

        var totalCount = await q.CountAsync();
        var items = await q.Skip(query.Start).Take(query.Count).ToArrayAsync();

        var result = new QueryResult<UserSearchResult>(query, totalCount, items);
        return result;
    }

    public async Task<QueryResult<RoleSearchResult>> SearchRoles(RoleSearch query)
    {
        var filter = query.Filter;
        var roles = _identityDb.Roles;

        var q =
           from role in roles
           where role.Name.Contains(filter) || filter == null
           select new RoleSearchResult
           {
               RoleName = role.Name
           };

        var totalCount = await q.CountAsync();
        var items = await q.Skip(query.Start).Take(query.Count).ToArrayAsync();

        var result = new QueryResult<RoleSearchResult>(query, totalCount, items);
        return result;
    }
}

Notice in the above sample, it is up to the logic in the implementation to decide how to use the filter. The use of the Start and Count are then used to obtain the correct page. Finally, the QueryResult constructor simply requires the total count and the items for the current page.