Gateway Operator Scopes
Least-privilege role-based access control for multi-operator Gateway deployments
Operator scopes grant teammates least-privilege access to a shared Gateway β read-only dashboards, send-but-not-approve operators, or full admins β without handing over the whole keys.
from praisonaiagents import Agent
agent = Agent(
name="assistant",
instructions="You are a helpful assistant.",
)
agent.start("Send a message through the gateway")The user assigns scoped roles; each operator reaches only the gateway actions their role allows.
Quick Start
- Single-operator (no scopes β unchanged)
Today’s setup keeps working. Authenticated clients receive all scopes when no policy is configured.
python from praisonaiagents import Agent agent = Agent( name="assistant", instructions="You are a helpful assistant.", ) # $ praisonai gateway start --host 127.0.0.1 agent.start("hello") - Multi-operator (scoped tokens)
Map each operator token to the scopes they need in
gateway.yaml, then run your agent as usual.yaml gateway: host: "0.0.0.0" port: 8765 auth: tokens: - token: "${VIEWER_TOKEN}" scopes: [read] - token: "${OPS_TOKEN}" scopes: [read, write] - token: "${ADMIN_TOKEN}" scopes: [admin] agents: assistant: instructions: "You are a helpful assistant." model: gpt-4o-minipython from praisonaiagents import Agent from praisonaiagents.gateway import OperatorScope agent = Agent(name="assistant", instructions="You are a helpful assistant.") # OperatorScope.READ, .WRITE, .APPROVALS, .PAIRING, .ADMIN print([s.value for s in OperatorScope.all()])
How It Works
- Client connects with a bearer token.
- Gateway resolves scopes via
GatewayConfig.resolve_scopes(token). - Each HTTP route and WebSocket action checks the required scope.
- Outbound events are filtered β approval events only reach clients with the
approvalsscope.
WebSocket frames go through the central authorize_method gate before any handler runs.
Scope Reference
| Scope | Value | Grants |
|---|---|---|
| Read | read | View dashboard, session transcripts, and status events |
| Write | write | Send messages as the agent (WebSocket message) |
| Approvals | approvals | Resolve tool-execution approvals and manage allowlist |
| Pairing | pairing | Approve or revoke device pairing |
| Admin | admin | Channel pause/resume/reconnect β implies all scopes |
Which scope should this operator have?
| Role | Recommended scopes |
|---|---|
| Read-only stakeholder | [read] |
| Junior support (send, not approve) | [read, write] |
| On-call approver | [read, approvals] |
| SRE / platform admin | [admin] |
Per-user attribution under a shared token
Operators sharing one gateway token collapse to a single audit identity by default. A proxy or SSO layer can stamp the X-Operator-Id header per user so each decision is still attributed to an individual on the audit trail.
curl -X POST -H "Authorization: Bearer $OPS_TOKEN" \
-H "X-Operator-Id: alice" \
-H "Content-Type: application/json" \
-d '{"request_id": "apr-1a2b3c4d", "approved": true}' \
http://localhost:8080/approval/resolveThe gateway stamps this as operator:alice on the audit trail. See Approval Attribution for the full identity derivation order.
Configuration
YAML β structured (recommended)
gateway:
auth:
tokens:
- token: "${VIEWER_TOKEN}"
scopes: [read]
- token: "${OPS_TOKEN}"
scopes: [read, write, approvals]
- token: "${ADMIN_TOKEN}"
scopes: [admin]YAML β flat mapping
gateway:
auth_scopes:
"${VIEWER_TOKEN}": [read]
"${OPS_TOKEN}": [read, write, approvals]
"${ADMIN_TOKEN}": [admin]Python
from praisonaiagents.gateway import GatewayConfig, OperatorScope
config = GatewayConfig(
host="0.0.0.0",
port=8765,
auth_token="${ADMIN_TOKEN}",
auth_scopes={
"${VIEWER_TOKEN}": [OperatorScope.READ.value],
"${OPS_TOKEN}": [OperatorScope.READ.value, OperatorScope.WRITE.value],
"${ADMIN_TOKEN}": [OperatorScope.ADMIN.value],
},
)
print(config.has_scope_policy) # True when auth_scopes is non-empty
print(config.resolve_scopes("${VIEWER_TOKEN}")) # ['read']Confining operators to specific agents (Issue #6253)
On a multi-agent gateway, an operator token can be confined to a subset of agents by giving it {scopes, agents} instead of a plain scope list.
Quick Start
- Flat auth_scopes (recommended)
yaml gateway: host: 0.0.0.0 port: 8765 auth_scopes: "${SUPPORT_TOKEN}": scopes: [read, write] agents: [support] "${FINANCE_TOKEN}": scopes: [read, write] agents: [finance] "${ADMIN_TOKEN}": [admin] # unchanged β reaches every agent agents: support: { instructions: "..." } finance: { instructions: "..." } - Structured auth.tokens[] (equivalent)
yaml gateway: auth: tokens: - token: "${SUPPORT_TOKEN}" scopes: [read, write] agents: [support] - token: "${ADMIN_TOKEN}" scopes: [admin] # no `agents:` β unconfined - Python
python import os from praisonaiagents.gateway import GatewayConfig, OperatorScope cfg = GatewayConfig( host="0.0.0.0", port=8765, auth_token=os.environ["ADMIN_TOKEN"], auth_scopes={ os.environ["SUPPORT_TOKEN"]: { "scopes": [OperatorScope.READ.value, OperatorScope.WRITE.value], "agents": ["support"], }, os.environ["ADMIN_TOKEN"]: [OperatorScope.ADMIN.value], # unconfined }, )
How it works
A confined operator is refused wherever a session would bind to, resume, join, or receive events from an agent outside its grant:
hello/joinbind β anagent_idoutside the grant is refused withAGENT_NOT_FOUND.joinsession resume β asession_idowned by an out-of-grant agent is refused (Session does not belong to the requested agent) before the session is resumed; sessions rehydrated from the store are checked again after resume.attach()(shared sessions) β raisesPermissionErrorfor a session whose agent is outside the grant.broadcast()β events whosesourceis an out-of-grant agent are withheld. System (non-agent) events are unaffected.
Fail-closed guards
| Situation | Behaviour |
|---|---|
Unknown grant key (e.g. agnts: [support]) | Rejected at load β a typo cannot silently drop the confinement |
Scalar agents: support | ValueError at load β must be a list |
${VAR} token key resolves to empty | That entry is dropped; if every entry is empty the gateway refuses to start |
Installed praisonaiagents too old to parse grants | A declared policy refuses the start; with no policy declared, behaviour is unchanged (every token unconfined) |
Python API
GatewayConfig.resolve_agents(token) returns the agent ids a token is confined to, or None when the token is unconfined (or unknown).
from praisonaiagents.gateway import GatewayConfig
cfg = GatewayConfig(
auth_token="tmp",
auth_scopes={
"tok_support": {"scopes": ["read", "write"], "agents": ["support"]},
"tok_legacy": ["read", "write"],
},
)
cfg.resolve_scopes("tok_support") # ['read', 'write']
cfg.resolve_agents("tok_support") # ['support'] β confined
cfg.resolve_agents("tok_legacy") # None β unconfined (every agent)
cfg.resolve_agents("nope") # NoneScope-Gated Routes
For programmatic (WebSocket / RPC) methods, see Method β Scope Registry below.
| Route | Method | Required scope |
|---|---|---|
/api/channels/{name}/pause | POST | admin |
/api/channels/{name}/resume | POST | admin |
/api/channels/{name}/reconnect | POST | admin |
/api/approval/resolve | POST | approvals |
/api/approval/allowlist | GET | any authenticated |
/api/approval/allowlist | POST/DELETE | approvals |
/api/pairing/approve | POST | pairing |
/api/pairing/revoke | POST | pairing |
WebSocket hello / join / leave | β | read |
WebSocket message | β | write |
WebSocket abort | β | write |
The WebSocket message requirement comes from the Method Scope Registry below β the dispatcher calls resolve_required_scope("message") (and resolve_required_scope("agent.message")) rather than hard-coding the check per route.
Method Scope Registry
New gateway methods are closed until classified β you cannot accidentally ship an unauthenticated control-plane call.
Every method maps to a GatewayMethodDescriptor in the module-level GATEWAY_METHODS registry. resolve_required_scope looks up that descriptor and returns the effective scope for a call.
Quick lookup
from praisonaiagents import Agent
from praisonaiagents.gateway import resolve_required_scope, OperatorScope
agent = Agent(name="assistant", instructions="You are a helpful assistant.")
print(resolve_required_scope("agent.message")) # OperatorScope.WRITE
print(resolve_required_scope("session.status")) # OperatorScope.READ
print(resolve_required_scope("myplugin.unknown")) # OperatorScope.ADMIN (default-deny)Registering a plugin method
Plugin authors adding a new dispatcher method register its required scope once, at import time.
from praisonaiagents import Agent
from praisonaiagents.gateway import register_gateway_method, OperatorScope
agent = Agent(name="assistant", instructions="You are a helpful assistant.")
register_gateway_method(
"myplugin.reindex",
scope=OperatorScope.ADMIN,
owner="myplugin",
)register_gateway_method refuses duplicates β pass replace=True to override an existing entry.
Per-field escalation
Escalation raises the required scope when a sensitive field is present; a plain call stays at the baseline.
from praisonaiagents import Agent
from praisonaiagents.gateway import register_gateway_method, resolve_required_scope, OperatorScope
agent = Agent(name="assistant", instructions="You are a helpful assistant.")
register_gateway_method(
"myplugin.publish",
scope=OperatorScope.WRITE,
owner="myplugin",
escalate_fields={"config": OperatorScope.ADMIN},
replace=True,
)
print(resolve_required_scope("myplugin.publish", {"text": "hi"})) # OperatorScope.WRITE
print(resolve_required_scope("myplugin.publish", {"config": {...}})) # OperatorScope.ADMINEscalation only ever raises the requirement β a field mapped to a weaker scope than the baseline is ignored.
Strict fields (fail-closed on unknown payload)
strict_fields=True escalates any structural field not explicitly listed as safe, so unexpected payload keys fail closed.
from praisonaiagents import Agent
from praisonaiagents.gateway import register_gateway_method, resolve_required_scope, OperatorScope
agent = Agent(name="assistant", instructions="You are a helpful assistant.")
register_gateway_method(
"myplugin.echo",
scope=OperatorScope.WRITE,
owner="myplugin",
strict_fields=True,
safe_fields={"text"},
replace=True,
)
print(resolve_required_scope("myplugin.echo", {"text": "hi"})) # OperatorScope.WRITE
print(resolve_required_scope("myplugin.echo", {"text": "hi", "raw": 1})) # OperatorScope.ADMINCore method classification
Core methods are classified at import; anything unlisted defaults to admin.
| Method | Required scope |
|---|---|
hello, join, leave | read |
agent.message | write |
message | write |
abort, message_abort | write |
session.status | read |
session.transcript | read |
session.resolve | read (read-only lookup β reveals no more than session.status) |
approvals.resolve | approvals |
server_reply | write (escalates to approvals when the request’s kind == "approval") |
pairing.approve | pairing |
pairing.revoke | pairing |
channels.control | admin |
channels.pause | admin |
channels.resume | admin |
channels.reconnect | admin |
Connection-lifecycle frames (hello, join, leave) run before a client has negotiated any scope, so they classify as read β the lowest meaningful level. abort (and its wire alias message_abort) mutates the turn just like sending one, so both carry the same write requirement as message.
server_reply is unique β the handler escalates to approvals for approval-kind replies (parity with approvals.resolve), so a WRITE-only role can answer choice/input but not approval prompts. See Gateway ServerβClient Request Channel.
Descriptor fields
GatewayMethodDescriptor is a frozen dataclass; its escalate_fields and safe_fields collections are defensively copied at construction, so mutating the originals after registration cannot change resolution.
| Field | Default | Meaning |
|---|---|---|
name | β | Method / route / message-type identifier |
required_scope | admin | Baseline scope required |
owner | "core" | Who declared it (informational) |
since | None | Version/date when classified |
escalate_fields | {} | Per-field escalations (raises, never lowers) |
strict_fields | False | Escalate unlisted fields to escalate_unknown_scope |
safe_fields | set() | Fields that never escalate under strict_fields |
escalate_unknown_scope | admin | Scope for unknown fields under strict_fields |
Central authorization gate
Every gateway method now flows through one default-deny gate before any handler runs.
Nothing changes for end users β the gateway gates dispatch for you.
from praisonaiagents import Agent
agent = Agent(name="assistant", instructions="You are a helpful assistant.")
agent.start("hello") # dispatch is gated automatically; nothing to configurePlugin authors extending the dispatcher can call the gate directly.
from praisonaiagents.gateway import (
authorize_method,
GatewayUnauthorized,
OperatorScope,
)
try:
authorize_method("message", {OperatorScope.READ})
except GatewayUnauthorized as exc:
print(exc.method, exc.required.value)
# -> message write- Called once, centrally, at the top of
_handle_client_messageβ no per-handler wiring needed. - Default-deny: an unclassified method requires
admin. adminimplies every scope.readis the baseline lifecycle scope β any operator holding at least one actionable scope (write/approvals/pairing/admin) satisfiesread, so awrite-only operator can still completehello/join/leave/session.status. An empty scope set cannot satisfy evenread.- A malformed
method(non-string / unhashable) fails closed toadminrather than raisingTypeError. - Transport
PING/PONGframes bypass the gate (handled earlier in the wire loop).
Method β Scope Registry
Every gateway method declares its required scope in one place β a declarative registry β instead of scattered checks per endpoint. New methods are closed until explicitly classified; an unregistered method requires admin.
from praisonaiagents.gateway import (
OperatorScope,
register_gateway_method,
resolve_required_scope,
)
# What scope does this call need?
resolve_required_scope("agent.message") # OperatorScope.WRITE
resolve_required_scope("channels.pause") # OperatorScope.ADMIN
resolve_required_scope("totally.unknown.method") # OperatorScope.ADMIN (default-deny)Core method classification
Core methods are classified at import in praisonaiagents.gateway.protocols:
| Method | Required scope |
|---|---|
hello, join, leave | read |
agent.message, message, abort, message_abort | write |
session.status, session.transcript, session.resolve | read |
approvals.resolve | approvals |
server_reply | write (escalates to approvals for approval-kind replies) |
pairing.approve, pairing.revoke | pairing |
channels.control, channels.pause, channels.resume, channels.reconnect | admin |
Connection-lifecycle frames (hello, join, leave) run before a client has negotiated any scope, so they classify as read β the lowest meaningful level. abort (and its wire alias message_abort) mutates the turn just like sending one, so both carry the same write requirement as message.
Registering a plugin method
A plugin that adds a new gateway surface registers its scope at import time:
from praisonaiagents.gateway import OperatorScope, register_gateway_method
register_gateway_method(
"myplugin.notify",
scope=OperatorScope.WRITE,
owner="myplugin",
since="1.4.0",
)Per-payload-field escalation (raise, never lower)
A descriptor can raise the required scope when a specific field appears in the params β an otherwise write method that mutates configuration should require admin when the config field is present:
from praisonaiagents.gateway import OperatorScope, register_gateway_method, resolve_required_scope
register_gateway_method(
"myplugin.update",
scope=OperatorScope.WRITE,
escalate_fields={"config": OperatorScope.ADMIN},
)
resolve_required_scope("myplugin.update", {"text": "hi"}) # WRITE
resolve_required_scope("myplugin.update", {"text": "hi", "config": {}}) # ADMINStrict-fields mode (fail closed on unknown fields)
For high-risk methods, enable strict_fields=True with an allowlist of known-safe fields. Any field not in safe_fields or escalate_fields escalates to admin:
from praisonaiagents.gateway import OperatorScope, register_gateway_method, resolve_required_scope
register_gateway_method(
"myplugin.execute",
scope=OperatorScope.WRITE,
strict_fields=True,
safe_fields={"text"},
)
resolve_required_scope("myplugin.execute", {"text": "hi"}) # WRITE
resolve_required_scope("myplugin.execute", {"text": "hi", "mutate": 1}) # ADMINIncomparable scopes escalate to ADMIN
approvals and pairing are sibling capabilities β neither implies the other. When a method’s baseline is one and a field escalates to the other, the combined requirement escalates to admin rather than silently picking one (a single-scope check cannot satisfy both):
from praisonaiagents.gateway import OperatorScope, register_gateway_method, resolve_required_scope
register_gateway_method(
"myplugin.combo",
scope=OperatorScope.APPROVALS,
escalate_fields={"pair": OperatorScope.PAIRING},
)
resolve_required_scope("myplugin.combo", {"other": 1}) # APPROVALS
resolve_required_scope("myplugin.combo", {"pair": True}) # ADMIN β fail-closedAPI reference
| Symbol | Meaning |
|---|---|
GatewayMethodDescriptor | Frozen dataclass β name, required_scope, owner, since, escalate_fields, strict_fields, safe_fields, escalate_unknown_scope |
GATEWAY_METHODS: dict[str, GatewayMethodDescriptor] | The module-level registry |
register_gateway_method(name, *, scope=ADMIN, owner="core", since=None, escalate_fields=None, strict_fields=False, safe_fields=None, escalate_unknown_scope=ADMIN, replace=False) | Registers name. Raises ValueError on duplicate unless replace=True |
resolve_required_scope(method, params=None) | Resolves the effective required scope. Unknown method β admin |
authorize_method(method, client_scopes, params=None) | Central default-deny gate. Resolves the required scope from resolve_required_scope() and raises GatewayUnauthorized when client_scopes do not satisfy it. Called by the WebSocket dispatcher for every non-ping frame |
GatewayUnauthorized | PermissionError subclass raised by authorize_method. Carries .method: str and .required: OperatorScope so the dispatcher can render insufficient_scope with required_scope set |
For the approval-flow context these scopes gate, see Scoped Approvals.
Common Patterns
Read-only dashboard viewer β [read] for status and transcripts without send or approve rights.
Send but not approve β [read, write] for operators who reply to users but cannot resolve tool approvals.
Approvals-only on-call β [read, approvals] for security-sensitive approval resolution without channel admin rights.
Full admin β [admin] for SREs who need pause/resume/reconnect plus all other capabilities.
Introspecting your own scopes
praisonai gateway status prints the scopes your own token holds on a running gateway, no curl needed.
$ praisonai gateway status
...
Access: authorized (scopes: read)When the token has neither write nor admin, status also prints a Next step: hint pointing at gateway.auth_scopes. The same data is the scopes field of GET /info; see Gateway CLI β status for all verdicts.
Error Handling
HTTP 403 when scope check fails:
{ "error": "insufficient scope", "required_scope": "approvals" }WebSocket message without write scope:
{
"type": "error",
"code": "insufficient_scope",
"message": "insufficient scope",
"required_scope": "write"
}Best Practices
Default to read and add scopes as needed
Start every operator with [read] and expand only when their role requires it.
Rotate per-token secrets independently
Issue separate tokens per operator so you can revoke one role without rotating everyone.
Pair approvals with the allowlist
Combine approvals scope with /api/approval/allowlist for defence-in-depth on tool execution.
Use admin sparingly
Prefer explicit scope lists over [admin] unless the operator truly needs channel control.
Classify every new gateway method
An unregistered gateway method defaults to admin (fail closed). When a plugin adds new surface, always call register_gateway_method(name, scope=...) at import time so the requirement matches the intent β not just whatever the current default happens to be.