Security
Reference
Public
Research
Research evidence behind the ERPat Security user guide — the live module source files (manifest, config, controllers, models, migrations, views, language) read to verify every fact about all four tools, including the Device Management move in from core.
Guide version: r3
Module version: 1.5.0 Updated: 2026-08-28 Estimated time: 14 min 8 views 0% helpful
Administration & Reference
Research
This guide was written from the module's own source, not from assumptions. This
page lists the files that were read and the verified facts each one established — so a future
editor can re-check the guide against reality.
????
Documentation-only. Nothing on this page (or anywhere in modules/Security/docs/)
is served by a route or consumed by PHP — it exists to keep the guide honest and traceable.
Manifest & module metadata
| File | Established |
module.json | Version 3.0.0; type feature; the module owns four tools end-to-end; the six tables and their migrations; the permission list; user_guide: true (docs sync into the User Guide portal); config.auth_checks pointing at the sign-in-check sidecar; the "core protection layer stays in core" risk note; the risk note that the module's two models on the core hot path are wired differently — OffsiteAccessModel is still a direct core→module call by class name from IpRestrictionMiddleware, while Banned_devices_model::pre_auth_check() is declaration-driven through config/auth_checks.php; the risk note that the banned-device check is deliberately ungated by module_security; the corrected note that the six tables do not ship in the base-install schema; and the note that module_device_management was retired into module_security. |
config/module_config.php | module_key = security (matches slug); can_disable = false — the umbrella is locked on. |
Config sidecars
| File | Established |
config/menu.php | Four items in menu order — Device Management (perm device_management, fa-mobile), Offsite Access (offsite_access, fa-globe), Locked Accounts (locked_accounts, fa-shield), Access Logs (access_logs, fa-history). No per-item setting keys: the whole slice is gated on module_security and each leaf is then purely permission-gated — the legacy module_offsite_access / module_access (2.0.0) and module_device_management (3.0.0) sub-toggles are all retired. |
config/default_menu.php | The four items under a Security header (position 160), in that order. The module now owns the whole section — with Device Management moved in, the core slice in application/config/left_menu.php was emptied and removed, so there is no core group left to coalesce with. |
config/permissions.php | Every RBAC group and its exact category/action; Offsite Access is a dropdown (default_level: specific), Active Sessions / Access Logs / Device Management / Device Management Groups are module-level, Locked Accounts is a simple View, and Block/Unblock Device are simple Enabled permits. Keys unchanged from core (no data migration) — device_management is the one genuinely new key. |
config/routes.php | The flat URL → controller map, incl. the lowercase routes for the mixed-case/underscore controllers, and the unchanged device_management / device_management_groups URLs. |
config/system_logs.php | The 19 audit-log event keys and their severities — the original 14 plus the five Device Management events added in 3.0.0; note that offsite_access:ip_security stays core-owned (emitted by IP-restriction middleware). |
config/auth_checks.php | The module's sign-in check declaration — one entry, security.banned_device, targeting Banned_devices_model::pre_auth_check at stage pre_credential, priority 10, required: true. This is the file that replaced the hardcoded load->model('Banned_devices_model') branch in core: core's erpat_run_auth_checks() reads it and resolves the check, so core names no module on the auth path. Its header records why the entry carries no gate key — an enforcement deny-check must not be disableable. |
Controllers
| File | Established |
controllers/Device_management.php | Constructor gates module_security + device_management — the page-level gate that did not exist before; index() renders the two-tab page; list_data() returns an empty list until a person/team filter is posted, then reads Banned_devices_model::get_devices_with_ban_status(); the ban action requires block_device, the restore action unblock_device; both write audit rows via _log_device_action(). |
controllers/Device_management_groups.php | The Groups tab — an ajax-tab partial, so index() uses load->view(), not template->rander(); gated on module_security + device_management_groups with _create/_update/_delete on the actions; create/update/delete audited. |
controllers/OffsiteAccess.php | Constructor gates module_security + offsite_access (the retired module_offsite_access sub-toggle is gone — every controller in the module now passes the same security slug); save() creates one grant per selected user (multi-select) and writes created:offsite_access; edit/delete write updated/deleted; the list captures old values before update/delete. |
controllers/Locked_Accounts.php | Constructor allows locked_accounts OR core staff_support; clear_attempts() resets disable_login/login_attempts/last_login_attempts, notifies the user, logs unlocked:locked_account; the Active Sessions tab reads live sessions and logs logout:active_session / deleted:active_session. |
controllers/Access_logs.php | The tabbed page shell (index → access/index), the Logs tab (view/list_data), a log_action creator, and delete logging deleted:access_log. |
controllers/Access_devices.php | Auto-generates api_key (UUID) + hashed api_secret on create; generate_new_secret() rotates and logs the critical rotated_secret:access_device (secret never logged); passes = staff allowed through; create/update/delete audited. |
controllers/Access_device_categories.php | Category CRUD gated on access_logs; create/update/delete audited. |
Models & migrations
| File | Established |
models/OffsiteAccessModel.php | The grants model — also read by the core IP-restriction middleware to resolve active off-network exceptions. |
models/Banned_devices_model.php | The banned_devices table (one soft-deletable row per user + user agent). pre_auth_check() is the sign-in check itself — core does not call it by name; the module declares it in config/auth_checks.php and core's erpat_run_auth_checks() (invoked from application/models/Users_model::authenticate()) resolves and runs it on every sign-in attempt, so the model and method may only be renamed alongside that sidecar. It returns nothing to allow, or the routed code device_banned to deny; a missing banned_devices table is logged and denied rather than fataling. get_devices_with_ban_status() was absorbed from core System_logs_model::get_system_logs_data_per_device(); it takes the latest new_login log per (user, user agent) via a self-join anti-join and LEFT JOINs the ban row to derive the status. |
models/Device_management_groups_model.php | The device_management_groups table (uuid, title, user_agent, status) behind the Groups tab and the group filter. |
models/Access_devices_model.php, Access_device_categories_model.php, Access_logs_model.php | The Access Device Logs data layer (list/detail/save/delete). |
migrations/…_create_offsite_access_table.php | Columns for offsite_access (uuid, user_id, start/end date, audit block); guarded CREATE-IF-NOT-EXISTS. |
migrations/…_create_access_device_categories_table.php | Columns for access_device_categories (title, detail, status). |
migrations/…_create_access_devices_table.php | Columns for access_devices (api_key, api_secret, device_name, passes, remarks, category_id, labels, status). |
migrations/…_create_access_logs_table.php | Columns for access_logs (device_id, user_id, remarks, timestamp). |
migrations/20260730213155_create_banned_devices_table.php | Columns for banned_devices (user_id, http_agent, ip_address, remarks, who banned it); guarded CREATE-IF-NOT-EXISTS — this migration is the table's only source (it does not ship in the base-install schema), and the guard simply makes a re-run a no-op. |
migrations/20260730213156_create_device_management_groups_table.php | Columns for device_management_groups (uuid, title, user_agent, status); same idempotent guard. |
application/migrations/20260730213157_retire_device_management_setting.php | Core-side retirement: carries a tenant's module_device_management = 1 forward onto module_security, then drops the orphaned row so the toggle no longer appears in Manage Modules. |
Views & language
| File | Established |
views/device_management/index.php | The two-tab Device Management shell — Entries · Groups — and the Entries filter bar: the person/team picker the list needs before it shows anything, plus the Status (defaulting to Active) and Device Group filters. The Groups tab only renders for holders of device_management_groups. |
views/device_management/ban_device_modal_form.php, restore_device_modal_form.php | The ban dialog (Remarks is a required field) and the restore confirmation. |
views/device_management/groups/index.php, groups/modal_form.php | The Groups list and its Title / User Agent / Status form. |
views/access/index.php | The three-tab Access Device Logs shell — Logs · Devices · Categories (ajax-tab, lazy-loaded). |
views/locked_accounts/index.php | The two-tab shell — Browse (Locked Accounts) · Active Sessions. |
views/offsite_access/*, views/access/devices/*, views/active_sessions/* | The grant form, the device forms incl. the credential dialog, the passes list, and the session-viewer modal. |
language/english/security_lang.php | The feature strings — including the Device Management vocabulary moved in from core (ban_device, unban_device, unban) — and the four nav_desc_* hover descriptions; documents which vocabulary stays core-shared. |
Device Management — evidence for the move in from core (3.0.0)
Device Management was a built-in ERPat screen (application/controllers/Device_management.php
and friends) until 3.0.0, when it became this module's fourth tool. Because a migration is
exactly where a guide is most likely to repeat a stale claim, the files below were read
end-to-end and the findings recorded verbatim.
Files inspected
- Controllers —
controllers/Device_management.php, controllers/Device_management_groups.php
- Models —
models/Banned_devices_model.php, models/Device_management_groups_model.php, and the core sign-in path application/models/Users_model.php plus the registry it calls in application/helpers/module_compatibility_helper.php
- Views —
views/device_management/index.php, ban_device_modal_form.php, restore_device_modal_form.php, views/device_management/groups/index.php, groups/modal_form.php
- Config —
config/routes.php, config/menu.php, config/permissions.php, config/system_logs.php, config/auth_checks.php
- Migrations —
migrations/20260730213155_create_banned_devices_table.php, migrations/20260730213156_create_device_management_groups_table.php
What those files established
| Finding | Evidence |
The URLs are unchanged. Existing links, bookmarks and in-view get_uri() calls keep resolving. | config/routes.php declares device_management → Device_management/index and device_management_groups → Device_management_groups/index (plus their (:any) forms) — the same URIs the core version served, now owned by the module. |
The permission keys are unchanged, so serialized grants in users.permissions / roles.permissions keep resolving with no data migration. | config/permissions.php re-declares device_management_groups (+ _create/_update/_delete), block_device and unblock_device with the original category/action labels; the matching core rows were deleted from application/helpers/permission_catalog_helper.php in the same change (a core row shadows a module declaration, so leaving them would have kept this block inert). |
| The page previously had no permission gate. Any signed-in staff member could open it by typing the address; only the menu link was gated, on a key nothing declared — so it was ungrantable and effectively admin-only. | The comment block in config/permissions.php records that Left_menu already called current_has_permit('device_management') while no catalog row ever declared the key. device_management is newly declared, and controllers/Device_management.php now calls with_permission("device_management", "redirect") in its constructor — the gate that makes it real and grantable. |
| The ban check still runs pre-authentication, but it is now the MODULE's code, reached through a core hook. It rejects a person + browser pair at sign-in; it does not disable the account. | The module declares the check in config/auth_checks.php (security.banned_device → Banned_devices_model::pre_auth_check, stage pre_credential, required). Core Users_model::authenticate() names no module: it calls erpat_run_auth_checks(), which resolves the declaration and runs the check before password_verify() — the same point the old hardcoded load->model('Banned_devices_model') branch sat, so the behaviour is unchanged (matched against the raw HTTP_USER_AGENT header, refused without burning a login attempt). The model still resolves through module package paths and loads regardless of the module's enabled state, so the check never goes dark — which is why the model and method may only be renamed alongside the sidecar, and why every option in the model is escaped or cast. |
| The registry is fail-closed, and the check is deliberately ungated. The module's one enable switch does not apply here. | erpat_get_auth_checks() injects a synthetic always-deny check whenever discovery cannot be trusted, and erpat_run_auth_checks() denies when a required check is unresolvable, throws, or returns out of contract — a discovery problem can never reduce to "no checks". Neither the registry nor the entry carries a gate key: the header of config/auth_checks.php and the module's known_risks record why — gating an enforcement deny-check would make the Manage Modules toggle an authentication bypass, module_enabled()/get_setting() fail open on a missing settings row, and Signin.php swaps to the tenant database without reloading settings, so core cannot read the right tenant's value on that path anyway. |
| User-visible behaviour is unchanged. Nothing an operator or integrator sees moved. | erpat_auth_deny_codes() routes device_banned verbatim, so authenticate() returns the same literal sentinel; Signin::execute_auth_logic() still shows lang('access_from_this_device_is_restricted') — "Access from this device is restricted." — and still writes the banned_device_attempt activity-log row, and v1/api/auth/Login::index() still answers 403 device_banned. Both of those live in CORE because the core sign-in path is what emits them. An unrouted code is downgraded by erpat_auth_verdict_return_value() to a generic failure every caller already handles. |
| The list is derived, not stored. No "is banned" flag exists anywhere. | Banned_devices_model::get_devices_with_ban_status() (absorbed from core System_logs_model::get_system_logs_data_per_device()) takes the latest new_login row per user + user agent out of system_logs via a self-join anti-join, then LEFT JOINs banned_devices and computes status as banned / active from whether that join matched. |
| Banning and restoring are now audited (they were not before). | config/system_logs.php declares banned:banned_device (critical) and unbanned:banned_device (warning), plus created/updated/deleted:device_management_group; Device_management::_log_device_action() emits the first two. |
The module_device_management toggle is retired, so the tool no longer has its own Manage Modules row — visibility is purely a permission decision. | config/menu.php carries no setting key on the item; the manifest's risk notes list the retirement, and application/migrations/20260730213157_retire_device_management_setting.php carries the old value forward onto module_security before dropping the row. |
| The tables do NOT ship in the base-install schema — the module migrations are their only source. (An earlier draft of the manifest, README and this guide claimed otherwise; corrected.) | Nothing under install/ creates banned_devices or device_management_groups. Both …_create_banned_devices_table.php and …_create_device_management_groups_table.php are idempotent CREATE-IF-NOT-EXISTS, so a re-run is a no-op, but a database that has not run php erpat migrate:modules genuinely has no banned_devices table — which is why pre_auth_check() probes for it and denies rather than fataling on FALSE->row(), as the old core branch did with db_debug off. They bring the module's table count to six. |
⚠️
Two SQL-injection vulnerabilities were fixed in the migrated code. The serious one was
reachable before authentication: the core version interpolated the browser's
User-Agent header straight into the sign-in query. Banned_devices_model
now escapes or casts every option, and rebuilds the user-id list from integers. Keep it that way.
Scope note — what was verified NOT to be here
The guide's central claim — that this module is tooling, not the protection layer — was
confirmed by the manifest's own risk notes and the config comments: guards
(application/guards/), the middleware pipeline
(application/middleware/), the Roles/permissions RBAC editor, and the audit-log
store all remain in core. The traffic between the two runs the other way: core reads this
module's data, by two different routes. IpRestrictionMiddleware loads
OffsiteAccessModel directly by class name to resolve the offsite grants. The device
ban list is reached through a hook instead — core's Users_model::authenticate()
runs whatever checks modules have declared, and this module declares
Banned_devices_model::pre_auth_check() in config/auth_checks.php, so
core names no module on the authentication path. Either way the ownership is the same: this
module supplies the lists and the policy, contributes permissions, and writes audit rows — it
does not own or gate the core controls that consume them.
Next steps