Skip to content

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.


File: src/Service/AccessService.php

AccessService centralizes all role and capability logic. It defines three custom capabilities and one custom role:

ConstantValueUsed for
CAP_MANAGE_SETTINGSomnalingo_manage_settingsAPI keys, language configuration
CAP_EDIT_TRANSLATIONSomnalingo_edit_translationsTranslation editor, glossary
CAP_VIEW_INACTIVEomnalingo_view_inactiveFrontend preview of inactive languages
ROLE_TRANSLATORomnalingo_translatorThe custom Translator role

The three can*() methods map these to WordPress primitives:

  • canManageSettings() — wraps current_user_can('manage_options'). Only administrators.
  • canEditTranslations()CAP_EDIT_TRANSLATIONS OR edit_others_posts. Covers administrators, editors, and the omnalingo_translator role.
  • canViewInactiveLanguages()CAP_VIEW_INACTIVE OR edit_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.

File: src/Subscriber/AccessSubscriber.php

Subscribes to two hooks:

  • admin_menu at priority 999 — restrictAdminMenus() removes Posts, Pages, Comments, Media, and Tools from the admin menu for users with only the omnalingo_translator role. Priority 999 ensures it runs after all other plugins register their menus.
  • woocommerce_prevent_admin_accessallowTranslatorAdminAccess() returns false (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).


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.

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=1
  • omnalingo_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.

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 (or SECURE_AUTH_COOKIE on SSL) using scheme 'auth' (or 'secure_auth')
  • The LOGGED_IN_COOKIE using 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.


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:

ScenarioMethodRoles 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 sitemapREST POST /scan/urlAll configured roles
”Scan first” in TranslationWidget (unscanned pages)Browser-direct with REST fallbackAdmin initially; all roles on fallback
Pre-translate auto-scan before a translation batchServer-side scanSingleUrlAll configured roles
Opening a page in the string list editor (/strings/:id)Server-side fetchForStringListCurrent user’s role only
Visual editor iframeServer-side fetchForStringListUser-selected role (dropdown in editor)
Action Scheduler cron jobServer-side processBatchAll 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).


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.


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.