Skip to content

Architecture — Security & Permissions

Authorization in the NKO ERP is built from five independent layers. A request must clear the first two to reach an action at all; the remaining three narrow what the action then shows.

# Layer Enforced by Question it answers
1 Identity ASP.NET Core Identity cookie Who is signing in?
2 Permission key [ClaimRequirement("Key")] → ClaimRequirementFilter May they reach this action?
3 Inline UI check PermissionChecker.HasPermission in views Should this button be rendered?
4 Data scope BUser.GetPermission(SharedPermissions.Area, userId) Which rows may they see?
5 Org hierarchy StaffPermissionchecker over ManagerId / TechManagerId Are those employees theirs?

Layers 3–5 live inside the action, not in a filter, so they are applied only where a developer remembered to apply them.

Two identities per person

flowchart TD
    P["Person"] --> AU["ApplicationUser - the Identity login"]
    P --> EMP["AppUser - the employee record"]
    AU -. IdentityUserId .-> EMP
    EMP --> M["AppUserGroupMember - IsEnable true"]
    M --> G["AppUserGroup - the role"]
    G --> GP["AppUserGroupPermission"]
    GP --> AP["AppPermission - PermissionName is the key"]

ApplicationUser is the sign-in record; AppUser is the employee. They are joined by AppUser.IdentityUserId. Almost all application code works with AppUser through CurrentRequestData.Employee.

A person's effective permissions are the union of the keys of every enabled group they belong to. There is no fixed role list — an administrator creates groups and attaches keys. 219 distinct keys are in force across 887 gated actions. (A 220th literal, GRNDelete, appears only inside a commented-out GRNReportController.Delete method, so it gates nothing.) The special key General means "any authenticated employee".

Identity configuration

From Startup.ConfigureServices:

services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
    options.Password.RequireDigit           = false;
    options.Password.RequiredLength         = 8;
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequireUppercase       = false;
    options.Password.RequireLowercase       = false;
})
.AddEntityFrameworkStores<NKO_DBEntities1>()
.AddUserManager<ApplicationUserManager>()
.AddDefaultTokenProviders();

services.ConfigureApplicationCookie(o => o.AccessDeniedPath = new PathString("/Error/UnAuthorised"));
services.Configure<SecurityStampValidatorOptions>(o => o.ValidationInterval = TimeSpan.Zero);
Setting Value Consequence
Minimum length 8 The only complexity rule in force
Digit / uppercase / lowercase / symbol all not required "aaaaaaaa" is an acceptable password
Password hashing BCrypt via BCryptPasswordHasher Stronger than the Identity default
Security-stamp revalidation TimeSpan.Zero Disabling a login or changing groups takes effect on the next request
Access-denied path /Error/UnAuthorised Where a failed key check lands

Password policy is weaker than the 8-character minimum suggests

With every character-class requirement disabled, an eight-character all-lowercase password is valid. Combined with the hard-coded initial password (R6-3) this is worth revisiting — tracked as R1-2 in Recommendations.

How the gate behaves

ClaimRequirementFilter runs before the action:

Situation Result
Not authenticated 302 → ~/Account/Login?ReturnUrl={path}
Authenticated, key missing ForbidResult, status 403, then redirect to ~/Error/UnAuthorised
Authenticated, IsEnableLogin == false PermissionsHandler returns an empty permission list, so every keyed action fails
Authenticated, key present CurrentRequestData.Employee and .Permissions are populated and the action runs

There is no default-deny — R3-2

ConfigureServices registers no global authorization filter and no FallbackPolicy. An action with no attribute is reachable by anyone, authenticated or not. 44 of 947 actions are in that state; about 15 are public by design and the rest look accidental — including eight internal helper methods that are public only because nobody marked them private (AccountController.SignIn / SignOut / GetUserClaims, four AttendancesController calculation helpers — PercentageFilled, HourPercentageFilled, GetMissingListByUser, GetMissingList — and GeneralController.FillDepartmentsOrg). Adding a fallback policy would turn a forgotten attribute into a redirect rather than an open endpoint. See Ungated actions for the full breakdown.

AccountController.LogOut is not in that group despite also being ungated: it is a genuine endpoint, linked from the "Sign out" control in Views/Base/TopNavBar.cshtml and the shared topbar component. It returns plain Task rather than an IActionResult, which is why it does not look like an action at a glance.

