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.

python
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.

Diagram

Multi-Operator Gateway

πŸ‘ Viewer
read

🌐 Gateway

βœ‰οΈ Ops
read + write

πŸ›‘ Admin
admin

πŸ€– Agent

Quick Start

  1. 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")
  2. 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-mini
    python
    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

Diagram
WebSocket subscribers/api/approval/resolveresolve_scopesGatewayClientWebSocket subscribers/api/approval/resolveresolve_scopesGatewayClientalt[has approvals scope][missing scope]Connect with tokenresolve_scopes(token)[read, approvals]POST resolve approval200 OKapproval event (approvals clients only)403 insufficient scope
  1. Client connects with a bearer token.
  2. Gateway resolves scopes via GatewayConfig.resolve_scopes(token).
  3. Each HTTP route and WebSocket action checks the required scope.
  4. Outbound events are filtered β€” approval events only reach clients with the approvals scope.

WebSocket frames go through the central authorize_method gate before any handler runs.

Diagram
Handlerresolve_required_scopeDispatcherClientHandlerresolve_required_scopeDispatcherClientalt[scope satisfied][GatewayUnauthorized]WS frame {type: “message”, …}authorize_method(“message”, scopes, params)OKdispatchresultraise (method, required){type: error, code: insufficient_scope, required_scope}

Scope Reference

ScopeValueGrants
ReadreadView dashboard, session transcripts, and status events
WritewriteSend messages as the agent (WebSocket message)
ApprovalsapprovalsResolve tool-execution approvals and manage allowlist
PairingpairingApprove or revoke device pairing
AdminadminChannel pause/resume/reconnect β€” implies all scopes
Diagram

admin

read

write

approvals

pairing

Which scope should this operator have?

Diagram

View only

Send messages

Resolve approvals

Manage pairing

Full control

What do they need?

read

read + write

read + approvals

read + pairing

admin

RoleRecommended 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.

bash
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/resolve

The gateway stamps this as operator:alice on the audit trail. See Approval Attribution for the full identity derivation order.


Configuration

YAML β€” structured (recommended)

yaml
gateway:
  auth:
    tokens:
      - token: "${VIEWER_TOKEN}"
        scopes: [read]
      - token: "${OPS_TOKEN}"
        scopes: [read, write, approvals]
      - token: "${ADMIN_TOKEN}"
        scopes: [admin]

YAML β€” flat mapping

yaml
gateway:
  auth_scopes:
    "${VIEWER_TOKEN}": [read]
    "${OPS_TOKEN}": [read, write, approvals]
    "${ADMIN_TOKEN}": [admin]

Python

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.

Diagram

Per-agent confinement

tok_support + tok_legacy

tok_legacy only

πŸ”‘ tok_support
agents: support

🌐 Gateway

πŸ”‘ tok_legacy
no agents key

πŸ€– support

πŸ€– finance

Quick Start

  1. 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: "..." }
  2. Structured auth.tokens[] (equivalent)
    yaml
    gateway:
      auth:
        tokens:
          - token: "${SUPPORT_TOKEN}"
            scopes: [read, write]
            agents: [support]
          - token: "${ADMIN_TOKEN}"
            scopes: [admin]              # no `agents:` β†’ unconfined
  3. 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 / join bind β€” an agent_id outside the grant is refused with AGENT_NOT_FOUND.
  • join session resume β€” a session_id owned 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) β€” raises PermissionError for a session whose agent is outside the grant.
  • broadcast() β€” events whose source is an out-of-grant agent are withheld. System (non-agent) events are unaffected.
Diagram
resolve_agentsGatewayConfined operator (tok_support)resolve_agentsGatewayConfined operator (tok_support)Same reply as a missing agent β€” the agent’s existence is not disclosedhello {agent_id: “finance”}resolve_agents(tok_support)[“support”]error AGENT_NOT_FOUND

Fail-closed guards

