SuperFinance Digital Banking SaaS: Step-1 – Setup Project, Models and Permissions
In this article
Introduction
As part of this tutorial, we’re building SuperFinance – a digital banking SaaS using ASPSecurityKit. In the previous step, we talked about the SuperFinance web application’s business features, security mechanisms, and the type of users. In this step, we’ll setup the initial project with ASPSecurityKit, build the data models, define permissions and learn how to assign them to different users. We’ll also highlight the best practices and recommendations behind the design choices you see so you can grasp the underlying concepts easily.
Prerequisites
This tutorial requires:
- An ASPSecurityKit license to generate the license key. By default, ASK has a limitation of 15 operations and by the end of this tutorial, we’ll be having more than 90 MVC actions – way beyond the 15.
- The Premium (ASP.NET Core Mvc) source package. It comes with several advanced workflows already implemented including Two-Factor Authentication, IP firewall, email verification, localization, user management, permit management, entity suspension, administration, and much more, which will help us rapidly build the multi-tenant digital banking service.
Setup the Initial Project
Follow the getting started walkthrough to setup the new SuperFinance project with the following options:
- Project template: ASP.NET Core Web Application (Empty)
- Source package: Premium-ASP.NET Core Mvc
Data Models
Creating the data models as a first thing forces us to think about the system and its interactions holistically, which will then aid in rapid development of various components of the system.
In SuperFinance, we’re going to use EntityFramework Core with code first approach for data access and representation.
Security Models
The Premium source package has already added necessary data models for user, permission, and suspension management as source code to the project. This is so that we can modify them as needed.
These entities are:
- User
- UserMultiFactor
- UserSession
- UserPermitGroup
- UserPermit
- Permission
- ImpliedPermission
- SuspendedEntity
- SuspensionExclusionRule
- FirewallRule
To keep a physical difference between three major kinds of users within SuperFinance, we choose to add UserType
property to the DbUser
model as follows:
public enum UserType
{
Admin,
Staff,
Customer
}
public class DbUser
{
...
[Required]
public UserType UserType { get; set; }
}
Domain Models
we need following data models to implement SF’s digital banking features. These are self-explanatory.
Bank
public class DbBank
{
[Key]
public Guid Id { get; set; }
[MaxLength(60)]
[Required]
public string Name { get; set; }
public string Address { get; set; }
public bool FirewallEnabled { get; set; }
public bool EnforceMFA { get; set; }
public bool SkipMFAInsideNetwork { get; set; }
public int? PasswordExpiresInDays { get; set; }
[ForeignKey("User")]
public Guid OwningUserId { get; set; }
public DbUser OwningUser { get; set; }
public IList<DbBranch> Branches { get; set; }
public IList<DbAccountType> AccountTypes { get; set; }
}
Branch
public class DbBranch
{
[Key]
public Guid Id { get; set; }
[MaxLength(60)]
[Required]
public string Name { get; set; }
[MaxLength(16)]
[Required]
public string Code { get; set; }
public string Address { get; set; }
[ForeignKey("Bank")]
public Guid BankId { get; set; }
public DbBank Bank { get; set; }
}
AccountType
public class DbAccountType
{
[Key]
public Guid Id { get; set; }
[MaxLength(30)]
[Required]
public string Name { get; set; }
[Required]
public double InterestRate { get; set; }
[Required]
public AccountKind Kind { get; set; }
[ForeignKey("Bank")]
public Guid BankId { get; set; }
public DbBank Bank { get; set; }
}
Account
public class DbAccount
{
[Key]
public Guid Id { get; set; }
[MaxLength(30)]
[Required]
public string Number { get; set; }
[MaxLength(24)]
[Required]
public string IdentityNumber { get; set; }
public AccountStatus Status { get; set; }
public string Reason { get; set; }
[ForeignKey("AccountType")]
public Guid AccountTypeId { get; set; }
public DbAccountType AccountType { get; set; }
[ForeignKey("Branch")]
public Guid BranchId { get; set; }
public DbBranch Branch { get; set; }
[ForeignKey("User")]
public Guid OwningUserId { get; set; }
public DbUser OwningUser { get; set; }
public DateTime CreatedDate { get; set; }
public IList<DbAccountNominee> Nominees { get; set; }
}
AccountNominee
public class DbAccountNominee
{
[Key]
public Guid Id { get; set; }
[ForeignKey("Account")]
public Guid AccountId { get; set; }
public DbAccount Account { get; set; }
[ForeignKey("User")]
public Guid NomineeUserId { get; set; }
public DbUser NomineeUser { get; set; }
}
Transaction
public class DbTransaction
{
[Key]
public Guid Id { get; set; }
public DateTime Date { get; set; }
public double Amount { get; set; }
public TransactionType TransactionType { get; set; }
public string Remarks { get; set; }
[ForeignKey("Account")]
public Guid AccountId { get; set; }
public DbAccount Account { get; set; }
}
Transfer
public class DbTransfer
{
[Key]
public Guid Id { get; set; }
[ForeignKey("Transaction")]
public Guid DebitTransactionId { get; set; }
public DbTransaction DebitTransaction { get; set; }
[ForeignKey("Transaction")]
public Guid CreditTransactionId { get; set; }
public DbTransaction CreditTransaction { get; set; }
public DateTime CreatedDate { get; set; }
}
UserInvitation
public class DbUserInvitation
{
[Key]
public Guid Id { get; set; }
[MaxLength(100)]
[Required]
public string EmailAddress { get; set; }
public DateTime Date { get; set; }
[ForeignKey("Account")]
public Guid AccountId { get; set; }
public DbAccount Account { get; set; }
[ForeignKey("User")]
public Guid? UserId { get; set; }
public DbUser User { get; set; }
}
Best Practices
- If the relationship between a user and an entity is that of ownership, you should define user property physically in that entity. As done in case of OwningUserId in both DbBank and DbAccount.
- However, as we’ll see below, actions even on such owned entities are also authorized via permits. For that, we assign a user the permit on the entity when it’s created.
- On the other hand, when the relationship is temporal, for instance, because of one’s job in the organization, it’s better to just stick to assigning the permit on the entity to the user. As we’ll do with a user such as branch manager and branch staff. These users do not own their branches; they are merely serving their current roles in the bank. This way it’s easy to grant or revoke their permit effectively deleting the relationship with the entity, and also makes it easy to add multiple such relationships for the same entity.
- However, there’s still one case the above rules aren’t exactly honored; and that is, the DbAccountNominee entity exists at the moment only to establish a relationship between a nominee user and an account. This can be achieved with just a permit – assigning
Nominee
permission on theAccountId
– but the model is created intentionally because there could likely be more information we need to capture as part of nominee creation, such as the percentage of the amount a nominee is allocated as a beneficiary when multiple nominees are specified for an account.
Permissions
We strongly recommend that you read both the design and how-to ADA guides to understand the concept and power of ASPSecurityKit’s activity-based, data-aware authorization (ADA).
User Roles
Based on the unique concept of implied permissions, we can implement the roles for different kinds of users in SuperFinance as permissions. This is the recommended way because:
- No extra effort is needed to develop/maintain role-related tables and UI.
- In case you need to assign one permission to a user, you don’t have to create a new role, role-permission mapping, and such stuff; just assign both roles and direct permission using a unified construct of UserPermit.
These are the permissions representing user roles in SuperFinance:
Permission | EntityType | PermissionKind |
---|---|---|
BankOwner | Bank | Instance |
BranchManager | Branch | Instance |
BranchStaff | Branch | Instance |
AccountHolder | Account | Instance |
AccountNominee | Account | Instance |
Customer | User | General |
Notes:
- EntityType represents the type of entity (bank/branch/etc) that this permission will be assigned against (EntityId)
- PermissionKind represents whether the permission is a general or instance permission. A general permission indicates that there won’t be any EntityId required to grant this permission.
Activity Permissions
We now define permissions related to MVC actions (the activities) and associate them with user roles. Following the convention of activity definition, the PermissionCode
for action is built by combining the controller and action names; you can override these conventions if needed.
Note:
- For the sake of elaboration, we’re defining these permissions explicitly in the sample; you can automate this process and add/update permissions (on startup or as a migration seed data step) based on the same conventions.
- Although the activity authorization approach recommends that you define a unique PermissionCode for every action, in the real-world sticking to the recommendation for all actions can lead to the problem of permissions bloat. In such cases, we recommend to be practical and define permissions at a bit higher-level for entities that do not need separate permission for every action on that entity.
For example, the CRUD (create/read/update/delete) actions related to Branch and AccountType entities are only meant for Bank Owner and even if they’re delegated, they’ll be delegated in as a group; simply put, we do not see a need to assign these actions independently. So,ManageBranch
andManageAccountType
permissions are suffice. in their respective controllers, we’ll override the conventions so ASK knows and authorize related actions using these permissions. - Some actions are anonymously available and hence they do not need permissions. For example, bank registration, customer sign up, login, etc.
Permission | EntityType | PermissionKind |
---|---|---|
ManageBank | Bank | Instance |
ManageBranch | Bank | Instance |
ManageAccountType | Bank | Instance |
IndexCustomerAccount | Account | General |
CreateCustomerAccount | Account | Instance |
ChangeStatus | Account | Instance |
CreateDeposit | Account | Instance |
CreateWithdrawal | Account | Instance |
OpenAccount | Account | General |
IndexAccount | Account | General |
CreateTransfer | Account | Instance |
AddNominee | Account | Instance |
DeleteNominee | Account | Instance |
IndexNominee | Account | Instance |
IndexAccountDetails | Account | Instance |
Implied Permissions
Next, we can associate the above activity permissions with user roles via implied permissions construct:
Permission | ImpliedPermission |
---|---|
AccountHolder | AddNominee |
AccountHolder | CreateTransfer |
AccountHolder | DeleteNominee |
AccountHolder | IndexAccount |
AccountHolder | IndexAccountDetails |
AccountHolder | IndexNominee |
AccountHolder | OpenAccount |
AccountNominee | IndexAccount |
AccountNominee | IndexAccountDetails |
BankOwner | BranchManager |
BankOwner | ManageAccountType |
BankOwner | ManageBank |
BankOwner | ManageBranch |
BankOwner | ManageFirewall |
BranchManager | BranchStaff |
BranchStaff | ChangeStatus |
BranchStaff | CreateCustomerAccount |
BranchStaff | CreateDeposit |
BranchStaff | CreateWithdrawal |
BranchStaff | IndexAccountDetails |
BranchStaff | IndexCustomerAccount |
BranchStaff | IndexNominee |
Customer | OpenAccount |
Migrations
We can now create an EntityFramework Core migration for the domain models and also include insert scripts for the permissions and implied permissions discussed above.
View Migration
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ASKSource.Migrations
{
public partial class BankModels : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "UserType",
table: "User",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "Bank",
columns: table => new
{
Id = table.Column<Guid>(nullable: false),
Name = table.Column<string>(maxLength: 60, nullable: false),
Address = table.Column<string>(nullable: true),
FirewallEnabled = table.Column<bool>(nullable: false),
EnforceMFA = table.Column<bool>(nullable: false),
SkipMFAInsideNetwork = table.Column<bool>(nullable: false),
PasswordExpiresInDays = table.Column<int>(nullable: true),
OwningUserId = table.Column<Guid>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Bank", x => x.Id);
table.ForeignKey(
name: "FK_Bank_User_OwningUserId",
column: x => x.OwningUserId,
principalTable: "User",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "AccountType",
columns: table => new
{
Id = table.Column<Guid>(nullable: false),
Name = table.Column<string>(maxLength: 30, nullable: false),
InterestRate = table.Column<double>(nullable: false),
Kind = table.Column<int>(nullable: false),
BankId = table.Column<Guid>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AccountType", x => x.Id);
table.ForeignKey(
name: "FK_AccountType_Bank_BankId",
column: x => x.BankId,
principalTable: "Bank",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Branch",
columns: table => new
{
Id = table.Column<Guid>(nullable: false),
Name = table.Column<string>(maxLength: 60, nullable: false),
Code = table.Column<string>(maxLength: 16, nullable: false),
Address = table.Column<string>(nullable: true),
BankId = table.Column<Guid>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Branch", x => x.Id);
table.ForeignKey(
name: "FK_Branch_Bank_BankId",
column: x => x.BankId,
principalTable: "Bank",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Account",
columns: table => new
{
Id = table.Column<Guid>(nullable: false),
Number = table.Column<string>(maxLength: 30, nullable: false),
IdentityNumber = table.Column<string>(maxLength: 24, nullable: false),
Status = table.Column<int>(nullable: false),
Reason = table.Column<string>(nullable: true),
AccountTypeId = table.Column<Guid>(nullable: false),
BranchId = table.Column<Guid>(nullable: false),
OwningUserId = table.Column<Guid>(nullable: false),
CreatedDate = table.Column<DateTime>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Account", x => x.Id);
table.ForeignKey(
name: "FK_Account_AccountType_AccountTypeId",
column: x => x.AccountTypeId,
principalTable: "AccountType",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Account_Branch_BranchId",
column: x => x.BranchId,
principalTable: "Branch",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Account_User_OwningUserId",
column: x => x.OwningUserId,
principalTable: "User",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "AccountNominee",
columns: table => new
{
Id = table.Column<Guid>(nullable: false),
AccountId = table.Column<Guid>(nullable: false),
NomineeUserId = table.Column<Guid>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AccountNominee", x => x.Id);
table.ForeignKey(
name: "FK_AccountNominee_Account_AccountId",
column: x => x.AccountId,
principalTable: "Account",
principalColumn: "Id");
table.ForeignKey(
name: "FK_AccountNominee_User_NomineeUserId",
column: x => x.NomineeUserId,
principalTable: "User",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Transaction",
columns: table => new
{
Id = table.Column<Guid>(nullable: false),
Date = table.Column<DateTime>(nullable: false),
Amount = table.Column<double>(nullable: false),
TransactionType = table.Column<int>(nullable: false),
Remarks = table.Column<string>(nullable: true),
AccountId = table.Column<Guid>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Transaction", x => x.Id);
table.ForeignKey(
name: "FK_Transaction_Account_AccountId",
column: x => x.AccountId,
principalTable: "Account",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "UserInvitation",
columns: table => new
{
Id = table.Column<Guid>(nullable: false),
EmailAddress = table.Column<string>(maxLength: 100, nullable: false),
Date = table.Column<DateTime>(nullable: false),
AccountId = table.Column<Guid>(nullable: false),
UserId = table.Column<Guid>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserInvitation", x => x.Id);
table.ForeignKey(
name: "FK_UserInvitation_Account_AccountId",
column: x => x.AccountId,
principalTable: "Account",
principalColumn: "Id");
table.ForeignKey(
name: "FK_UserInvitation_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Transfer",
columns: table => new
{
Id = table.Column<Guid>(nullable: false),
DebitTransactionId = table.Column<Guid>(nullable: false),
CreditTransactionId = table.Column<Guid>(nullable: false),
CreatedDate = table.Column<DateTime>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Transfer", x => x.Id);
table.ForeignKey(
name: "FK_Transfer_Transaction_CreditTransactionId",
column: x => x.CreditTransactionId,
principalTable: "Transaction",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Transfer_Transaction_DebitTransactionId",
column: x => x.DebitTransactionId,
principalTable: "Transaction",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Account_AccountTypeId",
table: "Account",
column: "AccountTypeId");
migrationBuilder.CreateIndex(
name: "IX_Account_BranchId",
table: "Account",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_Account_OwningUserId",
table: "Account",
column: "OwningUserId");
migrationBuilder.CreateIndex(
name: "IX_AccountNominee_AccountId",
table: "AccountNominee",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_AccountNominee_NomineeUserId",
table: "AccountNominee",
column: "NomineeUserId");
migrationBuilder.CreateIndex(
name: "IX_AccountType_BankId",
table: "AccountType",
column: "BankId");
migrationBuilder.CreateIndex(
name: "IX_Bank_Name",
table: "Bank",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Bank_OwningUserId",
table: "Bank",
column: "OwningUserId");
migrationBuilder.CreateIndex(
name: "IX_Branch_BankId",
table: "Branch",
column: "BankId");
migrationBuilder.CreateIndex(
name: "IX_Branch_Code",
table: "Branch",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Transaction_AccountId",
table: "Transaction",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_Transfer_CreditTransactionId",
table: "Transfer",
column: "CreditTransactionId");
migrationBuilder.CreateIndex(
name: "IX_Transfer_DebitTransactionId",
table: "Transfer",
column: "DebitTransactionId");
migrationBuilder.CreateIndex(
name: "IX_UserInvitation_AccountId",
table: "UserInvitation",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_UserInvitation_UserId",
table: "UserInvitation",
column: "UserId");
InsertActivityPermissions(migrationBuilder);
InsertRolePermissions(migrationBuilder);
InsertImpliedPermissions(migrationBuilder);
InsertSuspensionRules(migrationBuilder);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AccountNominee");
migrationBuilder.DropTable(
name: "Transfer");
migrationBuilder.DropTable(
name: "UserInvitation");
migrationBuilder.DropTable(
name: "Transaction");
migrationBuilder.DropTable(
name: "Account");
migrationBuilder.DropTable(
name: "AccountType");
migrationBuilder.DropTable(
name: "Branch");
migrationBuilder.DropTable(
name: "Bank");
migrationBuilder.DropColumn(
name: "UserType",
table: "User");
}
private void InsertActivityPermissions(MigrationBuilder migrationBuilder)
{
var permissions = new[]
{
"('ManageBank', 'BANK', 'Manage bank', 1)", // instance permission
"('ManageBranch', 'BANK', 'Manage branches', 1)", // instance permission
"('ManageAccountType', 'BANK', 'Manage account types', 1)", // instance permission
"('IndexCustomerAccount', 'ACCOUNT', 'View account', 0)", // general permission
"('CreateCustomerAccount', 'ACCOUNT', 'Add new accounts', 1)", // instance permission
"('ChangeStatus', 'ACCOUNT', 'edit account', 1)", // instance permission
"('CreateDeposit', 'ACCOUNT', 'Create deposit', 1)", // instance permission
"('CreateWithdrawal', 'ACCOUNT', 'Create withdrawal', 1)", // instance permission
"('OpenAccount', 'ACCOUNT', 'Add new accounts', 0)", // general permission
"('IndexAccount', 'ACCOUNT', 'View account', 0)", // general permission
"('CreateTransfer', 'ACCOUNT', 'Create transfer', 1)", // instance permission
"('AddNominee', 'ACCOUNT', 'Create new nominee', 1)", // instance permission
"('DeleteNominee', 'ACCOUNT', 'Delete nominee', 1)", // instance permission
"('IndexNominee', 'ACCOUNT', 'Create new nominee', 1)", // instance permission
"('IndexAccountDetails', 'ACCOUNT', 'View account details', 1)", // general permission
};
migrationBuilder.Sql(@"insert into [dbo].[Permission]
(PermissionCode, EntityTypeCode, Description, Kind)
values" + string.Join(",\r\n", permissions));
}
private void InsertRolePermissions(MigrationBuilder migrationBuilder)
{
var permissions = new[]
{
"('BankOwner', 'BANK', 'Bank admin permission', 1)", // instance permission
"('BranchManager', 'BRANCH', 'Branch admin permission', 1)", // instance permission
"('BranchStaff', 'BRANCH', 'Branch staff permission', 1)", // instance permission
"('AccountHolder', 'ACCOUNT', 'Account holder permission', 1)", // instance permission
"('AccountNominee', 'ACCOUNT', 'Account nominee permission', 1)", // instance permission
"('Customer', 'USER', 'Customer permission', 0)", // instance permission
};
migrationBuilder.Sql(@"insert into [dbo].[Permission]
(PermissionCode, EntityTypeCode, Description, Kind)
values" + string.Join(",\r\n", permissions));
}
private void InsertImpliedPermissions(MigrationBuilder migrationBuilder)
{
var impliedPermissions = new[]
{
"('BankOwner', 'BranchManager')",
"('BankOwner', 'ManageBranch')",
"('BankOwner', 'ManageAccountType')",
"('BankOwner', 'ManageFirewall')",
"('BankOwner', 'ManageBank')",
"('BranchManager', 'BranchStaff')",
"('BranchStaff', 'IndexCustomerAccount')",
"('BranchStaff', 'CreateCustomerAccount')",
"('BranchStaff', 'ChangeStatus')",
"('BranchStaff', 'CreateDeposit')",
"('BranchStaff', 'CreateWithdrawal')",
"('BranchStaff', 'IndexAccountDetails')",
"('BranchStaff', 'IndexNominee')",
"('AccountHolder', 'OpenAccount')",
"('AccountHolder', 'IndexAccount')",
"('AccountHolder', 'CreateTransfer')",
"('AccountHolder', 'AddNominee')",
"('AccountHolder', 'DeleteNominee')",
"('AccountHolder', 'IndexNominee')",
"('AccountHolder', 'IndexAccountDetails')",
"('AccountNominee', 'IndexAccount')",
"('AccountNominee', 'IndexAccountDetails')",
"('Customer', 'OpenAccount')"
};
migrationBuilder.Sql(@"insert into [dbo].[ImpliedPermission]
(PermissionCode, ImpliedPermissionCode)
values" + string.Join(",\r\n", impliedPermissions));
}
private void InsertSuspensionRules(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"insert into SuspensionExclusionRule
(Id, EntityTypePattern, SuspensionTypePattern, VerbPattern, OperationPattern, PossessesAnyOfThePermissions)
values
--Can view = all get operations permitted on suspended entities regardless of reason/user role. But not MVC view retrieval actions which are really the post ones.
(newid(), 'Account', '.*', 'GET', '^(?:(?<!ChangeStatus|Deposit|Withdrawal|Transfer).)*$', null),
--list* operations are also get only but called with post verb by jtable etc.
(newid(), 'Account', '.*', 'POST', 'List.*', null),
--deposit allowed to all on suspended entity regardless of reason/user role (except on close accounts). Note - only staff is allowed but that's handled by permit authorization (ADA).
(newid(), 'Account', '^(?:(?<!close).)*$', '.*', 'Deposit', null),
--transfer/withdrawal is not allowed on pendingApproval. so allowing only deposit.
(newid(), 'Account', 'PendingApproval', '.*', 'Deposit', null),
--changeStatus allowed to owner on any status other than close.
(newid(), 'Account', '^(?:(?<!close).)*$', '.*', 'ChangeStatus', 'BankOwner'),
--changeStatus allowed to manager on dormant/pendingApproval.
(newid(), 'Account', 'Dormant|PendingApproval', '.*', 'ChangeStatus', 'BranchManager'),
--changeStatus allowed to manager/staff on KYC required.
(newid(), 'Account', 'KYCRequired', '.*', 'ChangeStatus', 'BranchManager|BranchStaff')");
}
}
}
Try Out the Live Demo
Visit https://superfinance.ASPSecurityKit.net to play with a live demo based on this sample.