SuperFinance Digital Banking SaaS: Step-2 – Setup and Manage Banks, Branches, Accounts, and Staff

Introduction

As part of this tutorial, we’re building SuperFinance – a digital banking SaaS using ASPSecurityKit. In prior steps, we’ve learned about SF’s features, type of users, security mechanisms to be employed, data models, and permissions.

In this step, we’ll build the workflows or interfaces for bank employees to setup and manage bank, branches, accounts, and different types of staff users. We’ll also make use of various features of activity-data authorization (ADA) to implement exactly the authorization controls we need for several different scenarios we’ve got.

Important information

  • Every feature we build may require multiple things including view model, controller, and manager methods. We’ll list out each such thing (linking to its source code) but would only talk about things for which we have something to convey. We hope the rest is easy to follow – although if that’s not the case, you can give your valuable feedback at the bottom of this page.
  • We’ll leverage the sources that came with the Premium package we installed in step-1 but wouldn’t link to them, because we’re not creating them for this tutorial. By looking at the code it’d be apparent that we’re using them.
    • Much of this source code is compiled into an demo assembly as source packages require a purchase.
  • We continue to write the business logic in managers based on the architecture we’ve received from the Premium source package. The benefit of managers is that they’re agnostic of web requests, so you can use them in background jobs as well.

Loading BankId

The bankId acts as a tenant identifier in the system – we’ll need it to load bank related security policies for staff users and also to let staff users perform actions that need BankId but they shouldn’t be asked for it because every staff user is associated with only one bank ever. Therefore, we need to load the BankId upon authentication as follows.

  1. Define BankId property in IdentityAuthDetails and UserService.
  2. Load it in IdentityRepository.LoadAdditionalData.
protected override async Task<IAuthDetails> LoadAdditionalDataAsync(DbUserSession dbSession, IdentityAuthDetails auth)
{
	if (dbSession.User.UserType == UserType.Staff && auth is SFIdentityAuthDetails authDetails)
	{
		var owningUserId = dbSession.User.ParentId ?? dbSession.UserId;
		var dbBank = await dbContext.Banks.FirstOrDefaultAsync(x => x.OwningUserId == owningUserId)
			.ConfigureAwait(false);

		if (dbBank != null)
		{
			authDetails.BankId = dbBank.Id;
		}
	}

	return auth;
}
  1. Next, in OnAuthenticated event handler, set the BankId from identity details into UserService, to save it to the current user’s session store.

On-boarding Banking Institutions

SuperFinance intends to provide self-service interfaces for banking institutions to sign up and build the digital banking service for their bank.

The first step thus is on-boarding, which needs the following:

RegisterBank View Model

RegisterBankModel
public class RegisterBankModel
{
	[MaxLength(60)]
	[Required]
	[Display(Name = "Bank Name")]
	public string Name { get; set; }

	[Display(Name = "Address")]
	public string Address { get; set; }