SituationBehaviour
Unknown grant key (e.g. agnts: [support])Rejected at load β€” a typo cannot silently drop the confinement
Scalar agents: supportValueError at load β€” must be a list
${VAR} token key resolves to emptyThat entry is dropped; if every entry is empty the gateway refuses to start
Installed praisonaiagents too old to parse grantsA 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).

python
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")          # None

Scope-Gated Routes

For programmatic (WebSocket / RPC) methods, see Method β†’ Scope Registry below.

RouteMethodRequired scope
/api/channels/{name}/pausePOSTadmin
/api/channels/{name}/resumePOSTadmin
/api/channels/{name}/reconnectPOSTadmin
/api/approval/resolvePOSTapprovals
/api/approval/allowlistGETany authenticated
/api/approval/allowlistPOST/DELETEapprovals
/api/pairing/approvePOSTpairing
/api/pairing/revokePOSTpairing
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.

Diagram

πŸ“¨ method + params

πŸ” registry lookup

🧱 base scope

⬆️ apply escalations

πŸ›‘ effective scope

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

python
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.

python
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.

python
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.ADMIN

Escalation 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.

python
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.ADMIN

Core method classification

Core methods are classified at import; anything unlisted defaults to admin.

MethodRequired scope
hello, join, leaveread
agent.messagewrite
messagewrite
abort, message_abortwrite
session.statusread
session.transcriptread
session.resolveread (read-only lookup β€” reveals no more than session.status)
approvals.resolveapprovals
server_replywrite (escalates to approvals when the request’s kind == "approval")
pairing.approvepairing
pairing.revokepairing
channels.controladmin
channels.pauseadmin
channels.resumeadmin
channels.reconnectadmin

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.

FieldDefaultMeaning
nameβ€”Method / route / message-type identifier
required_scopeadminBaseline scope required
owner"core"Who declared it (informational)
sinceNoneVersion/date when classified
escalate_fields{}Per-field escalations (raises, never lowers)
strict_fieldsFalseEscalate unlisted fields to escalate_unknown_scope
safe_fieldsset()Fields that never escalate under strict_fields
escalate_unknown_scopeadminScope 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.

python
from praisonaiagents import Agent

agent = Agent(name="assistant", instructions="You are a helpful assistant.")
agent.start("hello")  # dispatch is gated automatically; nothing to configure

Plugin authors extending the dispatcher can call the gate directly.

python
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.
  • admin implies every scope.
  • read is the baseline lifecycle scope β€” any operator holding at least one actionable scope (write / approvals / pairing / admin) satisfies read, so a write-only operator can still complete hello / join / leave / session.status. An empty scope set cannot satisfy even read.
  • A malformed method (non-string / unhashable) fails closed to admin rather than raising TypeError.
  • Transport PING / PONG frames 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.

python
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)
Diagram

Method registry

πŸ“¦ Plugin

πŸ“ register_gateway_method

GATEWAY_METHODS

πŸ“ž Gateway call

πŸ”Ž resolve_required_scope

βœ… Required scope

Core method classification

Core methods are classified at import in praisonaiagents.gateway.protocols:

MethodRequired scope
hello, join, leaveread
agent.message, message, abort, message_abortwrite
session.status, session.transcript, session.resolveread
approvals.resolveapprovals
server_replywrite (escalates to approvals for approval-kind replies)
pairing.approve, pairing.revokepairing
channels.control, channels.pause, channels.resume, channels.reconnectadmin

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:

python
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:

python
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": {}}) # ADMIN

Strict-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:

python
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}) # ADMIN

Incomparable 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):

python
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-closed

API reference

SymbolMeaning
GatewayMethodDescriptorFrozen 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
GatewayUnauthorizedPermissionError 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.

bash
$ 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:

json
{ "error": "insufficient scope", "required_scope": "approvals" }

WebSocket message without write scope:

json
{
  "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.


Related