UCP · Admin-as-user impersonation

v1.0.0 feature. The manager's per-PBX UCP tab lets an admin land in the User Control Panel logged in as any FreePBX user on the paired PBX — no password required. Troubleshooting-only: reproduce a "my voicemail won't play" or "my BLF is stuck" report as the affected user, without phoning them for their password.

Requirements

How it works

  1. Manager admin opens the UCP tab on any PBX detail page. A searchable table lists every pickable user (filtered by effective ucp|Global.allowLogin).
  2. Click Open UCP → on a row. The tab's iframe body loads /pbx/{uuid}/ucp/login with the target user in a hidden target_user field (CSRF-signed POST).
  3. Manager mints an HMAC-signed SSO token with variant='ucp', target_path='/ucp/', target_user=<extension>, 30 s TTL. Nonce is one-shot.
  4. Token is 302 redirected to https://<pbx>/customsso-sso?token=… inside the iframe.
  5. PBX-side sso_entry.php verifies the HMAC signature, nonce atomicity, TTL, target-path/variant match, and effective ucp|Global.allowLogin for the target user.
  6. On success: \FreePBX::Ucp()->storeToken($ucpToken, $uid, $srcIp) establishes a UCP session bound to the admin's browser IP. Browser lands in /ucp/ logged in as that user.

The ↺ Switch user button in the header restores the picker overlay; the iframe is reset to about:blank so a stale impersonation session isn't left visible.

Freshness — three ingest paths

The picker is driven by the manager-side cache table pbx_ucp_users, kept in sync three ways:

  1. Sidecar SSH probe every 60 s — runs a small PHP one-liner over SSH that calls Userman::getCombinedModuleSettingByID($uid, 'ucp|Global', 'allowLogin') per user. Independent try/except from the status probe; a broken userman query does not mark the PBX offline.
  2. On-demand refresh — the ↻ Refresh user list button on the UCP tab nulls last_status_at for that PBX so the sidecar re-probes on its next 5 s tick. Fresh list lands in ~10–20 s. Reload the page after that.
  3. Push on fwconsole reload — customsso 1.0.0's doDialplanHook includes the current ucp_users array in its HMAC-signed status push. So toggling Allow UCP Login? in FreePBX admin propagates the moment the operator saves (which triggers reload).

All three paths use the exact same getCombinedModuleSettingByID signal for filtering, so what the picker offers is guaranteed to match what the PBX-side gate accepts.

Filtering: what counts as "pickable"

A user is emitted to the manager cache and shown in the picker if and only if Userman::getCombinedModuleSettingByID($uid, 'ucp|Global', 'allowLogin') returns a truthy value. FreePBX computes this as: per-user override (if explicitly set), else OR-merged across all groups the user belongs to, else the "All Users" group default (which FreePBX ships as 1 at install time).

Consequence: on a stock FreePBX 17 install, every User Manager user is pickable by default. To restrict: - Toggle Admin → User Management → user → UCP → Allow UCP Login? to No for individual users. - Or flip the group-level default in Admin → Groups → All Users → UCP → Allow UCP Login? to No and let users inherit.

Users with default_extension unset or set to 'none' (User Manager's sentinel for "no linked extension") are always excluded.

Security controls

Same defense-in-depth as the admin SSO path, plus:

Control Where enforced
Manager admin role required Sso::launchUcpSession::requireRole('admin') (chains CSRF)
POST + CSRF token (stronger than admin variant's GET + Referer gate) Session::requireRole default
Cache validation before token mint PbxUcpUser::byUsername + allow_login check
HMAC-SHA256 signature over {nonce, ts, target_user, target_path, pbx_uuid, variant} Sso::mintAndLaunch
30 s TTL + O_EXCL nonce (one-shot) sso_entry.php
Variant/target-path cross-validation sso_entry.php (variant='ucp' must target /ucp/)
PBX-side re-check of effective allowLogin sso_entry.php UCP branch
UCP session IP-pinned to the admin's browser IP \FreePBX::Ucp()->storeToken(..., $srcIp)
Every reject audit-logged (bad_target_user, user_not_found, user_login_disabled, pbx_not_found) Sso::auditUcpReject()
Referrer-Policy: no-referrer on the 302 sso_entry.php
CSP frame-ancestors 'self' <manager> on /ucp/ customsso-headers.conf.tpl

Known residual (deferred to v1.0.1): the UCP session row in asterisk.ucp_sessions persists until UCP's own TTL after Switch-user or tab close. IP-pinned, but the pin is the admin's browser IP — a walk-away workstation risk. A signed logout_ucp teardown endpoint + JS beforeunload sendBeacon is planned for v1.0.1.

Failure modes

"UCP module not installed" in the iframe — customsso module is < 1.0.0 on the PBX (missing the variant=ucp branch in sso_entry.php), OR the Ucp BMO class file couldn't be loaded under apache SAPI. Fix: install customsso 1.0.0 on the PBX via ↻ Refresh module list; the sidecar's self-heal SFTP will also push the current sso_entry.php on the next probe.

"UCP login disabled for this user" — the target user has effective Allow UCP Login? = No. Enable it in the FreePBX admin (see Filtering above) and hit ↻ Refresh user list on the manager.

Iframe stays blank / "refused to connect" — same causes as Native UI (untrusted cert, framing disabled, incognito third-party cookie block). See NATIVE_UI_HTTPS.md.

Picker table empty — the sidecar's probe hasn't run yet on a fresh pair. Wait 60 s or hit ↻ Refresh user list. If still empty, check that the PBX has User Manager users configured (Admin → User Management).

Audit

Every UCP-token issuance and every reject writes an audit row:

Query the audit log via Settings → Audit log or SQL: SELECT ts, user_id, pbx_uuid, action, target, JSON_EXTRACT(detail, '$.target_user') FROM audit WHERE action LIKE 'sso.ucp%' ORDER BY ts DESC.