Guide

Drupal administrator role granted — detecting privilege escalation with per-user suppression

A user account that yesterday could only edit their own articles can now install modules, drop the users table from /admin/config/development/devel/php, and revoke every other admin. Somebody granted them administrator.

1. Problem

A user account that yesterday could only edit their own articles can now install modules, drop the users table from /admin/config/development/devel/php, and revoke every other admin. Somebody granted them administrator. You want to know who, when, and from which IP — and you want to know about it the next time it happens, automatically. This is the textbook "drupal administrator role assigned how to detect" scenario, and it surfaces as a user.role_changed signal the moment Drupal's User entity save runs.

The problem isn't that Drupal lacks an audit trail — dblog records the role change in watchdog. The problem is that nobody reads those rows in real time. By the time an admin reviews /admin/reports/dblog once a week, the attacker has been root for six days. Or worse: a legitimate admin handed out administrator instead of editor by misclicking at /admin/people/edit/142, and the user has been quietly poking at /admin/config/system/site-information for a sprint.

You need to know within 60 seconds. You need exactly one alert per privilege escalation, not ten. And — for SOC 2, HIPAA, or FedRAMP audits — you need one auditable record per event, with the actor, the target, the timestamp, the source IP, and a clean correlation back to the login session that performed the grant. This guide is about the rule that produces that record.

2. Impact

A silently granted administrator role is the worst-case privilege escalation in Drupal. The role bypasses every permission check (User::hasPermission() short-circuits to TRUE), which means the holder can install arbitrary modules, edit any node, read every private file, dump the database via drush sql:dump, and rotate every other user's password.

For a Drupal Commerce store with PCI-DSS scope, an undetected admin grant is a reportable incident — every order, every billing address, every refund record was accessible by an account whose access was never reviewed. For a HIPAA-covered healthcare site, the same grant exposed every PHI field on every patient profile node. For a SOC 2 Type II audit, "we cannot show evidence that privilege escalations are detected within $X minutes" is a control failure that costs the audit cycle.

Compliance auditors don't want a 50-line dblog query result. They want exactly one record per event, with: timestamp, actor uid, target uid, role granted, source IP, session id, and correlation to a login event. The volume is the giveaway — a properly designed rule produces one alert per role grant. A naive rule produces ten alerts for one click; auditors get noise instead of evidence, and on-call learns to mute the channel. The cost of a bad rule isn't the alerts you get; it's the alerts you stop reading.

There's an operational cost too. Without per-user suppression, a single role-edit submission can generate three to ten user.role_changed events depending on cache rebuilds and contrib re-saves. Without filtering on roles_added, every "subscriber → editor" change pages on-call at 02:00 UTC during the nightly user-import cron. The rule design is the guide.

3. Why It’s Hard to Spot

Drupal's role-grant flow is uniquely noisy and uniquely silent. The user.role_changed event fires whenever the roles field diverges from its loaded original — on legitimate edits, but also on cache warming and contrib re-saves (workbench_access, domain_access, group re-save users to recompute access grants), bulk imports (migrate_plus, feeds), and custom entity_form_alter re-saves — producing duplicate events for one click.

Standard dblog monitoring drowns in this. The vast majority of rows are non-administrative role additions and removals, neither of which is a security event. Filtering manually means writing a SQL query that knows what "administrator" means in your install — and that name is configurable. Some sites use administrator, some use super_admin, some have multiple machine names. A naive grep for admin matches forum_administrator and comment_admin (real role names contrib modules ship).

The result is a silent failure mode: real privilege escalation events are buried under thousands of legitimate role changes, and no human is going to manually sort them. /admin/reports/dblog shows them with the same severity as a password change. Uptime monitors don't look at user roles. The grant happens, the page loads green, and the only place the truth lives is in PHP's request cycle for one eyeblink before it's replaced by 200 OK.

4. Cause

Drupal's User entity stores roles in the user__roles table (one row per uid+role pair). On every User::save(), Drupal loads the original entity, diffs $entity->getRoles() against $original->getRoles(), and produces three sets: roles_added, roles_removed, roles_unchanged.

The Logystera Drupal agent registers a hook_ENTITY_TYPE_update('user', ...) listener that fires after save, computes the same diff, and emits a user.role_changed signal:

event_type: user.role_changed
payload:
  uid: 142
  actor_uid: 1
  roles_added: ["administrator"]
  roles_removed: []
  source_ip: 203.0.113.45
  session_id: "abc123..."

The signal fires on every role change — the right primitive, the wrong granularity for alerting. The intelligence has to live in the rule.

A naive rule that triggers on event_type == user.role_changed alone alerts on every editor → contributor change, every onboarding grant, every migration run. It would page on-call thousands of times a week and they'd disable it within a sprint. The rule has to filter for administrator specifically, and suppress duplicates per-user so one click produces one alert. That's the design problem this guide solves.

5. Solution

5.1 Diagnose (logs first)

If the alert hasn't fired yet — or if you suspect it's misfiring — the diagnostic path runs through three log sources.

