Roles & access model

Six roles across two scopes. Every admin surface, every API endpoint, every SIP register gate consults the same role slug. This page is the canonical documentation — if you're building a permissions matrix for your tenant, start here.

Two scopes. operator roles bypass per-tenant isolation and live in the bridge's appsettings.json, not in any tenant's user store. per-tenant roles live in App_Data/<tenant>/sip-credentials/<tenant>.json and only see their own tenant. There is exactly one operator role (superuser); everything else is per-tenant.

superuser operator

Cross-tenant break-glass. The only role that can list, provision, delete or read across every tenant materialised on the machine. Credentials live in the bridge's appsettings.json (SuperuserBearer + optional HMAC secret) — NOT in any tenant's user store.

What it unlocks:

  • Cross-tenant view at /superadmin.html (the only page that enumerates all tenants).
  • Tenant provisioning: create a new tenant, bootstrap its first admin, issue an ACME/Let's Encrypt cert via the bridge /acquire-cert endpoint.
  • Bypass of per-tenant isolation on read endpoints. Every gate that says "tenant scope" allows superuser through as the exception.
  • The Stefan-IP allowlist (195.158.111.88) is a superuser-scoped safety valve; hits from that IP are never rate-limited.

Where it's checked (server): RequireSuperuserBearer(context, "<endpoint-name>") in signal.ashx, oidc.ashx, csc.ashx. The check accepts either a Bearer token whose sub matches SuperuserBearer or a request signed with the operator HMAC.

Where it's checked (client): if (p.role === 'superuser') after OIDC.init resolves. Elevates cross-tenant panels; on non-superuser sessions the panel hides entirely and the server returns 403 superuser-required.

How to assign: only the operator with root on the box can grant this role. Add / rotate the SuperuserBearer value in the bridge appsettings.json. Not creatable via any UI. Not discoverable on any customer-facing page (per house rule).

admin per-tenant

Tenant owner / operator. Full control inside one tenant's App_Data directory: users, virtual numbers, trunks, SIP ACL, appsettings, PAdES signing settings, wallet issuance toggles.

What it unlocks:

Where it's checked: the standard admin gate if(p.role !== "admin") in every admin page's AdminFetch.gate({onWrongRole}). Server-side, admin surfaces bind through the OIDC bearer verified against the per-tenant issuer.

How to assign: a tenant admin creates users via /register.html and picks admin from the dropdown. First admin of a fresh tenant is provisioned by a superuser via /superadmin.html.

poweruser per-tenant

Elevated user quotas, no admin. Added 2026-08-12. Sits between admin and user. Can consume more of the tenant's licensed quotas (calls per hour, signatures per day, wallet issuance per week) but cannot change tenant configuration.

What it unlocks:

  • All user-level surfaces (SIP softphone, EU Wallet, meetings, sign.html, csc-v2-api tester).
  • Higher per-hour / per-day rate-limit buckets on API endpoints.
  • No configuration surfaces (admin.html, tenant-settings.html, register.html all deny).

Where it's checked: rate-limit tiering in the bridge's LicenseGate.Check and per-endpoint meter budgets. Admin surfaces treat poweruser the same as user (denied).

How to assign: tenant admin picks poweruser from the role dropdown in /register.html when creating a user, or edits an existing user's role in /superadmin.html.

user per-tenant

Default role. Every newly-registered user gets this role unless the creating admin picks something else.

What it unlocks:

  • SIP soft-phone + registered desk-phone dialling.
  • Personal EU Wallet issuance + verification.
  • PDF signing (via sign.html or csc-v2-api.html Try-it-live).
  • WebRTC meetings.
  • Personal API keys via /api-keys-admin.html (own keys only).
  • OIDC sign-in as a client on any Relying Party wired to the tenant's OP.

Where it's checked: every endpoint that requires "signed-in, any role" passes on user. Rate-limit buckets use the standard tier.

