| name | project-mtp-authz-model | ||
|---|---|---|---|
| description | Authorization model — single Permission concept, single-column ltree/jsonb scope, JWT claims, menu derived. Replaces GNUMS SEC_ sprawl. | ||
| metadata |
|
Target authorization model for the multi-tenant platform. Detailed rationale in ADR-0004. Companion memory: [[project-mtp-tenancy-model]], [[project-mtp-persistence-model]], [[project-mtp-postgres-ltree]].
Why: Legacy GNUMS SEC_ schema (~50 tables, 4082 operations, 5575 menus, 297 roles, 58k allocations, 151 TVFs) is unfit for replication. Concrete smells: 15 nullable scope FK columns on SEC_RoleAllocation, Menu vs Operation duplication, SQL-Server TVF lock-in, session-based auth, kitchen-sink SEC_ mixing authz + login audit + approval workflow + dashboards + glossary.
Decisions (locked):
- No copying GNUMS tables.
INS_Department,INS_Program,INS_Faculty,INS_Course,SEC_Role,SEC_Operation,SEC_Menuetc. are input only. Their hierarchy is also not assumed — different tenants have different depth/labels/dimensions. Our own tables, our own model. - Configurable org hierarchy.
org."OrgLevelType"(per-tenant catalogue of level codes/labels/depth) +org."OrgNode"(single tree withPath ltree). Adding a new dimension a client needs = insert one row, no schema change, no code change. - One
Permissionconcept. Single string-keyed code (e.g.exam.result.publish) + densePermissionId smallintfor bitmap-style checks. No separate Menu + Operation. UI menu = projection of granted permission set + per-tenant menu config. - Permission codes are forever (Hard Rule 11 applied to authz).
- Single scope column per grant row —
ScopePath ltreefor hierarchical scope,ScopeExtra jsonbfor non-hierarchical extras. Never 15 nullable FKs. - Top-level scope vanishes under DB-per-client. No
UniversityID/InstituteID/CompanyIDcolumns. In-tenant org dimensions collapse into oneOrgNodeId(FK).OrgPath ltreeis conditionally denormalised — see decision 5a. 5b. One canonical tree per tenant, not a DAG. GNUMSINS_*tables form a DAG (Faculty↔Institute many-to-many viaINS_InstituteWiseFaculty, self-nesting Institute/Department, Course skips Institute, Program has 5 simultaneous parents, parallel mirrors like SalaryFacultyID/DegreeFacultyID). We do not port this faithfully. Each tenant picks ONE primary academic tree for authz scoping. Secondary dimensions (salary, exam centre, hostel cluster, transport route) go intoScopeExtra jsonb, not extra ltree columns. Multi-tree-per-tenant deferred until a concrete tenant demands it. 5c. Empirical depth check (May 2026 GNUMS_Development snapshot): max observed scope FKs populated onSEC_RoleAllocation= 6 (across 56 of 58 236 rows). Typical = 4 (62%). 8-level ltree cap has 2 levels of headroom. 5a. Two storage patterns, picked per business table:- Pattern A (denormalised):
OrgPathstored on every row. Use for hot, huge, write-heavy tables (audit log, ledger, exam attempts, attendance, payment lines). Fastest filter, but org-restructure requires bulk rewrite job. - Pattern B (joined): only
OrgNodeIdon row; filter joinsorg."OrgNode"(or materialised vieworg."NodePath"). Use for lower-volume entities (Student, Staff, Course, Result, LibraryItem). Tiny join, big storage win, painless org moves. - Path cap: 8 levels × 32 chars/label ≈ 256 chars total, CHECK-enforced.
- Org-move event fires two jobs: OrgNode subtree path rewrite (always) + Pattern-A table fan-out (gated, chunked).
- Pattern A (denormalised):
- JWT-backed auth. Token carries
sub+client(tenant code). Permission set resolved server-side after token validation, cached per request. No session affinity. - Insane-fast filtering — non-negotiable. Target: < 100 µs added per query.
- Per-JWT
PermissionSnapshotcached in-process (sliding TTL, invalidated by grant events). FrozenSet<int>/ bitmap of permission ids for O(1) check.- Materialised
authz."UserScopeExpanded"(UserId, PermissionCode, OrgPath) — pre-expanded effective access, refreshed on grant events. - GIST on every
Path ltree; GIN on everyjsonb; partial index on live grants (ValidTo IS NULL OR ValidTo > now()). IPermissionFilter<T>composesIQueryable<T>predicate (EF Core) or SQL fragment (Dapper). Never 151 SP TVFs.- Postgres native RLS = defence-in-depth on sensitive tables only, never primary.
- Per-JWT
- Domain separation:
identityschema → auth (login, password, OTP, lockout)authzschema → roles, permissions, scope, grantsorgschema → org hierarchy (level types + nodes)workflowschema → approval flows (not authz)- Feature flags → separate concern, never in permission rows
- Login + grant audit → Marten event stream (not per-table
LOG_*/LOJ_*shadows)
- Grant history = event stream (
UserGranted,UserRevoked,RoleAssigned,ScopeChanged) in Marten per user aggregate. Current state = projection.UserScopeExpandedis rebuilt from these events. Replayable. - User-level override is first-class but bounded:
authz."UserPermissionOverride"with explicitEffect = Grant|Deny, reason, validity window. No scatter acrossUserWiseFeeHead/UserWisePaymentMode/etc.
Core tables (sketch):
authz."Permission"— code, module, display name, metadataauthz."Role"— role definition + validity windowauthz."RolePermission"— role → permission grantsauthz."UserRole"— user → role withScopePath(ltree) +ScopeExtra(jsonb) + validityauthz."UserPermissionOverride"— direct user grant/deny with scope + reason
How to apply:
- New feature → define permission code first (e.g.
module.entity.action), register inauthz."Permission". Code stable forever. - Controllers stay MVC (Hard Rule 4); use
[RequirePermission("code")]attribute filter, never MediatR pipeline behaviors. - Service layer pulls user's scope paths via
IUserScopeProvider.GetScopePaths(permissionCode)and composes predicate viaIPermissionFilter<T>. - Adding a new org dimension = adding an ltree segment definition, not a schema migration on every table.
- When discussing GNUMS code that uses
Session["UserID"],TVF_FilterBy*, or scope FK columns, treat as legacy pattern to translate, not to copy.