1. Drupal dblog — confirms the role change happened and surfaces the actor. Each row is a candidate user.role_changed event; the variables column contains the actor uid, target uid, and role machine names that the Logystera agent reads to construct the signal payload.

drush -r /var/www/drupal/web sql:query "
  SELECT wid, timestamp, uid, message, variables, hostname FROM watchdog
  WHERE type = 'user' AND message LIKE '%roles%'
    AND timestamp > UNIX_TIMESTAMP() - 86400
  ORDER BY timestamp DESC LIMIT 50;"

2. Logystera agent log — confirms the signal fired and the rule evaluated.

tail -n 1000 /var/log/drupal-agent/signals.log | \
  grep -E '"event_type":"user\.role_changed"' | \
  jq 'select(.payload.roles_added | tostring | test("administrator"))'

If you see the JSON event but no alert arrived, the rule didn't match — typically because the role machine name in your install isn't literally administrator (it might be super_admin or site_admin). The condition regex needs to match roles_added.

3. Time-correlate with the most recent change window.

Real privilege escalations almost never happen in isolation. They cluster around a specific event: a successful login from a new IP for the actor, a session opened during off-hours, a deploy that touched user.role.administrator.yml config, or an SSO group-sync mapping a new external group to administrator.

# Did the actor uid log in from an unusual IP in the same window?
drush sql:query "
  SELECT wid, timestamp, message, hostname FROM watchdog
  WHERE uid = 1 AND type IN ('user', 'access')
    AND timestamp BETWEEN UNIX_TIMESTAMP() - 3600 AND UNIX_TIMESTAMP()
  ORDER BY timestamp DESC;"

# Was config sync touched recently?
git -C /var/www/drupal log --since "1 day ago" -- config/sync/user.role.*

If the user.role_changed signal lines up with a drupal_login_success_total increment from a new IP for actor_uid, you have the story: someone logged in as uid 1 from 203.0.113.45 at 14:02 UTC, opened /admin/people/edit/142 at 14:03 UTC, and granted administrator at 14:03:04 UTC. That correlation turns "uid 142 is now an admin" into "uid 1 logged in from a new IP and immediately escalated uid 142."

5.2 Root Causes

Each path that produces a user.role_changed with roles_added: ["administrator"] has different security implications, and the rule output should be enough to disambiguate them.

  • Legitimate admin grant via /admin/people/edit/N — one user.role_changed event (after suppression), actor_uid is a real admin, source_ip is a known IP. Auditable record. Maps to drupal_user_role_changed_total{role="administrator", direction="added"}.
  • Compromised admin account grants — same event signature, but source_ip is unrecognized for actor_uid and it correlates with a recent drupal_login_success_total from that same IP. The rule fires once; the response is incident response.
  • Misclick onboarding grant — admin meant editor, clicked administrator. Same signal, same payload. The auditable record is the difference between catching it in 60 seconds and finding it in next quarter's access review.
  • SSO / LDAP group-sync mapping — if simplesamlphp_auth or ldap_user maps an external group to administrator, every user in that group gets the role at next login. Without per-uid suppression, this is one alert per user; with it, you get one per escalated account. actor_uid is typically 0 (SSO sync runs in an auth context). Maps to drupal_role_grants_total{actor_uid="0"}.
  • Config-sync deploydrush config:import adds administrator permissions to an existing role. This doesn't fire user.role_changed (role permission set changed, not user membership), so it needs a separate signal (config.role_permissions_changed).
  • Direct DB writeINSERT INTO user__roles VALUES (142, 'administrator') bypasses Drupal's entity API and is invisible to this rule. Only DB-layer audit logs catch it. Worth documenting in the runbook.

5.3 Fix

The fix is the rule. Both conditions are load-bearing, and the suppression block is non-negotiable:

key: rule_drupal_admin_role_granted
name: drupal_admin_role_granted
type: rule
metric_type: alert
conditions:
  - field: event_type
    op: eq
    value: user.role_changed
  - field: payload.roles_added
    op: regex
    value: '\b(administrator|super_admin|site_administrator)\b'
threshold:
  count: 1
  window: 60s
suppress:
  by: payload.uid
  for: 60s
alert:
  severity: critical
  title: "Privilege escalation: administrator role granted to uid {{payload.uid}}"
  body: |
    Actor: uid {{payload.actor_uid}} from {{payload.source_ip}}
    Target: uid {{payload.uid}}
    Role granted: {{payload.roles_added | join: ", "}}
    Time: {{timestamp}}
    Session: {{payload.session_id}}
labels:
  cluster: "{{ env.CLUSTER }}"
  environment: "{{ env.ENV }}"
  entity_id: "{{ entity.id }}"

Why both conditions? Drop the second condition and the rule fires on every role change in the system — every editor → contributor swap, every cron-driven import, every contrib re-save. On a medium-sized install you'd page on-call hundreds of times per day. The regex matches administrator as a whole word so it doesn't trip on forum_administrator or comment_admin_role, and it covers the three machine-name variants in real installs.

