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:

  1. Seed migrations, with an explicit classification.
  2. Projection from the old permission catalogue after every migration (projectPermissionCatalogue(), INSERT IGNORE, UNMAPPED); old group assignments become group template rows with source role. Nothing is copied back.
  3. Discovery: the "scan" button of the Features master (masterdata/feature::scan) calls Dlbtfeature::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())

  1. Reads only the admin controller files; takes the first route definition per file.
  2. Collects canAction( and canActionForGroup( calls; with a route passed in a variable the file's own route is used; the key must be a literal.
  3. Does not recognise canSave(), canBulkAction(), canBulk(), canSee(), uiMode(), lockReason(), canActionForUser(), canSeeDeleted(), canStorageContext().
  4. Adds the active rows of the old catalogue (union) and builds a route → background job map from Dlbtcronhealth::JOBS.
  5. Existing rows only get the measured facts updated (access_type, name and note are not overwritten); new rows are UNMAPPED, status 1.
  6. Returns added, updated, gap_permission, gap_gate; then flush().

Execution gate (canAction)

  1. No signed-in user → deny; user_group_id = 1 → allow.
  2. With the master loaded: key not in the master → deny; SYSTEM → allow for every signed-in user.
  3. Otherwise a row valid TODAY with access_mode = 'usable' is needed in user_feature (any source; both date ends count, NULL is open). Without one, the dependency rule decides.
  4. With a row → Dlbtentitlement::allows(): UNMAPPED and BASE allowed; SUBSCRIPTION allowed 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).
  5. 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 = always and SYSTEM → visible; never → hidden. Otherwise a row valid today (visible or usable) is needed; list is also visible if the user has a row for any function of the route. For a non-list key the route's list and the menu group's menu key must be visible too (Permission::MENU_GROUPS).
  • uiMode: canAction → ok; else canSee → upsell; else none.
  • lockReason: a non-SYSTEM function in the master without a usable row → subscription; debt → debt. The startup upsellGuard() redirects full page loads to the offer page on this basis.
  • canSave($route, $id): id > 0 → edit; id = 0 → add if it exists in the master, else edit. canBulk, canBulkAction: the bulk-prefixed key decides if it exists. canSeeDeleted: super admin or delete. 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 source column does not count in the gate: PAID_SOURCES and hasPaidFeature() are not called anywhere.
  • Neither canSee nor canAction uses the parent_feature_id hierarchy.
  • MENU_GROUPS does 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 old visible column, is never called.