siponly per-tenant

SIP phone only, no browser surfaces. Created to distinguish desk-phone-only users from full app users, so licensing counts stay honest.

What it unlocks: SIP REGISTER + INVITE from the tenant's SIP trunk. Nothing else.

What it doesn't: no browser dashboard, no wallet, no signing, no OIDC sign-in flows. Attempts to sign in via /login.html return role_not_permitted_here.

Typical use: hotel desk phones, kiosk phones, deployed hardware in the field. The tenant admin bulk-provisions these via /register.html.

guest per-tenant

Sign-in only. Can authenticate to prove identity but has no consumable quotas. Used by external users invited to a single meeting or a single document-signing session.

What it unlocks:

  • OIDC sign-in as a subject (returns id_token so an RP can verify who they are).
  • Time-boxed meeting join via personal invite URL.
  • Chat access under the anonymous-invited-only model.

What it doesn't: no SIP registration, no signing, no wallet operations, no persistent identity.

Capability matrix

Capability superuser admin poweruser user siponly guest
Cross-tenant list / provisionyes-----
Tenant configuration (admin.html, tenant-settings)yesyes----
Create / disable tenant usersyesyes----
Elevated API quotasyesyesyesstandard--
Personal EU Walletyesyesyesyes--
Sign PDFs (sign.html + CSC v2)yesyesyesyes--
SIP soft-phone + WebRTC meetingsyesyesyesyes-meeting join only
SIP REGISTER from desk phoneyesyesyesyesyes-
OIDC sign-in (proof of identity)yesyesyesyes-yes
Assignable via register.htmlno (operator only)yesyesyesyesyes

Where roles live on disk

Per-tenant roles (admin, poweruser, user, siponly, guest) are stored in the tenant's SIP credentials file:

App_Data/<tenant-domain>/sip-credentials/<tenant-slug>.json

{
  "realm": "phone.aloaha.com",
  "users": {
    "alice": { "ha1": "<md5-hex>", "role": "admin",     "disabled": false },
    "bob":   { "ha1": "<md5-hex>", "role": "poweruser", "disabled": false },
    "carol": { "ha1": "<md5-hex>", "role": "siponly",   "disabled": false }
  }
}

ha1 = MD5("username:realm:password"). Same file drives SIP REGISTER, HTTP Basic auth (csc.ashx signDocBasic), and OIDC ROPC. Change the role and the change is picked up on next auth — no service restart.

The superuser role lives in the bridge's appsettings.json under SuperuserBearer + optional SuperuserHmacSecret. Only accessible to whoever has root on the machine. Never listed in sip-credentials/*.json. Never surfaced by any customer-facing page.

Assigning a role via the API

Short version. Use POST /signal.ashx?credentials=setrole with an admin OIDC Bearer token (or admin HMAC signature) and a JSON body of {tenant, user, role}. This is the passwordless admin path — you do not need the target user's password, HA1, or any other user-side secret. The endpoint mutates only the role field, preserving the user's HA1 and every other per-user field (SIP registration, disabled flag, profile, etc. all survive).

