Skip to content

Latest commit

 

History

History

DevExtreme.OData

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:

Prerequisites

  • Visual Studio 2022 v17.0+ with the following workloads:

    • ASP.NET and web development
    • .NET Core cross-platform development
  • .NET SDK 6.0+

  • Download and run the 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.

Step 1: Configure the ASP.NET Core Server App

  1. Add EFCore DevExpress NuGet packages to your project:

    <PackageReference Include="DevExpress.ExpressApp.EFCore" Version="22.2.3" />
    <PackageReference Include="DevExpress.Persistent.BaseImpl.EFCore" Version="22.2.3" />
  2. Install Entity Framework Core, as described in the Installing Entity Framework Core article.

  3. 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);
        }
    }

Step 2. Initialize Data Store and XAF Security System. Authentication and Permission Configuration

  • 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.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 ITypesInfo typesInfo;
        readonly IDbContextFactory<ApplicationDbContext> dbFactory;
    
        public ObjectSpaceProviderFactory(ISecurityStrategyBase security, ITypesInfo typesInfo, IDbContextFactory<ApplicationDbContext> dbFactory) {
            this.security = security;
            this.typesInfo = typesInfo;
            this.dbFactory = dbFactory;
        }
    
        IEnumerable<IObjectSpaceProvider> IObjectSpaceProviderFactory.CreateObjectSpaceProviders() {
            yield return new SecuredEFCoreObjectSpaceProvider<ApplicationDbContext>((ISelectDataSecurityProvider)security, dbFactory, typesInfo);
        }
    }
  • Set up database connection settings in your Data Store Provider object. Add security extension to DbContextFactory to allow your application to filter data based on user permissions.

    builder.Services.AddDbContextFactory<ApplicationDbContext>((serviceProvider, options) => {
        string connectionString = builder.Configuration.GetConnectionString("ConnectionString");
        options.UseSqlServer(connectionString);
        options.UseLazyLoadingProxies();
        options.UseChangeTrackingProxies();
        options.UseSecurity(serviceProvider);
    }, ServiceLifetime.Scoped);

    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=EFCoreTestDB;Integrated Security=True;MultipleActiveResultSets=True"
    }

    NOTE The Security System requires Multiple Active Result Sets in EF Core-based applications connected to the MS SQL database. We do not recommend that you remove “MultipleActiveResultSets=True;“ from the connection string or set the MultipleActiveResultSets parameter to 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);
    }).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.

Step 3. XAF Web API and OData Controllers for CRUD, Login, Logoff, etc.

  • 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>();
    });
  • AccountController handles the Login and Logout operations. The Login method is called when a user clicks the Login button on the login page. The Logoff method is called when a user clicks the Logoff 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. The GetTypePermissions 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));
        }
    }

Step 4: Implement the Client-Side App

  1. The authentication page (Authentication.html) and the main page(Index.html) represent the client side UI.

  2. 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;
    }
  3. 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.ID.toString();
        });
        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;
            });
    }
  4. 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);
    }
  5. 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];
            if (!e.row.isNewRow) {
                var key = e.row.key;
                var objectPermission = getPermission(key.toString());
                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;
                }
            }
        }
    }
  6. 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;
            var objectPermission = getPermission(key.toString());
            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.

  7. 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];
    }

Step 5: Run and Test the App

  • Log in a 'User' with an empty password.

  • Notice that secured data is displayed as '*******'.

  • Press the Logout button and log in as 'Admin' to see all the records.