Files
pfm/Architecture.md
hermes 5d7e2f74ce RBAC Improvements
Caching improvements
Maker-Checker Implementation
2026-10-21 07:27:47 +02:00

16 KiB

PostFundManagement (PFM) Platform — Architectural Specification

Document Version: 2.0
Target Runtimes: .NET 10.0 (C# 13) | React 19 / TypeScript 5.8 / Vite
Scope: System Architecture, Security Topology, RBAC Enforcement, and Maker-Checker Pattern


1. System Topology & Architectural Layers

The PFM platform is constructed using Clean Architecture and Domain-Driven Design (DDD) principles to guarantee strict decoupling of business rules, persistence concerns, and user interfaces.

┌────────────────────────────────────────────────────────────────────────┐
│                          PRESENTATION TIER                             │
│  PostFundManagement.ConsoleUI      │  PostFundManagement.PartnerUI     │
│  (Internal Staff & Governance)     │  (External Recipient Portal)      │
│  React 19 + TypeScript + Vite      │  React 19 + WebGL (OGL) + Vite    │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │ HTTPS / REST (v1/api/*) + OIDC PKCE
┌───────────────────────────────────▼────────────────────────────────────┐
│                             API GATEWAY                                │
│  PostFundManagement.Api (ASP.NET Core Minimal APIs / Carter)           │
│  Authentication: JWT Bearer + OpenTelemetry Observability              │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │
┌───────────────────────────────────▼────────────────────────────────────┐
│                          APPLICATION LAYER                             │
│  PostFundManagement.Application                                        │
│  CQRS (Mediator), Business Validators, Domain Services, Quartz Jobs   │
└──────────────────┬─────────────────────────────────┬───────────────────┘
                   │                                 │
┌──────────────────▼──────────────────┐   ┌──────────▼───────────────────┐
│            DOMAIN LAYER             │   │    INFRASTRUCTURE LAYER      │
│  PostFundManagement.Domain          │   │  PostFundManagement.Infrastr.│
│  Entities, Value Objects, Enums,    │   │  PostgreSQL (EF Core 10),    │
│  Domain Invariants, Audit Models    │   │  Skoruba STS, S3 Garage,     │
└─────────────────────────────────────┘   │  Redis HybridCache           │
                                          └──────────────────────────────┘

2. Security & Identity Architecture

Authentication and authorization are separated cleanly across system boundaries:

  • Security Token Service (STS): Duende IdentityServer / Skoruba Administration (https://sts.security.khongisa.co.za) issues cryptographically signed JWT access tokens using OpenID Connect (OIDC) with Proof Key for Code Exchange (PKCE).
  • Audience & Scopes: Web clients request standard OIDC scopes (openid, profile, roles, email, address) along with the target API resource scope (pfm-api-dev).
  • Tenant Isolation: Every authenticated principal carries a tenant_id claim. Multitenant query filters in EF Core enforce cross-tenant partition boundaries at the data access layer.
  • Partner Isolation: Grantees and external recipients possess the partner role. The ConsoleUI enforces immediate lockout for partner roles, restricting external users exclusively to PartnerUI.

3. Role-Based Access Control (RBAC) Architecture

PFM implements a fine-grained, claim-based RBAC model where system permissions are decomposed into 15 operational domains and 5 progressive action capabilities.

3.1 Role Claims Inventory

Claim Key Operational Domain Functional Scope
dashboard Executive Dashboard Macro portfolio KPIs, disbursed capital, and ISO 31000 risk heatmap
governance Governance & Structure Holding entities, funder portfolios, programmes, rules, and instruments
projects_milestones Projects & Milestones Work breakdown structure (WBS), contractual milestones, and activities
grant_financials Grants & Financials Statutory funding awards, budget lines, contracts, and disbursement tranches
evidence_risks M&E, Evidence & Risks Deliverable evidence verification queue, indicators, and risk registers
beneficiaries Beneficiaries & Impact Demographic targets, POPIA compliance items, and outcome deliverables
change_management Change Management Project issues, contractual change requests, and budget variations
site_visits Site Visits Physical field inspections, checklist observations, and inspector findings
settings_invites Settings Invites Onboarding invitations and candidate notifications dispatch
settings_users Settings Users User provisioning, role assignments, and account lockouts
settings_auditlog Settings Audit Log Immutable audit ledger and system telemetry records
settings_notifications Settings Notifications SMTP engines, notification channels, and dispatch telemetry
tenant_self_service Tenant Self-Service Tenant profile configuration and delegated sub-user management
tenant_management Tenant Management Multi-tenant directory, partition allocation, and solution ownership
unrestricted System Administration Root administrative bypass across all system partitions

3.2 Claim Actions & Capability Hierarchy

Action capabilities within a claim domain follow a strict subset hierarchy:

\text{view} \subset \text{add} \subset \text{update} \quad \text{and} \quad \text{view} \subset \text{export}
  • view: Read-only access to entity lists, detailed records, metrics, and progress scores.
  • add: Can create new records, edit draft records, submit milestone evidence, and change workflow statuses other than approval (includes view).
  • update: Authoritative governance capability. Can execute approval status transitions (Approved, Active contracts) on records (includes view and add).
  • export: Can generate and download structured spreadsheets (.xlsx) and formal PDF reports (includes view).
  • all: Comprehensive authority (view, add, update, export) over the claim domain.

3.3 Authoritative Role-to-Claims Matrix

Role Assigned Claim Domains Standard Capabilities Record Approval Authority (update)
Admin unrestricted all Yes (System-wide)
Executive dashboard, governance, projects_milestones, grant_financials, evidence_risks, tenant_self_service all Yes
Manager dashboard, projects_milestones, grant_financials, evidence_risks, beneficiaries, change_management, site_visits, settings_invites, settings_users, settings_auditlog, settings_notifications, tenant_self_service all Yes
Supervisor dashboard, projects_milestones, grant_financials, evidence_risks, beneficiaries, change_management, site_visits, settings_invites, settings_users view, add, update, export
(Dashboard: view, export)
Yes
Contributor projects_milestones, grant_financials, evidence_risks, beneficiaries, change_management, site_visits, settings_invites view, add ❌ Strictly Prohibited

4. Maker-Checker Pattern Architecture

To eliminate fraud risk, self-dealing, and conflicts of interest, PFM enforces the Maker-Checker Pattern (Segregation of Duties / Dual Authorization).

4.1 Core Principle

The user who creates or drafts a record (the "Maker") is strictly prohibited from verifying or approving that record (the "Checker"), regardless of their administrative role or RBAC claim privileges.

Even if an Executive, Manager, or Supervisor possesses the update claim action on a domain, they cannot approve a record if they are identified as the record's creator.

flowchart LR
    A["Maker (User A)<br/>Creates Record"] --> B["Record Persisted<br/>with createdBy = User A"]
    B --> C{"Checker Evaluation<br/>(Approver Login)"}
    C -->|Checker == User A| D["Approval BLOCKED<br/>Maker-Checker Violation"]
    C -->|Checker == User B<br/>with 'update' claim| E["Approval GRANTED<br/>Status -> Approved"]

4.2 Record Attribution Model

Every operational domain entity immutably captures the creator identity:

  • createdBy: Cryptographic user identifier (Guid / Subject ID) populated from the verified token claim context.GetUserId() upon creation.
  • approvedBy: User identifier of the independent verifier populated upon successful transition to Approved.

4.3 Client-Side Enforcement Hooks & Helpers

In PostFundManagement.ConsoleUI, the Maker-Checker rule is evaluated dynamically via useRbac():

  • isMaker(createdBy): Compares createdBy against current user user.id, sub, and user.email.
  • canApproveRecord(claim, createdBy): Evaluates: \text{canApproveRecord} = \text{canApprove}(\text{claim}) \land \neg\text{isMaker}(\text{createdBy})
  • getApprovalRestrictionReason(claim, createdBy): Surfaces explanatory feedback:
    • "Requires update/approval permission" if the claim lacks update.
    • "Maker-Checker policy: Record creators cannot approve their own records" if isMaker is true.

4.4 UI Guard Implementations

  1. Status Dropdowns: The Approved option in status <select> controls is disabled with contextual labels:
    <option value={ApprovalStatus.Approved} disabled={!canApproveRecord('projects_milestones', record.createdBy)}>
      Approved {isMaker(record.createdBy) ? '(Maker-Checker: Cannot approve own record)' : ''}
    </option>
    
  2. Action Buttons: Fast-track "Approve" buttons (e.g. Budget Approval, Disbursement Authorisation, Evidence Review) are hidden or accompanied by a Maker-Checker indicator badge when viewed by the creator.
  3. Export Controls: <ExportButton> enforces domain claim checks explicitly (claim="grant_financials"), rejecting unauthenticated exports.

5. Caching Strategy, Mutation Versioning & Performance Optimization

To achieve sub-50ms page transitions while guaranteeing data freshness across mutations in a distributed architecture, PFM employs a multi-tiered caching topology.

┌────────────────────────────────────────────────────────────────────────┐
│ REACT QUERY CACHE (In-Memory Browser Tier)                             │
│ staleTime: 2m (dynamic) | 5m (taxonomy) | gcTime: 10m                  │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │ Cache Miss / Background Refetch
┌───────────────────────────────────▼────────────────────────────────────┐
│ AXIOS CLIENT INTERCEPTOR & MUTATION VERSIONING (_v)                     │
│ Reads/Browsing: attaches active `_v` epoch token                       │
│ Mutations (POST/PUT/PATCH/DELETE): auto-bumps epoch -> new `_v` token  │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │ HTTP GET /v1/api/*?_v=<epoch>
┌───────────────────────────────────▼────────────────────────────────────┐
│ ASP.NET CORE OUTPUTCACHE (Redis / Memory Tier)                         │
│ Base Policy: 2-minute expiration partitioned by URI + QueryString       │
│ Hits same `_v` -> 2ms response | New `_v` -> DB query & cache refresh  │
└────────────────────────────────────────────────────────────────────────┘

5.1 The OutputCache Stale Data Challenge

ASP.NET Core Minimal APIs in PostFundManagement.Api attach .CacheOutput() to GET endpoints, using a 2-minute base sliding expiration backed by Redis / Memory cache.

Because OutputCache keys are generated from the HTTP Method, Host, Path, and Query String, a naive client refetch after a mutation (e.g. queryClient.invalidateQueries({ queryKey: ['projects'] })) calls GET /v1/api/projects. OutputCache intercepts this identical URI, returning the pre-mutation response cached earlier. The updated project status or approved milestone does not reflect in the UI until the 2-minute cache expires.

5.2 Client-Side Mutation Epoch Versioning

PFM solves this without modifying backend endpoints via Client-Side Mutation Versioning:

  1. Epoch Tracking (activeMutationEpoch): httpClient.ts tracks an active mutation timestamp initialized from sessionStorage or Date.now().
  2. Automatic Mutation Interceptor: Every successful mutating HTTP response (POST, PUT, PATCH, DELETE with HTTP 2xx) triggers bumpMutationEpoch().
  3. Query Parameter Injection: Outgoing GET requests to internal APIs automatically append _v: getMutationEpoch().
  4. Instant Invalidation: When a mutation succeeds, the epoch is bumped. Subsequent React Query refetches send the new _v, producing an immediate cache miss in ASP.NET Core OutputCache. The backend queries PostgreSQL, returns fresh data, and caches the new state under the new _v key.
  5. Cache Hit Snappiness: Navigations between pages while no mutations have occurred reuse the stable _v, yielding sub-5ms responses from Redis/Memory OutputCache and 0ms from React Query in-memory cache.
  6. Explicit Freshness (_t): When Cache-Control: no-cache is specified or during explicit refresh operations, a unique timestamp _t is attached to force a guaranteed fresh fetch.

5.3 RBAC Startup Pipeline Acceleration

Following the introduction of fine-grained RBAC and Maker-Checker checks, startup performance was streamlined to eliminate sequential network waterfalls:

  • Cache-First Role Claims: fetchAndCacheSystemRoles and fetchAndCacheRoleClaims check in-memory and localStorage caches before dispatching network calls.
  • Parallel User Resolution: getUserRolesFromApi and getUserClaimsFromApi are resolved concurrently using Promise.allSettled.
  • Parallel Claim Resolution: Claims across all assigned roles are retrieved concurrently rather than in serial loops.
  • Immediate Non-Blocking Session Render: If valid session tokens and user profiles exist in localStorage, AuthProvider immediately unblocks UI rendering (isLoading: false) and runs tenant lockout checks, account synchronization, and claim revalidation asynchronously in the background.
  • In-Memory User Sync & Lockout Caching: isUserSyncedInBrowser and verifyTenantLockout maintain in-memory references to eliminate synchronous JSON.parse operations from the request pipeline.