The permission engine – function master, execution and display gates
The function catalogue, the discovery scan, the decision order of the execution and display gates, the assignment table and the engine's known limitations.
Overview
The engine runs on every admin request: the startup step registers the libraries and controllers call the dlbtpermission methods. Three layers: 1) base route permission (access, modify), 2) function permission, 3) content scope. Table names carry the installation's own prefix in the database.
Function master (feature)
The single function master: one row is a route + action_key pair. Main fields: module, name, access_type (UNMAPPED, BASE, SUBSCRIPTION, SYSTEM; default UNMAPPED), visibility_rule (auto, never, always), parent_feature_id, list_price, currency, uom, usage_mode, limit_*, billing_period, permission_present, gate_covered, cron_related, cron_job_type, status.
Where rows come from:
- Seed migrations, with an explicit classification.
- Projection from the old
permissioncatalogue after every migration (projectPermissionCatalogue(),INSERT IGNORE,UNMAPPED); old group assignments become group template rows with sourcerole. Nothing is copied back. - Discovery: the "scan" button of the Features master (
masterdata/feature::scan) callsDlbtfeature::scan().
The seeds contain 523 route::key pairs: BASE 45, SUBSCRIPTION 6, SYSTEM 44, UNMAPPED 78, and 350 more only in the old catalogue (UNMAPPED after projection); 122 keys checked in code are in no seed. Dlbtfeature::classify() loads the active master once per request; if zero rows are loaded, the gate falls back to the old path.
Discovery (Dlbtfeature::scan())
- Reads only the admin controller files; takes the first route definition per file.
- Collects
canAction(andcanActionForGroup(calls; with a route passed in a variable the file's own route is used; the key must be a literal. - Does not recognise
canSave(),canBulkAction(),canBulk(),canSee(),uiMode(),lockReason(),canActionForUser(),canSeeDeleted(),canStorageContext(). - Adds the active rows of the old catalogue (union) and builds a route → background job map from
Dlbtcronhealth::JOBS. - Existing rows only get the measured facts updated (
access_type, name and note are not overwritten); new rows areUNMAPPED, status 1. - Returns
added,updated,gap_permission,gap_gate; thenflush().
Execution gate (canAction)
- No signed-in user → deny;
user_group_id = 1→ allow. - With the master loaded: key not in the master → deny;
SYSTEM→ allow for every signed-in user. - Otherwise a row valid TODAY with
access_mode = 'usable'is needed inuser_feature(any source; both date ends count, NULL is open). Without one, the dependency rule decides. - With a row →
Dlbtentitlement::allows():UNMAPPEDandBASEallowed;SUBSCRIPTIONallowed except with overdue debt (only when the debt lock setting is on, with grace days; based on the overdue open invoices of the user's client). - With an empty or unreadable master the old group-level catalogue decides, then
allows()may narrow it.
Dependency opening (dependencyAllows): only for read keys (list, autocomplete, search, select, view, read), if the user has a usable row for another function whose system-type dependency in feature_dependency points here. A commercial dependency opens nothing. Example: recording an invoice opens the partner and company group lists and the invoice block and bank account lists.
Display gate and screen mode
canSee: super admin, empty master, key not in the master,visibility_rule = alwaysandSYSTEM→ visible;never→ hidden. Otherwise a row valid today (visible or usable) is needed;listis also visible if the user has a row for any function of the route. For a non-listkey the route's list and the menu group'smenukey must be visible too (Permission::MENU_GROUPS).uiMode:canAction→ok; elsecanSee→upsell; elsenone.lockReason: a non-SYSTEMfunction in the master without a usable row →subscription; debt →debt. The startupupsellGuard()redirects full page loads to the offer page on this basis.canSave($route, $id): id > 0 →edit; id = 0 →addif it exists in the master, elseedit.canBulk,canBulkAction: thebulk-prefixed key decides if it exists.canSeeDeleted: super admin ordelete.getAllowedScopeValues: content rules (the user's own rule overrides the group's; only allow values).
Assignment (user_feature)
The single assignment table: user_id (or user_group_id for a group template), feature_id, date_from, date_to, source (role, subscription, order, manual), source_ref, origin, access_mode (visible, usable), offer_definition_id, is_override, limit and pricing fields, definition_id, plan_id. A group row (user_id = 0, source = 'role') is a template the gate does not read; syncGroupRoleRows() and fillUserRoleFromGroup() expand it into per-user rows. Writers: Dlbtentitlement::grant() and grantStructure(), Dlbtprovision::materialise(), the function permission model's saveUserFeatureRows() and addMeteredOrder(), Dlbtpayment::syncGrants(), Dlbtsignup::creditOnly(), the subscription model's materialiseLimits().
Known limitations
- The
sourcecolumn does not count in the gate:PAID_SOURCESandhasPaidFeature()are not called anywhere. - Neither
canSeenorcanActionuses theparent_feature_idhierarchy. MENU_GROUPSdoes not know, for example, the routes of the order catalogue, orders, add-on packages, package families, sign-up, test bootstrap and checkout; for those the menu group condition is skipped.isVisibleGrant(), which reads the oldvisiblecolumn, is never called.