Why per-uid suppression? Drupal's entity save lifecycle can emit user.role_changed two to ten times for one click. workbench_access re-saves the user during access-grant computation. domain_access does the same. Cache warming after a config change re-saves users. Without suppress.by: payload.uid for: 60s, one grant generates three to ten alerts in the same 200ms window. With it, you get exactly one alert per uid per minute — which is what compliance audits expect: one auditable record per privilege escalation.

Why 60 seconds? Long enough to suppress entity-lifecycle duplicates, short enough that a second legitimate grant to the same uid still produces a second alert. Bump to 300s if your install has unusually noisy entity save chains.

After publishing the rule, test it:

drush user:role:add administrator test_user_account
# Within 60s, the alert should arrive.
drush user:role:remove administrator test_user_account
# (revoke does not fire — roles_added is empty.)

5.4 Verify

You're looking for two things to hold: the rule fires exactly once per administrator grant, and drupal_user_role_changed_total{role="administrator"} increments by 1.

# After granting admin to one test uid, verify exactly one alert was sent:
curl -s "https://api.logystera.com/v1/alerts?rule=rule_drupal_admin_role_granted&since=5m" \
  | jq '. | length'
# Expected: 1

# Confirm the supporting metric incremented:
curl -s "https://vicky.logystera.internal:8428/api/v1/query?query=drupal_user_role_changed_total{role=\"administrator\",direction=\"added\"}" \
  | jq '.data.result[0].value[1]'

Healthy state for a typical Drupal install: zero rule_drupal_admin_role_granted firings per week (most installs grant admin at most once a quarter), drupal_user_role_changed_total ticking gently with non-administrator roles, and drupal_role_grants_total showing the expected onboarding cadence (5–20/day for a site doing active editor onboarding). The administrator-row of that metric should be near-flat.

The baseline matters: a healthy production site emits roughly 0 administrator grants per week. Unlike drupal_php_error_total, this signal has no expected baseline noise — any non-zero rate is a real event that needs review. If the rule fires once and you can map it to a known admin action, you're good. If it fires and nobody on the team takes credit, that's incident response.

If you see two alerts for one grant, your suppression isn't working — check that suppress.by: payload.uid made it into the published DSL (the dsl_yaml field is the source of truth). If you see zero alerts after a deliberate test grant, your roles_added regex doesn't match the role machine name in your install — check /admin/people/roles for the exact string.

6. How to Catch This Early

Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.

This issue surfaces as user.role_changed.

Everything you just did manually — query watchdog for role changes, filter for the administrator machine name, correlate with the actor's recent login, dedupe entity-save duplicates by uid — Logystera does automatically. The Drupal agent emits user.role_changed the moment Drupal's entity update hook runs, and the rule above evaluates it in a single pass: condition match, threshold check, per-uid suppression, alert emit. The entire §5.1 diagnostic flow is the rule.

!Logystera dashboard — user.role_changed over time user.role_changed rate, last 24h — flat baseline with one administrator-grant spike at 14:03 UTC, immediately after a successful login from a new IP for actor uid 1.

The rule that fires is id 511 — drupal_admin_role_granted, severity critical, threshold 1 event in 60s, suppression 60s by payload.uid. The two conditions and the suppression key turn a noisy event stream into one auditable record per privilege escalation — which is exactly what SOC 2, HIPAA, and FedRAMP control reviews expect.

!Logystera alert — Drupal administrator role granted Critical alert fires within 60s of the first administrator grant, including actor uid, target uid, source IP, and session id.

The alert payload includes the timestamp, actor uid, target uid, role granted, source IP, and session id — enough to satisfy an access-control review without anyone opening Drupal. Per-uid suppression means an auditor sees one record per privilege escalation, not ten records per click. That's the difference between evidence and noise.

The fix is simple once you know the problem. The hard part is knowing it happened at all. Logystera turns this kind of event from a quarterly access-review surprise into a 60-second notification with the actor, the target, and the source IP — one record per escalation, ready to drop into your audit binder.

7. Related Silent Failures

  • user.created with elevated roles at creation — same family, but the role is granted at user-creation time. Common in admin-bootstrapped accounts and SSO first-login flows. Needs a sister rule on event_type == user.created with roles regex match.
  • config.role_permissions_changed — the role itself gains a sensitive permission via drush config:import or /admin/people/permissions. Doesn't fire user.role_changed, but it's the same threat class.
  • auth.session_hijackdrupal_login_success_total from an unrecognized IP for an actor uid that immediately performs a role change. Pair with this rule for compound detection.
  • user.password_reset for admin accounts — actor resets uid 1's password, then grants themselves admin via the new credentials. Surfaces as user.password_reset followed by user.role_changed within minutes for the same target uid.
  • DB-direct grantsINSERT INTO user__roles bypasses Drupal's entity API and is invisible to user.role_changed. Requires DB-layer audit logging (MySQL audit plugin, RDS audit logs).

See what's actually happening in your Drupal system

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.