Skip to main content

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

PropertyTypeContent
req.appProfileObjectFull app_object_profiles row with JSON fields parsed
req.appRegistryObjectParent app_object_registries row with JSON fields parsed
req.profileMetadataObject{ 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

MethodScopeTriggered by
ProfileLoader.invalidateCache(entityCode, tenantId)One tenant × entityExplicit call
ProfileLoader.invalidateEntityCache(entityCode)All tenants for entityExplicit call; uses SCAN
CacheService.onEntityMutated("object_profiles")Full profiles reloadAny 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.

OptionTypeDefaultDescription
cacheEnabledbooleantrueUse Redis cache
cacheTTLnumber3600Cache TTL in seconds

ProfileLoader.loadProfile(options)

Direct profile load — bypasses the Express request context. Useful for background jobs or service-to-service calls.

OptionTypeRequiredDescription
entityCodestringEntity code to look up
tenantIdstringUsed for cache scoping
cacheEnabledbooleanDefault true
cacheTTLnumberDefault 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:

ColumnValue
prf_object_codeMust match the registry's obj_code exactly
prf_codeUnique identifier; convention is to match prf_object_code for base profiles
prf_status1 (active)
prf_is_deletedfalse / 0
prf_policyJSON — 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:

  1. Make one the canonical base — set prf_id ordering intentionally (or use prf_status = 0 to disable unwanted rows).
  2. Use tenant-specific profiles — set prf_tenant_id on the override row. The query filters by prf_object_code without a tenant clause, so multi-tenant profile differentiation requires application-level routing, not the current query.

Future enhancement: add a prf_tenant_id filter to the profile query so that a tenant-scoped row takes priority over a base row for the same entity.


Integration points

SystemIntegration
Entity routesProfileLoader.middleware() inserted at position 3 in every CRUD chain
Redis startupcacheObjectProfiles() pre-warms all profiles at boot
Cache serviceCacheService.onEntityMutated("object_profiles") reloads catalog on any profile mutation
Cache management APIGET /api/cache/catalog shows which profile keys are live; POST /api/cache/reload forces a refresh
Verify-token middlewareMust run before ProfileLoader; sets req.user which provides tenantId for cache scoping

Error responses

ConditionHTTPBody
entity_code absent from URL400{ 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 found404{ message: "Profile not found for entity: {entityCode}", entityCode }
No profile row found404same as above
Unexpected exception500{ 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

ItemImpactSeverity
No tenant-scoped profile selection in SQLTenant-specific profiles require manual managementLow
Bulk load uses N separate queriesSlower for 10+ entities simultaneouslyLow
No hit/miss metricsCannot measure cache effectiveness in productionLow

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...] }.