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.
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-certendpoint. - Bypass of per-tenant isolation on read endpoints. Every gate that says "tenant scope" allows
superuserthrough 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:
- /admin.html dashboard.
- /tenant-settings.html for tenant-wide fallbacks (Gemini API key, Vci: toggles, Oidc:EnablePasswordGrant).
- /register.html for creating and disabling users on the SAME tenant.
- /virtual-numbers.html for vnum configuration.
- /acl.html for SIP + PSTN allow/deny lists.
- /webhooks-admin.html, /api-keys-admin.html, /oidc-clients.html, /trunks-admin.html.
- All wallet + signing endpoints (issue PID, verify presentations, sign PDFs).
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 / provision | yes | - | - | - | - | - |
| Tenant configuration (admin.html, tenant-settings) | yes | yes | - | - | - | - |
| Create / disable tenant users | yes | yes | - | - | - | - |
| Elevated API quotas | yes | yes | yes | standard | - | - |
| Personal EU Wallet | yes | yes | yes | yes | - | - |
| Sign PDFs (sign.html + CSC v2) | yes | yes | yes | yes | - | - |
| SIP soft-phone + WebRTC meetings | yes | yes | yes | yes | - | meeting join only |
| SIP REGISTER from desk phone | yes | yes | yes | yes | yes | - |
| OIDC sign-in (proof of identity) | yes | yes | yes | yes | - | yes |
| Assignable via register.html | no (operator only) | yes | yes | yes | yes | yes |
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
| Status | Body (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
| Action | Passwordless? | Purpose | Extra body |
|---|---|---|---|
credentials=put | no — needs HA1 | Create or fully-replace a user record. | ha1 (required, 32 lowercase hex chars), role (optional, defaults to user), realm (optional). |
credentials=setrole | YES | Change the role field on an existing user. | role (required, one of the five valid values above). |
credentials=setpassword | new HA1 only | Reset a user's password (admin override — you do not need the old password). | ha1 (required, 32 lowercase hex chars). |
credentials=setdisabled | YES | Enable or disable an existing user without touching password or role. | disabled (required, boolean). |
credentials=delete | HMAC canonical carries empty HA1/realm | Remove 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
roleclaim must beadmin(orsuperuser, 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
- Admin dashboard — the main
adminsurface. - Superadmin panel — the cross-tenant view (superuser only).
- User registration — assign per-tenant roles from the browser UI.
- Assign a role via the API — the passwordless
credentials=setroleendpoint (section above). - CSC v2 auth section — role handling on the signing endpoints.
- SIP ACL — complementary IP-based access control (orthogonal to roles).
- Tenant settings — opt-in gates (e.g.
Oidc:EnablePasswordGrant) that admins toggle. - W3C Digital Credentials API manifest — per-tenant
/.well-known/web-identitywallet-provider registration.