This example demonstrates how to expose your data with XAF Web API and protect it with XAF Security System in the following client-server web app:
- Server: an OData v7 service built with ASP.NET Core Web API.
- Client: an HTML/JavaScript app with the DevExtreme Data Grid.
-
Visual Studio 2022 v17.0+ with the following workloads:
- ASP.NET and web development
- .NET Core cross-platform development
-
Download and run our Unified Component Installer or add NuGet feed URL to Visual Studio NuGet feeds.
We recommend that you select all products when you run the DevExpress installer. It will register local NuGet package sources and item / project templates required for these tutorials. You can uninstall unnecessary components later.
NOTE
If you have a pre-release version of our components, for example, provided with the hotfix, you also have a pre-release version of NuGet packages. These packages will not be restored automatically and you need to update them manually as described in the Updating Packages article using the Include prerelease option.
For detailed information about ASP.NET Core application configuration, see official Microsoft documentation.
-
Configure the OData and MVC pipelines in the Program.cs:
var builder = WebApplication.CreateBuilder(args); builder.Services .AddControllers(mvcOptions => { mvcOptions.EnableEndpointRouting = false; }) .AddOData((opt, services) => opt .Count() .Filter() .Expand() .Select() .OrderBy() .SetMaxTop(null) .AddRouteComponents(GetEdmModel()) .AddRouteComponents("api/odata", new EdmModelBuilder(services).GetEdmModel()) ); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseHsts(); } app.UseODataQueryRequest(); app.UseODataBatching(); app.UseRouting(); app.UseCors(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); app.Run();
-
Define the EDM model that contains data description for all used entities. We also need to define actions to log in/out a user and get the user permissions.
IEdmModel GetEdmModel() { ODataModelBuilder builder = new ODataConventionModelBuilder(); EntitySetConfiguration<ObjectPermission> objectPermissions = builder.EntitySet<ObjectPermission>("ObjectPermissions"); EntitySetConfiguration<MemberPermission> memberPermissions = builder.EntitySet<MemberPermission>("MemberPermissions"); EntitySetConfiguration<TypePermission> typePermissions = builder.EntitySet<TypePermission>("TypePermissions"); ActionConfiguration login = builder.Action("Login"); login.Parameter<string>("userName"); login.Parameter<string>("password"); builder.Action("Logout"); ActionConfiguration getPermissions = builder.Action("GetPermissions"); getPermissions.Parameter<string>("typeName"); getPermissions.CollectionParameter<string>("keys"); ActionConfiguration getTypePermissions = builder.Action("GetTypePermissions"); getTypePermissions.Parameter<string>("typeName"); getTypePermissions.ReturnsFromEntitySet<TypePermission>("TypePermissions"); return builder.GetEdmModel(); }
The MemberPermission, ObjectPermission and TypePermission classes are used as containers to transfer permissions to the client side.
public class MemberPermission { [Key] public Guid Key { get; set; } public bool Read { get; set; } public bool Write { get; set; } public MemberPermission() { Key = Guid.NewGuid(); } } //... public class ObjectPermission { public IDictionary<string, object> Data { get; set; } [Key] public string Key { get; set; } public bool Write { get; set; } public bool Delete { get; set; } public ObjectPermission() { Data = new Dictionary<string, object>(); } } //... public class TypePermission { public IDictionary<string, object> Data { get; set; } [Key] public string Key { get; set; } public bool Create { get; set; } public TypePermission() { Data = new Dictionary<string, object>(); } }
-
Enable the authentication service and configure the request pipeline with the authentication middleware in the Program.cs. UnauthorizedRedirectMiddleware сhecks if the ASP.NET Core Identity is authenticated. If not, it redirects a user to the authentication page.
var builder = WebApplication.CreateBuilder(args); //... builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(); builder.Services.AddAuthorization(); var app = builder.Build(); //... app.UseAuthentication(); app.UseAuthorization(); app.UseMiddleware<UnauthorizedRedirectMiddleware>(); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseHttpsRedirection(); app.UseCookiePolicy(); //... public class UnauthorizedRedirectMiddleware { private const string authenticationPagePath = "/Authentication.html"; private readonly RequestDelegate _next; public UnauthorizedRedirectMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync(HttpContext context) { if(context.User != null && context.User.Identity != null && context.User.Identity.IsAuthenticated || IsAllowAnonymous(context)) { await _next(context); } else { context.Response.Redirect(authenticationPagePath); } } private static bool IsAllowAnonymous(HttpContext context) { string referer = context.Request.Headers["Referer"]; return context.Request.Path.HasValue && context.Request.Path.StartsWithSegments(authenticationPagePath) || referer != null && referer.Contains(authenticationPagePath); } }
-
Register the business objects that you will access from your code in the Types Info system.
builder.Services.AddSingleton<ITypesInfo>((serviceProvider) => { TypesInfo typesInfo = new TypesInfo(); typesInfo.GetOrAddEntityStore(ti => new XpoTypeInfoSource(ti)); typesInfo.RegisterEntity(typeof(Employee)); typesInfo.RegisterEntity(typeof(PermissionPolicyUser)); typesInfo.RegisterEntity(typeof(PermissionPolicyRole)); return typesInfo; })
-
Register ObjectSpaceProviders that will be used in your application. To do this, implement the
IObjectSpaceProviderFactory
interface.builder.Services.AddScoped<IObjectSpaceProviderFactory, ObjectSpaceProviderFactory>() // ... public class ObjectSpaceProviderFactory : IObjectSpaceProviderFactory { readonly ISecurityStrategyBase security; readonly IXpoDataStoreProvider xpoDataStoreProvider; readonly ITypesInfo typesInfo; public ObjectSpaceProviderFactory(ISecurityStrategyBase security, IXpoDataStoreProvider xpoDataStoreProvider, ITypesInfo typesInfo) { this.security = security; this.typesInfo = typesInfo; this.xpoDataStoreProvider = xpoDataStoreProvider; } IEnumerable<IObjectSpaceProvider> IObjectSpaceProviderFactory.CreateObjectSpaceProviders() { yield return new SecuredObjectSpaceProvider((ISelectDataSecurityProvider)security, xpoDataStoreProvider, typesInfo, null, true); } }
-
Set up database connection settings in your Data Store Provider object. In XPO, it is
IXpoDataStoreProvider
.builder.Services.AddSingleton<IXpoDataStoreProvider>((serviceProvider) => { var connectionString = serviceProvider.GetRequiredService<IConfiguration>().GetConnectionString("ConnectionString"); return XPObjectSpaceProvider.GetDataStoreProvider(connectionString, null, true); });
The
IConfiguration
object is used to access the application configuration appsettings.json file. In appsettings.json, add the connection string."ConnectionStrings": { "ConnectionString": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=XPOTestDB;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False" }
-
Register security system and authentication in the Program.cs. AuthenticationStandard authentication, and ASP.NET Core Identity authentication is registered automatically in AspNetCore Security setup.
builder.Services.AddXafAspNetCoreSecurity(builder.Configuration, options => { options.RoleType = typeof(PermissionPolicyRole); options.UserType = typeof(PermissionPolicyUser); options.Events.OnSecurityStrategyCreated = strategy => ((SecurityStrategy)strategy).RegisterXPOAdapterProviders(); }).AddAuthenticationStandard();
-
Call the
UseDemoData
method at the end of the Program.cs to update the database:public static WebApplication UseDemoData(this WebApplication app) { using var scope = app.Services.CreateScope(); var updatingObjectSpaceFactory = scope.ServiceProvider.GetRequiredService<IUpdatingObjectSpaceFactory>(); using var objectSpace = updatingObjectSpaceFactory .CreateUpdatingObjectSpace(typeof(BusinessObjectsLibrary.BusinessObjects.Employee), true)); new Updater(objectSpace).UpdateDatabase(); return app; }
For more details about how to create demo data from code, see the Updater.cs class.
-
Register your business objects in XAF Web Api to automatically implement CRUD logic & controllers for them.
builder.Services.AddXafWebApi(builder.Configuration, options => { options.BusinessObject<Employee>(); options.BusinessObject<Department>(); }).AddXpoServices();
-
AccountController handles the Login and Logout operations. The
Login
method is called when a user clicks theLogin
button on the login page. TheLogoff
method is called when a user clicks theLogoff
button on the main page. A user is identified by the standard logon parameters, which are user name and password.public class AccountController : ODataController { readonly IStandardAuthenticationService authenticationStandard; public AccountController(IStandardAuthenticationService authenticationStandard) { this.authenticationStandard = authenticationStandard; } [HttpPost("Login")] [AllowAnonymous] public ActionResult Login(string userName, string password) { Response.Cookies.Append("userName", userName ?? string.Empty); ClaimsPrincipal principal = authenticationStandard.Authenticate( new AuthenticationStandardLogonParameters(userName, password)); if(principal != null) { HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); return Ok(); } return Unauthorized(); } [HttpGet("Logout")] public ActionResult Logout() { HttpContext.SignOutAsync(); return Ok(); } }
-
ActionsController contains additional methods that process permissions. The
GetPermissions
method gathers permissions for all objects on the DevExtreme Data Grid current page and sends them to the client side as part of the response. TheGetTypePermissions
method gathers permissions for the type on the DevExtreme Data Grid's current page and sends them to the client side as part of the response.public class ActionsController : ODataController { readonly IObjectSpaceFactory objectSpaceFactory; readonly SecurityStrategy security; readonly ITypesInfo typesInfo; public ActionsController(ISecurityProvider securityProvider, IObjectSpaceFactory objectSpaceFactory, ITypesInfo typesInfo) { this.typesInfo = typesInfo; this.objectSpaceFactory = objectSpaceFactory; this.security = (SecurityStrategy)securityProvider.GetSecurity(); } [HttpPost("/GetPermissions")] public ActionResult GetPermissions(ODataActionParameters parameters) { if(parameters.ContainsKey("keys") && parameters.ContainsKey("typeName")) { string typeName = parameters["typeName"].ToString(); ITypeInfo typeInfo = typesInfo.PersistentTypes.FirstOrDefault(t => t.Name == typeName); if(typeInfo != null) { Type type = typeInfo.Type; using IObjectSpace objectSpace = objectSpaceFactory.CreateObjectSpace(type); IEnumerable<Guid> keys = ((IEnumerable<string>)parameters["keys"]).Select(k => Guid.Parse(k)); IEnumerable<ObjectPermission> objectPermissions = objectSpace .GetObjects(type, new InOperator(typeInfo.KeyMember.Name, keys)) .Cast<object>() .Select(entity => CreateObjectPermission(entity, typeInfo)); return Ok(objectPermissions); } } return NoContent(); } [HttpGet("/GetTypePermissions")] public ActionResult GetTypePermissions(string typeName) { ITypeInfo typeInfo = typesInfo.PersistentTypes.FirstOrDefault(t => t.Name == typeName); if(typeInfo != null) { Type type = typeInfo.Type; using IObjectSpace objectSpace = objectSpaceFactory.CreateObjectSpace(type); var result = new TypePermission { Key = type.Name, Create = security.CanCreate(type) }; foreach(IMemberInfo member in GetPersistentMembers(typeInfo)) { result.Data.Add(member.Name, security.CanWrite(type, member.Name)); } return Ok(result); } return NoContent(); } private ObjectPermission CreateObjectPermission(object entity, ITypeInfo typeInfo) { var objectPermission = new ObjectPermission { Key = typeInfo.KeyMember.GetValue(entity).ToString(), Write = security.CanWrite(entity), Delete = security.CanDelete(entity) }; foreach(IMemberInfo member in GetPersistentMembers(typeInfo)) { objectPermission.Data.Add(member.Name, new MemberPermission { Read = security.CanRead(entity, member.Name), Write = security.CanWrite(entity, member.Name) }); } return objectPermission; } private static IEnumerable<IMemberInfo> GetPersistentMembers(ITypeInfo typeInfo) { return typeInfo.Members.Where(p => p.IsVisible && p.IsProperty && (p.IsPersistent || p.IsList)); } }
-
The authentication page (Authentication.html) and the main page (Index.html) represent the client side UI.
-
authentication_code.js gathers data from the login page and attempts to log the user in.
$("#userName").dxTextBox({ name: "userName", placeholder: "User name", tabIndex: 2, onInitialized: function (e) { var texBoxInstance = e.component; var userName = getCookie("userName"); if (userName === undefined) { userName = "User"; } texBoxInstance.option("value", userName); }, onEnterKey: pressEnter }).dxValidator({ validationRules: [{ type: "required", message: "The user name must not be empty" }] }); $("#password").dxTextBox({ name: "Password", placeholder: "Password", mode: "password", tabIndex: 3, onEnterKey: pressEnter }); $("#validateAndSubmit").dxButton({ text: "Log In", tabIndex: 1, useSubmitBehavior: true }); $("#form").on("submit", function (e) { var userName = $("#userName").dxTextBox("instance").option("value"); var password = $("#password").dxTextBox("instance").option("value"); $.ajax({ method: 'POST', url: 'Login', data: { "userName": userName, "password": password }, complete: function (e) { if (e.status === 200) { document.cookie = "userName=" + userName; document.location.href = "/"; window.location = "Index.html"; } if (e.status === 401) { alert("User name or password is incorrect"); } } }); e.preventDefault(); }); function pressEnter(data) { $('#validateAndSubmit').click(); } function getCookie(name) { let matches = document.cookie.match(new RegExp( "(?:^|; )" + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + "=([^;]*)" )); return matches ? decodeURIComponent(matches[1]) : undefined; }
-
index_code.js configures the DevExtreme Data Grid and logs the user out. The onLoaded function sends a request to the server to obtain permissions for the current data grid page.
function onLoaded(data) { var oids = $.map(data, function (val) { return val.Oid._value; }); var parameters = { keys: oids, typeName: 'Employee' }; var options = { dataType: "json", contentType: "application/json", type: "POST", async: false, data: JSON.stringify(parameters) }; $.ajax("GetPermissions", options) .done(function (e) { permissions = e.value; }); }
-
The
onInitialized
function handles the data grid's initialized event and checks create operation permission to define whether the Create action should be displayed or not.function onInitialized(e) { $.ajax({ method: 'GET', url: 'GetTypePermissions?typeName=Employee', async: false, complete: function (data) { typePermissions = data.responseJSON; } }); var grid = e.component; grid.option("editing.allowAdding", typePermissions.Create); }
-
The
onEditorPreparing
function handles the data grid's editorPreparing event and checks Read and Write operation permissions. If the Read operation permission is denied, it displays the "*******" placeholder and disables the editor. If the Write operation permission is denied, the editor is disabled.function onEditorPreparing(e) { if (e.parentType === "dataRow") { var dataField = e.dataField.split('.')[0]; var key = e.row.key._value; if (key != undefined) { var objectPermission = getPermission(key); if (!objectPermission[dataField].Read) { e.editorOptions.disabled = true; e.editorOptions.value = "*******"; } if (!objectPermission[dataField].Write) { e.editorOptions.disabled = true; } } else { if (!typePermissions[dataField]) { e.editorOptions.disabled = true; } } } }
-
The
onCellPrepared
function handles the data grid's cellPrepared event and checks Read, Write, and Delete operation permissions. If the Read permission is denied, it displays the "*******" placeholder in data grid cells. Write and Delete operation permission checks define whether the Write and Delete actions should be displayed or not.function onCellPrepared(e) { if (e.rowType === "data") { var key = e.key._value; var objectPermission = getPermission(key); if (!e.column.command && e.column.dataField != undefined) { var dataField = e.column.dataField.split('.')[0]; if (!objectPermission[dataField].Read) { e.cellElement.text("*******"); } } else if (e.column.command == 'edit') { if (!objectPermission.Delete) { e.cellElement.find(".dx-link-delete").remove(); } if (!objectPermission.Write) { e.cellElement.find(".dx-link-edit").remove(); } } } }
Note that SecuredObjectSpace returns default values (for instance, null) for protected object properties - it is secure even without any custom UI. Use the SecurityStrategy.IsGranted method to determine when to mask default values with the "*******" placeholder in the UI.
-
The
getPermission
function returns the permission object for a business object. The business object is identified by the key passed in function parameters:function getPermission(key) { var permission = permissions.filter(function (entry) { return entry.Key === key; }); return permission[0]; }