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 | [Claim → Claim |
May they reach this action? |
| 3 | Inline UI check | Permission in views |
Should this button be rendered? |
| 4 | Data scope | BUser. |
Which rows may they see? |
| 5 | Org hierarchy | Staff over Manager / Tech |
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 BCrypt |
Stronger than the Identity default |
| Security-stamp revalidation | Time |
Disabling a login or changing groups takes effect on the next request |
| Access-denied path | / |
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 → ~/ |
| Authenticated, key missing | Forbid, status 403, then redirect to ~/ |
Authenticated, Is |
Permissions returns an empty permission list, so every keyed action fails |
| Authenticated, key present | Current and . 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:
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 Context action |
| Asset, Asset registration, Stock, GRN report, Maintenance | yes — in the action |
| Advance, Overtime, Leave | yes — PIN (Verify) in the action, Has in the business class |
| Timesheet | only when the requester sends (BTimesheet.), not on approval |
| Payroll, Training, Evaluation, Destruction | no |
| Jobs — requisition, technical interview, applicant | no |
Purchase Order justification (Justification) |
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 | Public streams any object-storage file by key with no authentication and no ownership check; Files's ownership check is commented out |
High |
| R3-2 | No fallback authorization policy — 44 ungated actions | High |
| R1-1 | Use runs in production; no Use |
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 / |
Medium |
| R6-7 | Account lockout never engages — the wrong-password branch returns before Password |
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. 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 |
Related¶
- Permissions flow · Authentication flow
- Request Pipeline · API Overview
- Administration module — the screens that manage groups and keys