Data scoping

Reaching an action does not decide which rows it returns. Actions that need narrowing call:

var scope = await _user.GetPermission(SharedPermissions.PR, userId);

DataPermissionType has four values:

Value Effect
All Every row in the area
Location Rows belonging to the user's branch
Custom Rows for an explicitly assigned set of branches or projects
Default Only the user's own records, plus anything where they hold a role slot

Per-project access is stored separately in AppUserProjectPermission, and StaffPermissionchecker resolves the manager → staff subtree so a line manager sees their own people and no one else's.

E-signature

Most workflow transitions are gated a second time: BSign.HasSign(userId) refuses with "Please upload your E-signature." if the acting user has no approved AppUserSign. Uploading a signature is itself an approval workflow, so signing authority is administered rather than self-granted.

The gate is applied per workflow, not centrally, and it is missing from several:

Workflow E-signature checked?
Purchase Request, RFQ (both endpoints), Purchase Order, GRN, Payment Order yes — in the ContextActions action
Asset, Asset registration, Stock, GRN report, Maintenance yes — in the action
Advance, Overtime, Leave yes — PIN (VerifyPin) in the action, HasSign in the business class
Timesheet only when the requester sends (BTimesheet.SetSent), not on approval
Payroll, Training, Evaluation, Destruction no
Jobs — requisition, technical interview, applicant no
Purchase Order justification (JustificationContextActions) no

An approver with no approved signature can therefore sign off a payroll run or a training request. Tracked as R3-9.

JWT

A JWT bearer scheme is registered, and AddAuthentication does ask for it as the default authenticate / challenge / sign-in scheme. That default never takes effect, however: AddIdentity<ApplicationUser, IdentityRole>(...) is called immediately afterwards, and it registers its own AuthenticationOptions configuration setting the defaults back to IdentityConstants.ApplicationScheme. Configure callbacks run in registration order, so Identity's cookie wins and the browser UI authenticates normally. The JWT options themselves are still internally contradictory:

ValidateIssuerSigningKey = true,
ValidateIssuer           = false,
ValidateAudience         = false,
RequireExpirationTime    = false,
ValidateLifetime         = true

RequireExpirationTime = false together with ValidateLifetime = true is contradictory — a token with no exp claim passes lifetime validation because there is nothing to check. Issuer and audience validation are both off. No endpoint in the solution issues or consumes a JWT, so this appears to be scaffolding left in place. Tracked as R6-4.

The ordering is load-bearing

Because the cookie default only wins by virtue of AddIdentity being called after AddAuthentication, reordering those two lines during a refactor would silently switch the whole application to bearer-token authentication and break every browser session. Removing the unused JWT registration outright is the safer fix.

Known gaps

Every item below is advisory and unfixed; full detail in Recommendations.

ID Issue Priority
R3-1 PublicFilesController streams any object-storage file by key with no authentication and no ownership check; FilesController's ownership check is commented out High
R3-2 No fallback authorization policy — 44 ungated actions High
R1-1 UseDeveloperExceptionPage() runs in production; no UseExceptionHandler High
R3-6 131 actions gated by a key from another feature; several let a weaker key perform a delete Medium
R3-5 ~17 destructive actions reachable by GET Medium
R3-4 114 of 343 POST actions have no anti-forgery token Medium
R6-3 Hard-coded initial password "321321321" Medium
R1-2 Password policy disables every character-class requirement Medium
R6-2 A failed sign-in e-mails the account owner every time, unthrottled Medium
R3-7 Eight internal helper methods are public and therefore routable as actions, including /Account/SignIn Medium
R6-7 Account lockout never engages — the wrong-password branch returns before PasswordSignInAsync Medium
R8-6 The E-signature PIN is MD5-hashed, unsalted, through an encoder that is not hex and can collide High
R3-9 The e-signature gate is missing from payroll, training, jobs, evaluation, PO justification and destruction approvals Medium
R3-8 56 User.IsInRole(...) checks, and nothing in the application ever grants a role Medium
R8-3 The permission gate matches keys case-sensitively, the view helper case-insensitively Medium
R6-4 Contradictory JWT validation settings on an unused scheme Low–Medium