	[MaxLength(100)]
	[Required]
	[Display(Name = "Admin Email")]
	[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
	[DoNotAuthorize]
	public string Username { get; set; }

	[Required]
	[StringLength(512, MinimumLength = 6, ErrorMessageResourceName = "InvalidPasswordLength", ErrorMessageResourceType = typeof(Messages))]
	[DataType(DataType.Password)]
	[Display(Name = "Password")]
	public string Password { get; set; }

	[DataType(DataType.Password)]
	[Display(Name = "Confirm password")]
	[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
	public string ConfirmPassword { get; set; }
}

View Raw File

RegisterBankAsync Method in BankManager

RegisterBankAsync
public async Task<LoginResult> RegisterBankAsync(RegisterBankModel model, string verificationUrl, string contactUrl)
{
	try
	{
		var result = await this.userManager.RegisterStaffUserAsync(
			new AppUser
			{
				Name = "Administrator",
				Username = model.Username,
				Password = model.Password
			},
			verificationUrl, contactUrl, true);

		if (result != null && result.IsSuccess)
		{
			var dbBank = new DbBank
			{
				Id = Guid.NewGuid(),
				Name = model.Name,
				Address = model.Address,
				OwningUserId = this.userService.CurrentUserId
			};
			this.dbContext.Banks.Add(dbBank);

			await this.permitRepository
				.AddPermitAsync(dbBank.OwningUserId, SFPermissionCodes.BankOwner, dbBank.Id)
				.ConfigureAwait(false);

			await this.permitRepository
				.AddPermitAsync(dbBank.OwningUserId, PermissionCodes.AddUser, null)
				.ConfigureAwait(false);

			await this.dbContext.SaveChangesAsync().ConfigureAwait(false);

			// reload permissions to include permit granted on the new bank entity to the owner user.
			await this.userService.RefreshPermissionsAsync().ConfigureAwait(false);

			// set bankId in session store
			this.userService.BankId = dbBank.Id;
		}

		return result;
	}
	catch (OpException)
	{
		throw;
	}
	catch (Exception ex)
	{
		await this.logger.ErrorAsync(ex).ConfigureAwait(false);

		if (ex.GetBaseException() is SqlException sqlEx &&
			sqlEx.Number.In((int)SqlErrors.KeyViolation, (int)SqlErrors.UniqueIndex))
		{
			throw new OpException(OpResult.AlreadyExists,
				string.Format(SFMessages.CannotAddDuplicateBank, model.Name));
		}

		throw;
	}
}

View Raw File

  • In the above method, we’re doing three major things: creating the bank owner user, creating a new bank entity, and granting BankOwner role permit to the user on the bankId. We’re also granting AddUser general permit so the bank owner can add staff users and setting the BankId into the session as the new user is also logged in upon registration.
  • Since the bank name has to be unique across the system, we have a constraint in the DbContext for it and we’re handling the exception generated upon its violation.

RegisterStaffUserAsync Method in SFUserManager

RegisterStaffUserAsync
public async Task<LoginResult> RegisterStaffUserAsync(AppUser user, string verificationUrl, string contactUrl, bool createAuthCookie)
{
	var result = await RegisterUserAsync(user, verificationUrl, contactUrl, createAuthCookie).ConfigureAwait(false);
	if (result != null)
	{
		var dbUser = await this.dbContext.Users
			.SingleOrDefaultAsync(x => x.Username == user.Username)
			.ConfigureAwait(false);
		dbUser.UserType = UserType.Staff;
		this.userService.CurrentUser.UserType = UserType.Staff;
		await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
	}

	return result;
}

View Raw File

  • SFUserManager.RegisterCustomerAsync calls UserManager.RegisterUserAsync method to create the user and then sets the UserType as Staff
  • UserManager.RegisterUser (which came as part of the Premium package) performs series of steps including creating the user, sending a verification email and login.

SignUp in BankController

SignUp
[AllowAnonymous]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> SignUp(RegisterBankModel model)
{
	if (ModelState.IsValid)
	{
		try
		{
			var result = await this.bankManager.RegisterBankAsync(
				model, VerificationUrl(), ContactUrl());

			if (result != null && result.IsSuccess)
			{
				if (this.securitySettings.MustHaveBeenVerified)
				{
					return RedirectWithMessage("Verify", "User",
						string.Format(Messages.VerificationMailSent, this.userService.CurrentUsername), OpResult.Success);
				}

				return RedirectToAction("Index");
			}
		}
		catch (OpException ex)
		{
			ModelState.AddModelError(string.Empty, ex.Message);
		}
	}

	// If we got this far, something failed, redisplay form
	return View(model);
}

View Raw File

  • We’re redirecting the new user on the verification page (came with the Premium source package) to take user through the email verification workflow.

Authorization

Please note the AllowAnonymous attribute placed over the SignUp action. This attribute indicates to ASK’s security pipeline that the given action is accessible without authentication. Since SuperFinance allows banking institutions to sign up and setup bank on their own, the SignUp action is publicly and anonymously available.

SignUp UI

SuperFinance bank signup page

Setup AccountTypes

A bank can setup number of account types it offers to its customers. Essentially there are only two kinds of accounts in SF: Investment and Loan. However, a bank usually has different interest rates for different kinds of investments or loans, for example, Personal Loan, Car Loan, Home Loan, Fixed Deposits-1 year, Fixed Deposits-3 years, and so on. AccountType helps in defining such bank products.

For this feature, we need the following:

AccountType model

AccountType
public class AccountType
{
	[Authorize("AccountTypeId")]
	public Guid? Id { get; set; }

	[Required]
	[MaxLength(30)]
	public string Name { get; set; }

	[Required]
	public double InterestRate { get; set; }

	[Required]
	public AccountKind Kind { get; set; }
}

View Raw File

AcountTypeManager

AddAccountTypeAsync
public async Task<AccountType> AddAccountTypeAsync(AccountType accountType)
{
	try
	{
		var dbAccountType = new DbAccountType
		{
			Name = accountType.Name,
			InterestRate = accountType.InterestRate,
			Kind = accountType.Kind,
			BankId = this.userService.BankId.GetValueOrDefault()
		};

		this.dbContext.AccountTypes.Add(dbAccountType);
		await this.dbContext.SaveChangesAsync().ConfigureAwait(false);

		return new AccountType
		{
			Id = dbAccountType.Id,
			Name = dbAccountType.Name,
			InterestRate = dbAccountType.InterestRate,
			Kind = dbAccountType.Kind
		};
	}
	catch (Exception ex)
	{
		await this.logger.ErrorAsync(ex).ConfigureAwait(false);

		if (ex.GetBaseException() is SqlException sqlEx &&
			sqlEx.Number.In((int)SqlErrors.KeyViolation, (int)SqlErrors.UniqueIndex))
		{
			throw new OpException(OpResult.AlreadyExists,
				string.Format(SFMessages.CannotAddDuplicateAccountType, accountType.Name));
		}

		throw;
	}
}
EditAccountTypeAsync
public async Task<AccountType> EditAccountTypeAsync(AccountType accountType)
{
	var dbAccountType = await this.dbContext.AccountTypes
		.Where(m => m.Id == accountType.Id)
		.SingleOrDefaultAsync()
		.ConfigureAwait(false);

	if (dbAccountType == null)
	{
		await this.logger.WarnAsync("{0} doesn't exist", accountType.Name).ConfigureAwait(false);
		throw new OpException(OpResult.DoNotExist);
	}

	try
	{
		dbAccountType.Name = accountType.Name;
		dbAccountType.InterestRate = accountType.InterestRate;
		dbAccountType.Kind = accountType.Kind;

		await this.dbContext.SaveChangesAsync().ConfigureAwait(false);

		return new AccountType
		{
			Id = dbAccountType.Id,
			Name = dbAccountType.Name,
			InterestRate = dbAccountType.InterestRate,
			Kind = dbAccountType.Kind
		};
	}
	catch (Exception ex)
	{
		await this.logger.ErrorAsync(ex).ConfigureAwait(false);

		if (ex.GetBaseException() is SqlException sqlEx &&
			sqlEx.Number.In((int)SqlErrors.KeyViolation, (int)SqlErrors.UniqueIndex))
		{
			throw new OpException(OpResult.AlreadyExists,
				string.Format(SFMessages.CannotAddDuplicateAccountType, accountType.Name));
		}

		throw;
	}
}
DeleteAccountTypeAsync
public async Task DeleteAccountTypeAsync(Guid id)
{
	var dbAccountType = await this.dbContext.AccountTypes
		.Where(m => m.Id == id)
		.SingleOrDefaultAsync()
		.ConfigureAwait(false);

	if (dbAccountType != null)
	{
		try
		{
			this.dbContext.AccountTypes.Remove(dbAccountType);
			await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
		}
		catch (Exception ex)
		{
			await this.logger.ErrorAsync(ex).ConfigureAwait(false);
			throw new OpException(OpResult.DBDeletionDenied, SFMessages.CannotDeleteAccountType);
		}
	}
	else
	{
		throw new OpException(OpResult.DoNotExist);
	}
}
GetAccountTypesAsync
public async Task<PagedResult<AccountType>> GetAccountTypesAsync(int startIndex, int pageSize)
{
	var bankId = this.userService.BankId;

	var list = await this.dbContext.AccountTypes.AsNoTracking()
		.Select(dummy => new
		{
			Total = this.dbContext.AccountTypes.Count(p => p.BankId == bankId),
			ThisPage = this.dbContext.AccountTypes.Where(p => p.BankId == bankId)
				.OrderBy(p => p.Name).Skip(startIndex).Take(pageSize)
				.Select(x => new AccountType
				{
					Id = x.Id,
					Name = x.Name,
					InterestRate = x.InterestRate,
					Kind = x.Kind
				})
				.ToList()
		}).FirstOrDefaultAsync().ConfigureAwait(false);

	return new PagedResult<AccountType>(list?.ThisPage.ToList(), startIndex, pageSize, list?.Total ?? 0);
}

View Raw File

  • The name is unique hence in both Add and Edit methods we’re handling the exception generated upon its violation.

AccountTypeController

[AuthPermission("ManageAccountType")]
public class AccountTypeController : ServiceControllerBase
{
	...
}
List
[PossessesPermissionCode]
[HttpPost]
public async Task<ActionResult> List(int jtStartIndex, int jtPageSize)
{
	return await SecureJsonAction(async () =>
	{
		var result = await this.accountTypeManager.GetAccountTypesAsync(jtStartIndex, jtPageSize);
		return Json(ApiResponse.List(result.Records, result.TotalCount));
	});
}
Add
[HttpPost, ValidateAntiForgeryToken]
[PossessesPermissionCode]
public async Task<ActionResult> Add(AccountType model)
{
	return await SecureJsonAction(async () =>
	{
		if (ModelState.IsValid)
		{
			return Json(ApiResponse.Single(await this.accountTypeManager.AddAccountTypeAsync(model)));
		}

		throw new OpException(OpResult.InvalidInput);
	});
}
Edit
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(AccountType model)
{
	return await SecureJsonAction(async () =>
	{
		if (ModelState.IsValid)
		{
			return Json(ApiResponse.Single(await this.accountTypeManager.EditAccountTypeAsync(model)));
		}

		throw new OpException(OpResult.InvalidInput);
	});
}
Delete
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Delete([Authorize("AccountTypeId")] Guid id)
{
	return await SecureJsonAction(async () =>
	{
		await this.accountTypeManager.DeleteAccountTypeAsync(id);
		return Json(ApiResponse.Success());
	});
}

View Raw File

AccountTypeId Related ReferencesLoader

public async Task<List<IdReference>> AccountTypeId(Guid id)
{
	return await dbContext.AccountTypes
		.Where(x => x.Id == id)
		.Select(x => new List<Guid> { x.BankId })
		.ToIdReferenceListAsync();
}

View Raw File

Read the next section to learn more about it.

Authorization

  • By default, ADA builds a unique permissionCode for every action, however, by decorating the controller with AuthPermissionAttribute, we’re overriding the convention and indicating to use only one permission ManageAccountType for all the CRUD actions as per the reasons mentioned in the activity permissions section in step-1. This helps in limiting the problem of permissions bloat. If you have a need to assign individual AccountType actions – say only create capability but not delete/edit – you can go with separate permission for each action.
  • You also see that we’ve decorated Index, List and Add actions with PossessesPermissionCodeAttribute. This is because these actions have no input key data (EntityIds) to authorize against, and ManageAccountType permission has been implicitly granted against BankId (being implied permission of BankOwner role). Thus calls to these actions will be denied for not matching with any item in the user’s permit set. Such actions (having no EntityId expect to authorize the user on the mere basis of the user possessing the associated permission. These actions do not generally touch any sensitive existing data because nothing has been referred in the request. You may think for a moment that the List method is indeed touching the existing data – because it’s returning the records – but it’s based on trusted BankId value we had loaded from the database for the current user and not based on some arbitrary value sent in the request.
  • The AccountTypeId ReferencesLoader loads the related references (the entity hierarchy preceding the given EntityId) – BankId in this case – and only one of them is required to exist in user’s permit set for the given permission. This way you don’t have to grant a permit on every AccountTypeId to the user – just permit loaded with BankId is enough to authorize the action on all AccountTypes for that bank.
    • In whichever actions AccountTypeId is mentioned, ADA will use this AccountTypeId ReferencesLoader to authorize that value.
    • Usually, a ReferencesLoader also returns the EntityId itself part of the collection but here we don’t, because we won’t be granting a permit on individual AccountTypeIds.
  • The Delete action specifies AuthorizeAttribute for id param – this is exactly as we did in the model for the Id property, to specify the name – AccountTypeId – by which the ReferencesLoader is to be located.

Account Type Management UI

Account types list page
New account type creation page

For the menu item, in _NavBarPartial.cshtml, we need to add the following code:

if (Context.UserService().PossessesPermission(PermissionCodes.ManageAccountType))
{
	<li class="nav-item @Html.IsSelected("Index", "AccountType")">@Html.ActionLink("Account Types", "Index", "AccountType", routeValues: null, new { @class = "nav-link" })</li>
}

Setup Branches

A bank can setup as many branches as it needs to serve its customers in their vicinity. The types/logic required is similar to what we saw with AccountType, except that for each branch, a bank can (and should) manage users representing the branch manager and other branch staff who operate the day-to-day functions of that branch on behalf of the bank. While a bank owner can access data or perform an action related to the entire bank; branch staff have access to only the branch they have been appointed at.

For this feature, we need the following:

Models

Branch
public class Branch
{
	[Authorize("BranchId")]
	public Guid? Id { get; set; }

	[Required]
	[MaxLength(60)]
	public string Name { get; set; }

	[Required]
	[MaxLength(16)]
	public string Code { get; set; }

	public string Address { get; set; }

	public IList<BranchStaff> Staff { get; set; }
}

View Raw File

BranchStaff
public class BranchStaff
{
	public Guid? Id { get; set; }

	public string Name { get; set; }

	public string Username { get; set; }

	public string Role { get; set; }

	public Guid? BranchId { get; set; }
}

View Raw File

AddStaffModel
public class AddStaffModel
{
	[MaxLength(100)]
	[Required]
	[Display(Name = "Email")]
	[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
	public string Username { get; set; }

	[Required]
	[Display(Name = "Role")]
	public string Role { get; set; }

	public Guid? BranchId { get; set; }

	public IList<string> Roles { get; set; }
}

View Raw File

BranchManager

AddBranchAsync
public async Task<Branch> AddBranchAsync(Branch branch)
{
	try
	{
		var dbBranch = new DbBranch
		{
			Id = Guid.NewGuid(),
			Name = branch.Name,
			Code = branch.Code,
			Address = branch.Address,
			BankId = this.userService.BankId.GetValueOrDefault()
		};

		this.dbContext.Branches.Add(dbBranch);
		await this.dbContext.SaveChangesAsync().ConfigureAwait(false);

		return new Branch
		{
			Id = dbBranch.Id,
			Name = dbBranch.Name,
			Code = dbBranch.Code,
			Address = dbBranch.Address
		};
	}
	catch (Exception ex)
	{
		await this.logger.ErrorAsync(ex).ConfigureAwait(false);

		if (ex.GetBaseException() is SqlException sqlEx &&
			sqlEx.Number.In((int)SqlErrors.KeyViolation, (int)SqlErrors.UniqueIndex))
		{
			throw new OpException(OpResult.AlreadyExists,
				string.Format(SFMessages.CannotAddDuplicateBranch, branch.Name));
		}

		throw;
	}
}
EditBranchAsync
public async Task<Branch> EditBranchAsync(Branch branch)
{
	var dbBranch = await this.dbContext.Branches
		.Where(m => m.Id == branch.Id)
		.SingleOrDefaultAsync()
		.ConfigureAwait(false);

	if (dbBranch == null)
	{
		await this.logger.WarnAsync("{0} doesn't exist", branch.Name).ConfigureAwait(false);
		throw new OpException(OpResult.DoNotExist);
	}

	try
	{
		dbBranch.Name = branch.Name;
		dbBranch.Code = branch.Code;
		dbBranch.Address = branch.Address;

		await this.dbContext.SaveChangesAsync().ConfigureAwait(false);

		return new Branch
		{
			Id = dbBranch.Id,
			Name = dbBranch.Name,
			Code = dbBranch.Code,
			Address = dbBranch.Address
		};
	}
	catch (Exception ex)
	{
		await this.logger.ErrorAsync(ex).ConfigureAwait(false);

		if (ex.GetBaseException() is SqlException sqlEx &&
			sqlEx.Number.In((int)SqlErrors.KeyViolation, (int)SqlErrors.UniqueIndex))
		{
			throw new OpException(OpResult.AlreadyExists,
				string.Format(SFMessages.CannotAddDuplicateBranch, branch.Name));
		}

		throw;
	}
}
DeleteBranchAsync
public async Task DeleteBranchAsync(Guid id)
{
	var dbBranch = await this.dbContext.Branches
		.Where(m => m.Id == id)
		.SingleOrDefaultAsync()
		.ConfigureAwait(false);

	if (dbBranch != null)
	{
		try
		{
			this.dbContext.Branches.Remove(dbBranch);
			await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
		}
		catch (Exception ex)
		{
			await this.logger.ErrorAsync(ex).ConfigureAwait(false);
			throw new OpException(OpResult.DBDeletionDenied, SFMessages.CannotDeleteBranch);
		}
	}
	else
	{
		throw new OpException(OpResult.DoNotExist);
	}
}
GetBranchesAsync
public async Task<PagedResult<Branch>> GetBranchesAsync(int startIndex, int pageSize)
{
	var bankId = this.userService.BankId;

	var list = await this.dbContext.Branches.AsNoTracking()
		.Select(dummy => new
		{
			Total = this.dbContext.Branches.Count(p => p.BankId == bankId),
			ThisPage = this.dbContext.Branches.Where(p => p.BankId == bankId)
				.OrderBy(p => p.Name).Skip(startIndex).Take(pageSize)
				.Select(x => new Branch
				{
					Id = x.Id,
					Name = x.Name,
					Code = x.Code,
					Address = x.Address
				})
				.ToList()
		}).FirstOrDefaultAsync().ConfigureAwait(false);

	var branches = list?.ThisPage.ToList();
	if (branches != null)
	{
		var branchIds = branches.Select(x => x.Id).ToList();
		var staffPermissionCodes = new[] { SFPermissionCodes.BranchManager, SFPermissionCodes.BranchStaff };

		var branchStaff = await this.dbContext.UserPermits.Where(x =>
			branchIds.Contains(x.EntityId) && staffPermissionCodes.Contains(x.PermissionCode)).Select(x =>
			new BranchStaff
			{
				Id = x.Id,
				Name = x.UserPermitGroup.User.Name,
				Username = x.UserPermitGroup.User.Username,
				Role = x.PermissionCode.Replace("Branch", string.Empty),
				BranchId = x.EntityId
			}).ToListAsync().ConfigureAwait(false);

		foreach (var branch in branches)
		{
			branch.Staff = branchStaff.Where(x => x.BranchId == branch.Id).ToList();
		}
	}

	return new PagedResult<Branch>(branches, startIndex, pageSize, list?.Total ?? 0);
}
AddStaffAsync
public async Task AddStaffAsync(AddStaffModel model)
{
	await this.permitRepository.AddPermitAsync(model.Username,
		model.Role.Equals("Manager") ? SFPermissionCodes.BranchManager : SFPermissionCodes.BranchStaff,
		model.BranchId).ConfigureAwait(false);
}

View Raw File

  • The name is unique hence in both Add and Edit methods we’re handling the exception generated upon its violation.
  • In AddStaff, we’re granting the given role to an existing user; The actual user (employee) is added via the user management interface. Please note that the role permission is granted on a BranchId which makes sure that the user has access to only that branch.

BranchController

[AuthPermission("ManageBranch")]
public class BranchController : ServiceControllerBase
{
	...
}
List
[HttpPost]
[PossessesPermissionCode]
public async Task<ActionResult> List(int jtStartIndex, int jtPageSize)
{
	return await SecureJsonAction(async () =>
	{
		var branches = await this.branchManager.GetBranchesAsync(jtStartIndex, jtPageSize);
		return Json(ApiResponse.List(branches.Records, branches.TotalCount));
	});
}
Add
[HttpPost, ValidateAntiForgeryToken]
[PossessesPermissionCode]
public async Task<ActionResult> Add(Branch model)
{
	return await SecureJsonAction(async () =>
	{
		if (ModelState.IsValid)
		{
			return Json(ApiResponse.Single(await this.branchManager.AddBranchAsync(model)));
		}

		throw new OpException(OpResult.InvalidInput);
	});
}
Edit
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(Branch model)
{
	return await SecureJsonAction(async () =>
	{
		if (ModelState.IsValid)
		{
			return Json(ApiResponse.Single(await this.branchManager.EditBranchAsync(model)));
		}

		throw new OpException(OpResult.InvalidInput);
	});
}
Delete
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Delete([Authorize("BranchId")] Guid id)
{
	return await SecureJsonAction(async () =>
	{
		await this.branchManager.DeleteBranchAsync(id);
		return Json(ApiResponse.Success());
	});
}
AddStaff
public ActionResult AddStaff([Authorize("BranchId")] Guid id)
{
	var model = new AddStaffModel
	{
		BranchId = id
	};

	return View(PopulateAddStaffModel(model));
}

[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> AddStaff(AddStaffModel model)
{
	if (ModelState.IsValid)
	{
		try
		{
			await this.branchManager.AddStaffAsync(model);
			return RedirectToAction("Index");
		}
		catch (OpException ex)
		{
			ModelState.AddModelError(string.Empty, ex.Message);
		}
	}

	return View(PopulateAddStaffModel(model));
}

View Raw File

BranchId ReferencesLoader

public async Task<List<IdReference>> BranchId(Guid id)
{
	return await dbContext.Branches
		.Where(x => x.Id == id)
		.Select(x => new List<Guid> { x.Id, x.BankId })
		.ToIdReferenceListAsync();
}

View Raw File

Read the authorization section to learn more about it.

AddStaff AuthDefinition

public async Task<AuthResult> AddStaffAsync(AddStaffModel model, string permissionCode)
{
	var result = await this.entityIdAuthorizer.IsAuthorizedAsync("EditUser", new { model.Username });
	if (result.IsSuccess)
	{
		return await this.entityIdAuthorizer.IsAuthorizedAsync(permissionCode, new { model.BranchId });
	}

	return result;
}

View Raw File

Read the next section to learn more about it.

Authorization

  • In BranchController also, we’re overriding the default conventions by specifying the ManageBranch permissionCode for CRUD actions in this controller, as we did in case of AccountType.
  • You also see that we’ve decorated Index, List and Add actions with PossessesPermissionCodeAttribute. This is because of the same reason we had mentioned for similar actions in AccountType. The ManageBranch permission is an instance permission and thus requires an EntityId while these actions do not have any EntityId in the request. The BankId we’re using in the Add/List methods is a trusted value we had loaded from the database for the current user and not based on some arbitrary value sent in the request.
  • The AddStaff auth definition is written to implement custom authorization logic for the associated action because we need to authorize the Username against a different permissionCode – EditUser. You might have observed use of IEntityIdAuthorizer rather than IUserService to authorize BranchId and Username in the auth definition, the reason is to leverage the related references feature while authorizing these EntityIds.
  • The BranchId ReferencesLoader loads the related references (the entity hierarchy preceding the given EntityId) – BankId and BranchId in this case – and only one of them is required to exist in user’s permit set for the given permission. This way you don’t have to grant a permit on every BranchId to the user – just permit loaded with BankId is enough to authorize the action on all branches for that bank (although you can perfectly do that if there’s a requirement as we’re returning both BranchId and BankId – either of them needs to match in user’s permit set!)
  • The Delete action specifies AuthorizeAttribute for id param – this is exactly as we did in the model for the Id property, to specify the name – BranchId – by which the ReferencesLoader is to be located.

Branch Management UI

Branch list page
Assign a staff to branch

Setup and Manage Bank Accounts

As part of this feature, we’ll build interfaces to let staff create customers and their bank accounts and change account status. Again, the branch employees (including the manager) have only access to bank accounts related to their branch.

For this feature, we need the following:

Models

Account
public class Account
{
	public Guid? Id { get; set; }

	public string AccountNumber { get; set; }

	public string IdentityNumber { get; set; }

	public AccountStatus Status { get; set; }

	public string Reason { get; set; }

	public string AccountType { get; set; }

	public AccountKind AccountKind { get; set; }

	public string Branch { get; set; }

	public string Name { get; set; }

	public bool IsOwnAccount { get; set; }

	public double Balance { get; set; }

	public DateTime CreatedDate { get; set; }
}

View Raw File

CreateAccountModel
public class CreateAccountModel
{
	[MaxLength(60)]
	[Required]
	[Display(Name = "Name")]
	public string Name { get; set; }

	[MaxLength(100)]
	[Required]
	[Display(Name = "Email")]
	[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
	[DoNotAuthorize]
	public string Username { get; set; }

	[MaxLength(24)]
	[Required]
	[Display(Name = "Identity Number")]
	public string IdentityNumber { get; set; }

	[Required]
	[Display(Name = "Amount")]
	public double Amount { get; set; }
	
	public Guid? BranchId { get; set; }

	[Required]
	[Display(Name = "Account Type")]
	[DoNotAuthorize]
	public Guid AccountTypeId { get; set; }

	[Display(Name = "Nominee Email")]
	[RegularExpression(RegexPatterns.EmailAddress, ErrorMessageResourceName = "InvalidEmailAddress", ErrorMessageResourceType = typeof(Messages))]
	[DoNotAuthorize]
	public string NomineeUsername { get; set; }

	public IList<Branch> Branches { get; set; }

	public IList<AccountType> AccountTypes { get; set; }
}

View Raw File

  • Since customers in SuperFinance do not belong to a specific bank, we need not authorize their references in create account action, hence using the DoNotAuthorize attribute on both Username and NomineeUsername in CreateAccountModel.
  • The AuthorizeAttribute on Account.Id tells the actual name (AccountId) of the EntityId to help in locating the AccountId ReferencesLoader while authorizing Edit/etc. actions on the given Account instance, as we’ve seen in prior sections.

AddCustomerUserAsync in SFUserManager

AddCustomerUserAsync
public async Task<AppUser> AddCustomerUserAsync(AppUser user, string verificationUrl, string contactUrl)
{
	var result = await AddUserAsync(user, verificationUrl, contactUrl).ConfigureAwait(false);
	if (result != null)
	{
		await AddCustomerPermitsAsync(user.Username).ConfigureAwait(false);
		await SendCustomerWelcomeMailAsync(result, user.Password, contactUrl).ConfigureAwait(false);

		// reload permissions to include permission on newly added user entity.
		await this.userService.RefreshPermissionsAsync().ConfigureAwait(false);
	}

	return result;
}
private async Task AddCustomerPermitsAsync(string username)
{
	var dbUser = await this.dbContext.Users
		.Where(x => x.Username == username)
		.Include(x => x.MultiFactors)
		.SingleOrDefaultAsync()
		.ConfigureAwait(false);

	dbUser.UserType = UserType.Customer;
	dbUser.ParentId = null;
	dbUser.MultiFactors.First().Enabled = false;

	var dbUserInvites = await this.dbContext.UserInvitations
		.Where(x => x.EmailAddress == dbUser.Username)
		.ToListAsync()
		.ConfigureAwait(false);

	foreach (var invite in dbUserInvites)
	{
		var dbNominee = new DbAccountNominee
		{
			Id = Guid.NewGuid(),
			AccountId = invite.AccountId,
			NomineeUserId = dbUser.Id
		};
		this.dbContext.AccountNominees.Add(dbNominee);

		invite.UserId = dbUser.Id;

		await this.permitRepository
			.AddPermitAsync(dbUser.Id, SFPermissionCodes.AccountNominee, invite.AccountId)
			.ConfigureAwait(false);
	}

	await this.permitRepository
		.AddPermitAsync(dbUser.Id, SFPermissionCodes.Customer, null)
		.ConfigureAwait(false);

	await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}

View Raw File

  • The AddCustomerUserAsync sets up the customer user, grants it the Customer role permission and also AccountNominee role permission(s) for the pending invitation(s) (we’ll talk about it in the next step), and sends out a welcome email.
  • The password for new customer isn’t specified by the bank staff; it’s randomly generated by AddUser and sent via welcome email to the user. The password is marked as expired and thus user is required to change it upon login.
  • The Customer permit is granted with EntityId as null because it’s a general permit and implies such actions as open account only applicable to customer users.

GetBranchesAsync in BranchManager and GetAccountTypesAsync in AccountTypeManager

GetBranchesAsync
public async Task<List<Branch>> GetBranchesAsync(Guid? bankId, IList<Guid> branchIds)
{
	var dbBranches = dbContext.Branches.Where(p => p.BankId == bankId);

	if (branchIds != null && branchIds.Any())
	{
		dbBranches = dbBranches.Where(x => branchIds.Contains(x.Id));
	}

	return await dbBranches.OrderBy(p => p.Name)
				.Select(x => new Branch
				{
					Id = x.Id,
					Name = x.Name,
					Code = x.Code,
					Address = x.Address
				})
				.ToListAsync().ConfigureAwait(false);
}

View Raw File

GetAccountTypesAsync
public async Task<List<AccountType>> GetAccountTypesAsync(Guid? bankId, AccountKind? kind)
{
	var dbAccountTypes = this.dbContext.AccountTypes.Where(x => x.BankId == bankId);

	if (kind != null)
	{
		dbAccountTypes = dbAccountTypes.Where(x => x.Kind == kind);
	}

	return await dbAccountTypes.OrderBy(p => p.Name)
				.Select(x => new AccountType
				{
					Id = x.Id,
					Name = x.Name,
					InterestRate = x.InterestRate,
					Kind = x.Kind
				})
				.ToListAsync().ConfigureAwait(false);
}

View Raw File

  • These methods return all the records because we need to populate dropdowns with them so no paging.

AccountManager

CreateAccountAsync
public async Task<Account> CreateAccountAsync(CreateAccountModel model, string verificationUrl, string contactUrl, string registerUrl, string loginUrl)
{
	var dbUser = await this.dbContext.Users
		.SingleOrDefaultAsync(x => x.Username == model.Username).ConfigureAwait(false);
	var userId = dbUser?.Id;
	if (dbUser == null)
	{
		// leaving password empty so a random password is generated and marked as expired so user is forced to change it upon login
		var result = await this.userManager.AddCustomerUserAsync(
			new AppUser
			{
				Name = model.Name,
				Username = model.Username
			},
			verificationUrl, contactUrl);

		userId = result.Id;
	}
	else if (dbUser.UserType != UserType.Customer)
	{
		throw new OpException(OpResult.InvalidInput, string.Format(SFMessages.NotACustomerUser, model.Username));
	}

	var dbAccount = new DbAccount
	{
		Id = Guid.NewGuid(),
		Number = Guid.NewGuid().ToString("N").Substring(0, 10).ToUpper(),
		Status = AccountStatus.Active,
		IdentityNumber = model.IdentityNumber,
		AccountTypeId = model.AccountTypeId,
		BranchId = model.BranchId.GetValueOrDefault(),
		OwningUserId = userId.GetValueOrDefault(),
		CreatedDate = DateTime.UtcNow
	};

	return await AddAccountAsync(dbAccount, model.NomineeUsername, registerUrl, loginUrl, model.Amount).ConfigureAwait(false);
}
GetAccountsAsync
public async Task<PagedResult<Account>> GetAccountsAsync(int startIndex, int pageSize)
{
	var dbAccounts = this.dbContext.Accounts.AsNoTracking();

	if (this.userService.IsBankOwner())
	{
		dbAccounts = dbAccounts.Where(x => x.Branch.BankId == this.userService.BankId);
	}
	else
	{
		var branchIds = this.userService.GetEmployeeBranchIds();
		dbAccounts = dbAccounts.Where(x => branchIds.Contains(x.BranchId));
	}

	var list = await dbAccounts
		.Select(dummy => new
		{
			Total = dbAccounts.Count(),
			ThisPage = dbAccounts
				.Include(x => x.OwningUser)
				.Include(x => x.Branch)
				.Include(x => x.AccountType)
				.Include(x => x.Nominees)
				.OrderBy(p => p.Number)
				.Skip(startIndex).Take(pageSize)
				.Select(x => new Account
				{
					Id = x.Id,
					AccountNumber = x.Number,
					IdentityNumber = x.IdentityNumber,
					Status = x.Status,
					Branch = x.Branch.Name,
					AccountType = x.AccountType.Name,
					AccountKind = x.AccountType.Kind,
					Name = x.OwningUser.Name,
					CreatedDate = x.CreatedDate
				})
				.ToList()
		}).FirstOrDefaultAsync().ConfigureAwait(false);

	return new PagedResult<Account>(list?.ThisPage.ToList(), startIndex, pageSize, list?.Total ?? 0);
}
ChangeAccountStatusAsync
public async Task ChangeAccountStatusAsync(Guid id, AccountStatus status, string reason)
{
	var dbAccount = await this.dbContext.Accounts.FindAsync(id).ConfigureAwait(false);
	if (dbAccount != null)
	{
		if (status != dbAccount.Status)
		{
			if (suspendedAccountStatuses.Contains(dbAccount.Status))
			{
				await this.suspensionManager.DeleteEntityAsync(id);
			}

			if (suspendedAccountStatuses.Contains(status))
			{
				await this.suspensionManager.AddEntityAsync(new SuspendedEntity(id, "Account", status.ToString()));
			}
		}

		dbAccount.Status = status;
		dbAccount.Reason = reason;
		await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
	}
	else
	{
		throw new OpException(OpResult.DoNotExist);
	}
}
GetAccountStatusesAsync
public async Task<IList<string>> GetAccountStatusesAsync(Guid accountId)
{
	var accountStatus = await this.dbContext.Accounts
		.Where(p => p.Id == accountId)
		.Select(x => x.Status)
		.SingleOrDefaultAsync()
		.ConfigureAwait(false);

	return Enum.GetNames(typeof(AccountStatus)).Where(x => !x.Equals(accountStatus.ToString())).ToList();
}

View Raw File

  • In CreateAccountAsync, the flow is working as follows:
    1. Set up the customer user if it doesn’t exist.
    2. Create the account and grant AccountHolder role permission on the new account to the customer user.
    3. If the nominee user already exists, grant it the AccountNominee role permission and send an email notification.
    4. If it doesn’t, create an invitation and send an invite email.
    5. Create either a debit transaction if AccountKind is Loan or credit transaction if AccountKind is Investment.
  • In ChangeAccountStatusAsync, we’re updating the status and adding an entity suspension record for non-active status (or removing the existing record otherwise). We’ll talk about entity suspension rules in step4 – Advance Security controls.
  • In GetAccountsAsync, we’re looking up for all accounts across the current user’s bank if the user is BankOwner; otherwise, only based on the current (staff) user’s access to branches.

ManageAccountController

	[AuthEntity("CustomerAccount")]
	public class ManageAccountController : ServiceControllerBase
	{
		...
	}
Index
[PossessesPermissionCode]
public ActionResult Index()
{
	return View();
}
Create
[PossessesPermissionCode]
public async Task<ActionResult> Create()
{
	var model = new CreateAccountModel();
	await PopulateCreateAccountModel(model);
	return View(model);
}

[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> Create(CreateAccountModel model)
{
	if (ModelState.IsValid)
	{
		try
		{
			var result = await this.accountManager.CreateAccountAsync(model, VerificationUrl(), ContactUrl(), RegisterUrl(), LoginUrl());
			if (result != null)
			{
				return RedirectToAction("Index");
			}
		}
		catch (OpException ex)
		{
			ModelState.AddModelError(string.Empty, ex.Message);
		}
	}

	await PopulateCreateAccountModel(model);
	return View(model);
}
ChangeStatus
[AuthPermission("ChangeStatus")]
public async Task<ActionResult> ChangeStatus(Guid accountId)
{
	var model = new ChangeAccountStatusModel
	{
		Statuses = await this.accountManager.GetAccountStatusesAsync(accountId)
	};

	return View(model);
}

[HttpPost, ValidateAntiForgeryToken]
[AuthPermission("ChangeStatus")]
public async Task<ActionResult> ChangeStatus(Guid accountId, ChangeAccountStatusModel model)
{
	if (ModelState.IsValid)
	{
		if (Enum.TryParse(model.Status, out AccountStatus status))
		{
			await this.accountManager.ChangeAccountStatusAsync(accountId, status, model.Reason);
			return RedirectToAction("Index");
		}
		else
		{
			ModelState.AddModelError(nameof(model.Status), "Please select valid account status");
		}
	}

	model.Statuses = await this.accountManager.GetAccountStatusesAsync(accountId);

	return View(model);
}
List
[AuthAction("Index")]
[PossessesPermissionCode]
[HttpPost]
public async Task<ActionResult> List(int jtStartIndex, int jtPageSize)
{
	return await SecureJsonAction(async () =>
	{
		var accounts = await this.accountManager.GetAccountsAsync(jtStartIndex, jtPageSize);
		return Json(ApiResponse.List(accounts.Records, accounts.TotalCount));
	});
}
PopulateCreateAccountModel
private async Task PopulateCreateAccountModel(CreateAccountModel model)
{
	var branchIds = this.userService.PossessesPermission(SFPermissionCodes.BankOwner)
		? null
		: this.userService.GetEmployeeBranchIds();

	model.Branches = await this.branchManager.GetBranchesAsync(this.userService.BankId, branchIds);
	model.AccountTypes = await this.accountTypeManager.GetAccountTypesAsync(this.userService.BankId, null);
}

View Raw File

AccountId ReferencesLoader

public async Task<List<IdReference>> AccountId(Guid id)
{
	return await dbContext.Accounts
		.Where(x => x.Id == id)
		.Select(x => new List<Guid> { x.Id, x.BranchId, x.Branch.BankId })
		.ToIdReferenceListAsync();
}

View Raw File

Read the next section to learn more about it.

Authorization

  • ADA conventionally builds a unique PermissionCode by joining the action and controller names. By decorating the controller with AuthEntityAttribute, we’re overriding the controller part of the PermissionCode as CustomerAccount.
  • With AuthAction attribute, we’re overriding the action name part of the permission here with Index because List action exists only to retrieve accounts data for the Index view via ajax, so only one permission is needed for both actions.
  • You also see that we’ve decorated Index, List and Create (http get) actions with PossessesPermissionCodeAttribute. This is because of the same reason we had mentioned for similar actions in AccountType. These actions have no Id request input and hence they can’t be authorized against a complete permit (which includes both permission and id). The Create (http post) does have a BranchId property and hence that needs to be authorized therefore, no PossessesPermissionCodeAttribute on it.
  • The AccountId ReferencesLoader loads the related references (the entity hierarchy preceding the given EntityId) – BankId, BranchId, and AccountId in this case – and only one of them is required to exist in user’s permit set for the given permission. This way you don’t have to grant a permit on every AccountId to the staff user – just permit loaded with BankId is enough to authorize the action on all Accounts for that bank (although you can perfectly do that if there’s a requirement as we’re also returning AccountId, and only one of them needs to match in the user’s permit set!)
  • Both the ChangeStatus actions specify AuthorizeAttribute for id param – this is exactly as we did in the model for the Id property, to specify the name – AccountId – by which the ReferencesLoader is to be located.

Account Management UI

Showing list of accounts of a branch
Showing account creation page
Showing account status changing page

Try Out the Live Demo

Visit https://superfinance.ASPSecurityKit.net to play with a live demo based on this sample.

Don't Miss Out!

Be the first to get notified when the new quality content related to web app security like the one you're reading is posted.