Do not use credentials=put for a role-only change — put is a full user-record upsert and requires an HA1 (the caller must know the target user's password to compute it). setrole is the admin-scoped mutation you want.

Valid role values

Exactly one of the following, lowercase, no whitespace:

  • siponly — SIP phone only, no browser surfaces.
  • user — default tenant user; full app surfaces at standard quotas.
  • poweruser — elevated per-user quotas, no admin surfaces.
  • admin — per-tenant administrator.
  • guest — sign-in only, no consumable quotas.

Not accepted here: superuser. Superuser is an OPERATOR-scope role that lives in the bridge's appsettings.json, not in any tenant's user store — it is intentionally NOT assignable via this endpoint. Requests with role=superuser return 400 with the message "role must be one of: siponly, user, poweruser, admin, guest (superuser is operator-scope, not assignable via this endpoint)".

Wire — Bearer-authenticated (typical browser-side admin)

POST /signal.ashx?credentials=setrole HTTP/1.1
Host: phone.aloaha.com
Authorization: Bearer <admin-oidc-access-token>
Content-Type: application/json

{
  "tenant": "phone.aloaha.com",
  "user":   "alice",
  "role":   "poweruser"
}

curl example

curl -sS -X POST "https://phone.aloaha.com/signal.ashx?credentials=setrole" \
  -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tenant":"phone.aloaha.com","user":"alice","role":"poweruser"}'

Wire — HMAC-authenticated (server-to-server, no Bearer)

Compute HMAC-SHA256 over the canonical string, hex-encode the result, and send as the X-CodeB-Admin-Signature header. Key = the tenant's SuperuserHmacSecret. No Authorization header needed on this path.

Canonical string  = "setrole|" + tenant + "|" + user + "|" + role
                    e.g. "setrole|phone.aloaha.com|alice|poweruser"

Signature header  = X-CodeB-Admin-Signature: hex(hmac_sha256(SuperuserHmacSecret, canonical))

Fields are joined with literal pipe characters, no surrounding whitespace, all lowercase (role slug + user slug already lowercased at the schema level). Any mismatch — wrong secret, wrong field order, wrong casing — returns 401 bad signature.

Response shapes

StatusBody (example)Cause
200{"ok":true,"user":"alice","role":"poweruser"}Role updated. HA1 + all other fields preserved.
400{"error":"invalid user (lowercase alnum + . _ - + @; max 254 chars)"}Username shape wrong (must match ^[a-z0-9._+\-@]{1,254}$).
400{"error":"role must be one of: siponly, user, poweruser, admin, guest ..."}Role not in whitelist (includes role=superuser attempts).
401{"error":"bad signature"}Neither a valid admin Bearer nor a valid HMAC signature.
404{"error":"user not found"}The target user does not exist in this tenant's sip-credentials/<tenant>.json. Create it via credentials=put first (that DOES need an HA1 — you're setting the initial password).

Companion endpoints on the same handler

ActionPasswordless?PurposeExtra body
credentials=putno — needs HA1Create or fully-replace a user record.ha1 (required, 32 lowercase hex chars), role (optional, defaults to user), realm (optional).
credentials=setroleYESChange the role field on an existing user.role (required, one of the five valid values above).
credentials=setpasswordnew HA1 onlyReset a user's password (admin override — you do not need the old password).ha1 (required, 32 lowercase hex chars).
credentials=setdisabledYESEnable or disable an existing user without touching password or role.disabled (required, boolean).
credentials=deleteHMAC canonical carries empty HA1/realmRemove a user record entirely.— (no extra fields; canonical includes empty HA1 + empty realm for backward compat).

Auth-gate details

The internal check is AdminAllowed(context, expectedSig, sigHeader). Both auth paths satisfy it independently:

  • OIDC Bearer — the token's role claim must be admin (or superuser, but nobody signs in as superuser routinely). Introspection is against the per-tenant OIDC issuer.
  • HMAC — matches per-action canonical (shown above) against the tenant's SuperuserHmacSecret. Constant-time comparison; timing attacks not viable.

Either alone is sufficient. Sending both is allowed; the check short-circuits on the first success.

What survives a role change

Everything except role is preserved on the user record: ha1 (password), disabled flag, pbkdf2 (if migrated), profile fields, per-user metadata added by future features. A live SIP registration keeps registering (SIP REGISTER re-checks the HA1, not the role); an active browser session may need a re-login to pick up new role-gated surfaces (client caches p.role from the OIDC id_token).

Cross-references

Procurement teams: This capability sits inside our shipped EUDIW stack. See EUDIW procurement readiness — Malta for the full response map (sovereign hosting, HSM supply chain, ARF 3.0 self-assessment table, certification-support commitment).