Profile Loader
Module: axiom-genesis-middleware/utils/profile-loader.util.js
Status: Production-ready
Roadmap item: M4 (resolveRegistryAndProfile) — complete
Purpose
The Profile Loader is Express middleware that resolves an app_object_profiles row for the entity being accessed and attaches it to the request before any controller runs. Every entity route depends on this: the attached profile carries the prf_policy (permissions, field visibility, audit rules) and prf_payload (entity-specific configuration) that the rest of the request stack consumes.
It is the bridge between the object registry (what the entity is) and the access policy (what a caller may do with it).
Request chain position
verifyToken.validate_token
→ resolveEntityInfo
→ ProfileLoader.middleware() ← attaches req.appProfile + req.appRegistry
→ MaintainedByGuard.middleware()
→ authorizeEntityAccess()
→ controller
Source: routes/v1/entity.routes.js lines 158–179.
What gets attached to the request
| Property | Type | Content |
|---|---|---|
req.appProfile | Object | Full app_object_profiles row with JSON fields parsed |
req.appRegistry | Object | Parent app_object_registries row with JSON fields parsed |
req.profileMetadata | Object | { source, cached, loadedAt, entityCode, tenantId } |
source is either "redis" (cache hit) or "database" (cache miss). cached is a boolean matching that.
Resolution algorithm
Overview
ProfileLoader.middleware()
│
├─ cacheEnabled && tenantId?
│ └─ YES: GET {prefix}:prf:{tenantId}:{entityCode}
│ ├─ HIT → return, _source = "redis"
│ └─ MISS → continue
│
├─ _loadFromDatabase(entityCode)
│ ├─ Step 1: query app_object_registries WHERE obj_code = entityCode
│ │ (active + not soft-deleted)
│ │ → NULL means entity unknown → return null → 404
│ │
│ └─ Step 2: query app_object_profiles WHERE prf_object_code = entityCode
│ ORDER BY prf_id ASC
│ LIMIT 1
│ → NULL means no profile configured → return null → 404
│
└─ _saveToCache(entityCode, tenantId, profile, ttl)
key: {prefix}:prf:{tenantId}:{entityCode}
TTL: 3600s (default, configurable)
Profile selection strategy
A single active profile is returned — the one with the lowest prf_id for the entity. There is no role-based profile selection. Role-level access control is encoded inside prf_policy, not by choosing a different profile row. This keeps the lookup deterministic: one entity = one profile.
To vary behaviour by role, use the prf_policy.permissions object within a single profile. To vary it by tenant, create a tenant-scoped profile row (prf_tenant_id set to the specific tenant).
No-profile behaviour
If no active profile row exists for an entity (prf_object_code yields 0 rows), the middleware returns 404 { success: false, message: "Profile not found for entity: {entityCode}" }. The controller is never called.
To allow an entity to be accessed, a row in app_object_profiles with prf_object_code matching the registry's obj_code must exist and be active (prf_status = 1, prf_is_deleted = false).
Caching
Key format
{REDIS_KEY_PREFIX}:prf:{tenantId}:{entityCode}
Example: genesisone:prf:7:load_role_based_menus
Cache conditions
The cache tier is used only when both cacheEnabled (default true) and tenantId are present. If either is absent, the database is queried directly on every request.
Startup pre-warming
At application startup, cacheObjectProfiles() (called from loadBackgroundResources) loads all active profiles into Redis at:
{prefix}:profile:{PRF_CODE}
This is a separate key namespace from the per-request cache above. The per-request cache (prf:{tenantId}:{entityCode}) is populated lazily on first miss and persists for 1 hour. The startup catalog keys (profile:{PRF_CODE}) persist indefinitely until the next restart or a POST /api/cache/reload.
Cache invalidation
| Method | Scope | Triggered by |
|---|---|---|
ProfileLoader.invalidateCache(entityCode, tenantId) | One tenant × entity | Explicit call |
ProfileLoader.invalidateEntityCache(entityCode) | All tenants for entity | Explicit call; uses SCAN |
CacheService.onEntityMutated("object_profiles") | Full profiles reload | Any write/delete to app_object_profiles via entity routes |
The last row is automatic — realtimeSync.emitCollectionEvent calls CacheService.onEntityMutated after every successful mutation, so the catalog is always consistent with the database.
Database queries
Both queries are parameterized and dialect-aware (PostgreSQL ILIKE / MSSQL LIKE syntax; boolean vs integer for is_deleted).
Registry query
SELECT obj_id, obj_code, obj_description, obj_type_code,
obj_connection_code, obj_payload, obj_status,
obj_is_deleted, obj_audit_data
FROM app_object_registries
WHERE obj_code = @objCode
AND (obj_is_deleted = FALSE OR obj_is_deleted IS NULL) -- PostgreSQL
AND COALESCE(obj_status, 1) = 1
Profile query
SELECT prf_id, prf_object_code, prf_code, prf_description,
prf_mode_code, prf_status, prf_is_deleted,
prf_policy, prf_payload, prf_audit_data, prf_add_dtls
FROM app_object_profiles
WHERE prf_object_code = @objCode
AND (prf_is_deleted = FALSE OR prf_is_deleted IS NULL)
AND COALESCE(prf_status, 1) = 1
ORDER BY prf_id ASC
LIMIT 1 -- PostgreSQL; SELECT TOP 1 ... ORDER BY prf_id on MSSQL
Public API
ProfileLoader.middleware(options?)
Express middleware factory. Returns a middleware function.
| Option | Type | Default | Description |
|---|---|---|---|
cacheEnabled | boolean | true | Use Redis cache |
cacheTTL | number | 3600 | Cache TTL in seconds |
ProfileLoader.loadProfile(options)
Direct profile load — bypasses the Express request context. Useful for background jobs or service-to-service calls.
| Option | Type | Required | Description |
|---|---|---|---|
entityCode | string | ✅ | Entity code to look up |
tenantId | string | — | Used for cache scoping |
cacheEnabled | boolean | — | Default true |
cacheTTL | number | — | Default 3600 |
Returns the enriched profile object or null if not found.
ProfileLoader.loadBulk(tenantId, entityCodes[])
Load multiple profiles in parallel. Returns { [entityCode]: profile }. Failures on individual entities are logged and skipped; the map is still returned for the successful ones.
ProfileLoader.invalidateCache(entityCode, tenantId)
Delete the cache entry for one entity within one tenant.
ProfileLoader.invalidateEntityCache(entityCode)
Delete cache entries for one entity across all tenants (uses Redis SCAN).
Configuring a profile
Every entity accessed through /api/entity/:entity_code/* must have a corresponding row in app_object_profiles. Minimum required fields:
| Column | Value |
|---|---|
prf_object_code | Must match the registry's obj_code exactly |
prf_code | Unique identifier; convention is to match prf_object_code for base profiles |
prf_status | 1 (active) |
prf_is_deleted | false / 0 |
prf_policy | JSON — see below |
prf_policy structure
{
"tenantPolicy": { "mode": "STRICT" },
"projection": {},
"queryPolicy": {
"allowAdhocFilters": true,
"maxPageSize": 500,
"defaultPageSize": 100
},
"permissions": {
"read": true,
"write": true,
"delete": false,
"export": false,
"approve": false
},
"audit": {
"enabled": true,
"logCreations": true,
"logUpdates": true,
"logDeletions": true
}
}
tenantPolicy.mode controls tenant isolation enforcement:
"STRICT"— tenant field is always injected into queries; cross-tenant reads are blocked"OPTIONAL"— tenant field applied when present; useful for shared/global entities"PERMISSIVE"— no tenant filtering applied
Multiple profiles for the same entity
When more than one profile row exists for the same prf_object_code, the one with the lowest prf_id is used (i.e., the first one inserted). To use a different profile:
- Make one the canonical base — set
prf_idordering intentionally (or useprf_status = 0to disable unwanted rows). - Use tenant-specific profiles — set
prf_tenant_idon the override row. The query filters byprf_object_codewithout a tenant clause, so multi-tenant profile differentiation requires application-level routing, not the current query.
Future enhancement: add a
prf_tenant_idfilter to the profile query so that a tenant-scoped row takes priority over a base row for the same entity.
Integration points
| System | Integration |
|---|---|
| Entity routes | ProfileLoader.middleware() inserted at position 3 in every CRUD chain |
| Redis startup | cacheObjectProfiles() pre-warms all profiles at boot |
| Cache service | CacheService.onEntityMutated("object_profiles") reloads catalog on any profile mutation |
| Cache management API | GET /api/cache/catalog shows which profile keys are live; POST /api/cache/reload forces a refresh |
| Verify-token middleware | Must run before ProfileLoader; sets req.user which provides tenantId for cache scoping |
Error responses
| Condition | HTTP | Body |
|---|---|---|
entity_code absent from URL | 400 | { message: "Entity code required in URL path (/:entity_code/...)" } |
req.user absent (no auth) | 401 | { message: "User context required. Ensure verify-token middleware runs first" } |
| No registry row found | 404 | { message: "Profile not found for entity: {entityCode}", entityCode } |
| No profile row found | 404 | same as above |
| Unexpected exception | 500 | { message: "Error loading access profile", error: "..." } |
Cache failures (Redis unavailable or key parse errors) are logged as warnings and fall through to the database — they never surface as HTTP errors.
Known limitations
| Item | Impact | Severity |
|---|---|---|
| No tenant-scoped profile selection in SQL | Tenant-specific profiles require manual management | Low |
| Bulk load uses N separate queries | Slower for 10+ entities simultaneously | Low |
| No hit/miss metrics | Cannot measure cache effectiveness in production | Low |
These are tracked in the roadmap as P1 enhancements. None affect production correctness.
Verification
# Confirm profile resolves and data returns
curl -X GET "http://localhost:8008/api/entity/load_role_based_menus/get?ten_id=7" \
-H "Authorization: Bearer <token>" \
-H "x-tenant-id: <encrypted>"
# Expected: HTTP 200, success: true, data: [...]
# Check which profiles are currently in cache
curl -X GET http://localhost:8008/api/cache/catalog \
-H "Authorization: Bearer <token>"
# → data.profiles.keys lists every cached prf_code
Confirmed working: 2026-06-04. Response: { "success": true, "data": [...navigation items...] }.