Role-Based Scanning
Omnalingo scans each URL multiple times, once per configured role, because WordPress pages can render different content depending on who is viewing. This document explains the internal mechanics: how authentication is handled for each scan type, how discovered strings are tagged with the scanning role, and why different UI triggers use different scan paths.
Access Control Layer
Section titled “Access Control Layer”AccessService
Section titled “AccessService”File: src/Service/AccessService.php
AccessService centralizes all role and capability logic. It defines three custom capabilities and one custom role:
| Constant | Value | Used for |
|---|---|---|
CAP_MANAGE_SETTINGS | omnalingo_manage_settings | API keys, language configuration |
CAP_EDIT_TRANSLATIONS | omnalingo_edit_translations | Translation editor, glossary |
CAP_VIEW_INACTIVE | omnalingo_view_inactive | Frontend preview of inactive languages |
ROLE_TRANSLATOR | omnalingo_translator | The custom Translator role |
The three can*() methods map these to WordPress primitives:
canManageSettings()— wrapscurrent_user_can('manage_options'). Only administrators.canEditTranslations()—CAP_EDIT_TRANSLATIONSORedit_others_posts. Covers administrators, editors, and theomnalingo_translatorrole.canViewInactiveLanguages()—CAP_VIEW_INACTIVEORedit_posts. Covers any role that can edit content.
getCurrentUserRole() returns 'admin', 'translator', or '' for the frontend UI; it does not map to WordPress role slugs.
AccessSubscriber
Section titled “AccessSubscriber”File: src/Subscriber/AccessSubscriber.php
Subscribes to two hooks:
admin_menuat priority 999 —restrictAdminMenus()removes Posts, Pages, Comments, Media, and Tools from the admin menu for users with only theomnalingo_translatorrole. Priority 999 ensures it runs after all other plugins register their menus.woocommerce_prevent_admin_access—allowTranslatorAdminAccess()returnsfalse(allow) for translators, preventing WooCommerce from redirecting them to the storefront “My Account” page.
ensureRoles() is called from Plugin::activate() (not from a hook). It is idempotent: it uses get_role() before calling add_role(), so running it multiple times on the same site does not create duplicate roles. It grants the three custom capabilities to the administrator role and creates the omnalingo_translator role with CAP_EDIT_TRANSLATIONS and CAP_VIEW_INACTIVE but no content-editing capabilities (edit_posts is false).
Scan Authentication
Section titled “Scan Authentication”File: src/Service/SiteRequestService.php
SiteRequestService makes HTTP requests from the plugin back to the same WordPress installation. All scan types ultimately call the private doGet() method, which uses wp_remote_get() with sslverify => false and a configurable timeout (content.scan.http_timeout_seconds, default 20).
There are two authentication paths depending on the role being scanned.
Visitor Scans: HMAC Token
Section titled “Visitor Scans: HMAC Token”fetchForScanAsVisitor() makes an unauthenticated request — no WordPress session cookies are passed. Instead it generates an HMAC token:
public static function generateScanToken(int $urlId, int $timestamp): string{ return hash_hmac('sha256', $urlId . '|' . $timestamp, wp_salt('nonce'));}The token binds the URL ID and the current Unix timestamp to the site’s nonce salt. It expires after 60 seconds (SCAN_TOKEN_TTL = 60). The query parameters added to the request are:
omnalingo_scan=1omnalingo_scan_token={hash}omnalingo_scan_ts={timestamp}url_id={urlId}omnalingo_scan_role=visitor
On the receiving end, ScannerSubscriber::validateScanRequest() calls SiteRequestService::validateScanToken(), which checks the TTL with abs(time() - $timestamp) > SCAN_TOKEN_TTL and then verifies the token with hash_equals() to prevent timing attacks.
Role Scans: Generated Auth Cookies
Section titled “Role Scans: Generated Auth Cookies”fetchForScanAsRole() locates a real WordPress user who has the target role (preferring the current user if they match, otherwise querying get_users(['role' => $role, 'number' => 1])). If no user with that role exists, the method throws an exception and the scan for that role is skipped.
Once a user is found, generateAuthCookiesForUser() creates a short-lived (120 second) auth cookie set using wp_generate_auth_cookie():
- The
AUTH_COOKIE(orSECURE_AUTH_COOKIEon SSL) using scheme'auth'(or'secure_auth') - The
LOGGED_IN_COOKIEusing scheme'logged_in'
These cookies are passed to wp_remote_get(), so the scan request is authenticated as that user. A per-user WordPress nonce (wp_create_nonce('omnalingo_scan')) is also generated — generateScanNonceForUser() temporarily switches the current user with wp_set_current_user(), creates the nonce, then restores the original user.
ScannerSubscriber::validateScanRequest() accepts either an HMAC token (visitor path) or a WordPress nonce (role path). If both are absent or invalid, the request is rejected and treated as a normal page load.
ScannerSubscriber: Role Detection and String Tagging
Section titled “ScannerSubscriber: Role Detection and String Tagging”File: src/Subscriber/ScannerSubscriber.php
When a scan request reaches template_redirect (priority 20), ScannerSubscriber::onTemplateRedirect() reads the role from $_GET['omnalingo_scan_role'] if it is present (set by SiteRequestService). If the parameter is absent — editor iframe or a direct page visit with a nonce — detectCurrentRole() derives the role from the currently logged-in WordPress user’s first role, or returns 'visitor' if not logged in.
The role string is validated: it must be 'visitor' or a real WordPress role slug per wp_roles()->is_role(). Invalid values fall back to 'visitor'.
The role is passed through the string discovery pipeline (StringDiscoveryService::discoverStrings()) and ultimately stored in the omnalingo_url_strings.role column for every new URL-string mapping created during this scan.
The junction table uses INSERT IGNORE for URL-string mappings, so the first scan role to discover a string for a given URL wins. A string found on the visitor scan has role='visitor'; a string only found during an administrator scan has role='administrator'.
TranslationSubscriber uses this column at translation-serving time to decide whether to swap in a translation for the current viewer. Admin bar strings (role='administrator') are only replaced for users who have admin bar access.
Scan Method Decision Table
Section titled “Scan Method Decision Table”Different UI triggers use different scan methods. The trade-off is always speed versus completeness (full multi-role coverage). The following is from DECISIONS.md:
| Scenario | Method | Roles covered |
|---|---|---|
| ”Scan” button in sidebar (scans all unscanned pages) | Browser-direct (?omnalingo_scan_list=1) | Admin only — eliminates one WP load per URL (~33% faster) |
| Scan icon on a single content row in the sitemap | REST POST /scan/url | All configured roles |
| ”Scan first” in TranslationWidget (unscanned pages) | Browser-direct with REST fallback | Admin initially; all roles on fallback |
| Pre-translate auto-scan before a translation batch | Server-side scanSingleUrl | All configured roles |
Opening a page in the string list editor (/strings/:id) | Server-side fetchForStringList | Current user’s role only |
| Visual editor iframe | Server-side fetchForStringList | User-selected role (dropdown in editor) |
| Action Scheduler cron job | Server-side processBatch | All configured roles |
The key distinction is between browser-direct scans and server-side scans. Browser-direct means the Vue frontend makes the scan request itself by navigating an iframe to the URL with ?omnalingo_scan_list=1. This saves one WP load compared to the REST path, where the controller has to make a second wp_remote_get() call. The trade-off is that browser-direct scans run as the logged-in admin only — they cannot impersonate other roles because cookie generation requires PHP.
Speed measurements from DECISIONS.md: REST path — 178.7s for 34 URLs (avg 8987ms per request); browser-direct — 121.5s for the same 34 URLs (avg 2262ms per request).
Inactive Language Handling
Section titled “Inactive Language Handling”When a user with canViewInactiveLanguages() access navigates to an inactive language URL, LocaleService::detectLanguage() stores the language data in $pendingInactiveLanguage rather than activating it immediately. This deferred check is needed because detectLanguage() runs on plugins_loaded (priority 1), before authentication context is fully established.
finalizeLanguageDetection() (called on init) then calls access->canViewInactiveLanguages(). If authorized, the language is promoted to active via setLanguage(). If not, the service reverts to the default language.
The pendingInactiveLanguage property is checked throughout LocaleService — including in getDetectedLanguageCode() and isCurrentLanguageRtl() — so features like RTL detection work correctly during this pending phase.
REST API Permission Check
Section titled “REST API Permission Check”All REST controllers that modify scan state use BaseController::permissionsCheck(), which calls current_user_can('manage_options'). This is a hard requirement — the Translator role (CAP_EDIT_TRANSLATIONS only) cannot trigger scans via the REST API.
The AccessService::canManageSettings() method is the semantic wrapper for this check. When tracing a 403 error on any /omnalingo/v1/scan/* or /omnalingo/v1/gather/* endpoint, verify that the requesting user has manage_options — not just omnalingo_edit_translations.