Fine-Grained Tokens

Fine-Grained Tokens

Restrict projects, client and tunnel discovery, tunnel creation, and connections.


Fine-grained tokens let a backend issue credentials that are limited to specific tunnel projects and specific tunnel operations. They are designed for delegation: a trusted backend keeps its personal access token or application credential, then gives a short-lived, narrower token to a browser, device, worker, or tenant service.

Resource Boundaries

Resource boundaries restrict runtime access to project-scoped resources. Today the public contract has one runtime resource family, resources.tunnels, which is shared by tunnel operations, lightweight WebTTY tunnels, registered WebTTY runtime sessions, and TURN relay authorization. These boundaries do not hide workspaces, projects, credentials, managed WebTTY server records, or billing objects from hosted APIs. Hosted API access is controlled by API permissions, documented in Tokens.

A token with no resources field can use every tunnel project available to its owner, subject to its runtime permissions. A token with resources.tunnels can only use matching projects. The nested resources.tunnels value can target workspaces, target projects, or apply globally when no target field is present.

The signed token and API shape is always:

{
  "resources": {
    "tunnels": {
      "projects": ["project-id"]
    }
  }
}

The examples below focus on the nested resources.tunnels object:

{
  "OR": [
    {
      "workspaces": ["workspace-id"]
    },
    {
      "projects": ["project-id"],
      "scopes": {
        "tunnels": {
          "list": true
        }
      }
    },
    {
      "scopes": {
        "tunnels": {
          "connect": {
            "params": {
              "path": {
                "regex": "^/api"
              }
            }
          }
        }
      }
    }
  ]
}

The Dashboard exposes four resource-boundary modes for stored credentials: all projects, selected workspaces, selected projects, and advanced JSON. The first three modes cover the common cases. Advanced JSON edits the nested resources.tunnels value and is used when boundaries need operation-level scopes or different rules per project.

AND and OR are explicit object operators. A resource object can be a leaf (workspaces, projects, scopes) or a logical branch (AND or OR). resources.tunnels is always encoded as one canonical JSON object; array roots are invalid.

Resource boundaries are enforced by managed engines. Community Edition deployments do not enforce this model.

Managed WebTTY adds durable Control plane objects, but runtime narrowing still happens at the project boundary. A token that can administer registered WebTTY servers through the Control plane still needs the matching project/runtime boundary when it connects to the engine, joins a live session, or exports recording events.

Control plane permission
  can the token call the registered-server or recording API?
        |
        v
resources.tunnels boundary
  which project/runtime surface can the token use?
        |
        v
Engine permission
  can the token create, connect, attach, control, or export?

Scopes

scopes.tunnels has three actions: create, connect, and list. The new scopes.clients category has one action, list. Adding scopes.clients extends the authorization JSON format; it is not a query filter or a new top-level permission. resources.tunnels remains the common runtime resource family. At least one category is required when scopes is present, and each category must include an action.

Availability: client listing scopes require a compatible managed Engine. During the coordinated rollout, hosted credential and token issuance rejects these scopes until support is enabled. Keep existing credentials unchanged until your environment supports them. Community Edition does not enforce these authorization scopes.

Each action accepts true, false, or a restriction object. Both listing actions support the same filters and select structure. Operation-level scopes apply to Basic, Pro, and Enterprise managed projects.

tunnels.create.filters restricts tunnel properties at creation time. If a filter forces an exact property or label, the engine fills an omitted value when possible and rejects incompatible explicit values. Across OR alternatives, only exact values common to every applicable, allowed creation branch are defaults. A branch allowing any value prevents that default; different values, regular expressions and multi-value choices do not select an arbitrary default. Final authorization and project settings still apply. The correction for resource-level OR alternatives requires the updated Engine. tunnels.connect.filters restricts which existing tunnels can be reached. tunnels.connect.params.path restricts accepted HTTP request paths for HTTP tunnels. tunnels.list.select limits the fields returned during tunnel discovery.

{
  "projects": ["project-id"],
  "scopes": {
    "tunnels": {
      "create": {
        "filters": {
          "protocol": "http",
          "publish": true,
          "token_auth": true
        }
      },
      "connect": {
        "params": {
          "path": {
            "regex": "^/api"
          }
        }
      },
      "list": {
        "select": {
          "id": true,
          "name": true,
          "protocol": true
        }
      }
    }
  }
}

String filters accept exact strings, { "exact": "value" }, { "oneof": ["a", "b"] }, and { "regex": "^/api" }. Filter trees also support nonempty AND and OR arrays. Label filters use the same matcher per label key.

Client and tunnel listing

The Engine permission tunnels.resources.read-only grants access to runtime inventory. The two scopes narrow that permission independently:

Scope valueResult
trueList the category within the authorized project/workspace boundary.
falseDirect list endpoint returns HTTP 403; watch contains no objects or lifecycle events for that category.
{ "filters": { ... }, "select": { ... } }Return only matching objects and selected fields.

For compatibility, omitting scopes.clients preserves existing client visibility within the resource boundary. Tunnel list filters never implicitly filter clients. When a scopes object exists, omitted tunnel actions remain denied. A clients-only scope therefore does not grant tunnel listing, creation or connections.

List authorized production tunnels and WebTTY servers while refusing client listing:

{
  "projects": ["project-id"],
  "scopes": {
    "clients": { "list": false },
    "tunnels": {
      "list": { "filters": { "labels": { "env": "prod" } } }
    }
  }
}

The inverse is also supported:

{
  "projects": ["project-id"],
  "scopes": {
    "clients": {
      "list": {
        "filters": { "labels": { "env": "prod" } },
        "select": { "id": true, "status": true, "agent": true }
      }
    },
    "tunnels": { "list": false }
  }
}

Both use permissions: ["tunnels.resources.read-only"]. A watch remains available when either category is allowed; both denied means HTTP 403. rstream ui can display tunnels and discover WebTTY servers without a client inventory. Its empty category shows a neutral “No visible clients” or “No visible tunnels” message; an empty list alone does not prove permission denial.

Client filters cover identity, status, project/workspace, endpoint, cluster, plan, provider, region, agent metadata and labels. Tunnel filters cover the tunnel model. String and label matchers have the same syntax for both categories. Request-side params.filters only narrow an already authorized result and are never an authorization boundary.

select is an allow-list: omitted or false fields are absent, including id when not selected. An empty selection returns empty objects. Credential and token selections intersect; matching OR alternatives contribute the union of their allowed fields. Nonmatching alternatives contribute nothing. Event envelope ownership fields follow the same selection.

SDK inventory responses may contain partial objects. Check optional fields before using them. Include id for addressable UI rows; include the tunnel protocol, connection address and required discovery labels when a consumer must discover or open WebTTY servers. Listing a server does not itself grant connection, terminal control or recording access.

Effective restrictions

Resource boundaries can exist in two places. Stored credentials can have resources.tunnels in the database. Short-lived auth or application tokens can also carry resources.tunnels in their signed claims.

When both are present, the engine applies both. A token derived from a restricted credential cannot escape the credential boundary. Project settings are also applied as runtime bounds, so the effective rule is the intersection of stored credential boundaries, token boundaries, and project settings.

For example, if a project requires public tunnels to use token auth or rstream Auth, a token cannot create an unauthenticated public tunnel. If a token limits HTTP paths to ^/api, requests outside that path are denied even if the tunnel itself is public.

Remote device example

A common backend flow is to issue a short-lived application token to a remote device. The device only needs to create a tunnel for one project. It does not need to read account resources, manage credentials, or open connections back to other tunnels.

import { createClientCredentialsToken } from "@rstreamlabs/rstream";
 
const { token } = createClientCredentialsToken(
  {
    clientId: process.env.RSTREAM_CLIENT_ID!,
    clientSecret: process.env.RSTREAM_CLIENT_SECRET!,
  },
  {
    expiresInSeconds: 60,
    claims: {
      permissions: ["tunnels.tunnels.create-delete"],
      resources: {
        tunnels: {
          projects: ["project-id"],
          scopes: {
            tunnels: {
              create: {
                filters: {
                  protocol: "http",
                  publish: true,
                },
              },
            },
          },
        },
      },
    },
  },
);

The resulting token can create matching HTTP tunnels for project-id. It cannot list projects through the hosted API unless API permissions are also granted, and it cannot connect to tunnels because no tunnels.streams.create-delete permission is present.

Typical backend resources

Create one tunnel

Use this when a device or worker should publish exactly one HTTP tunnel shape. The token can create matching tunnels only; it cannot list tunnels or connect to existing tunnels unless those API permissions and scopes are also present.

{
  "projects": ["project-id"],
  "scopes": {
    "tunnels": {
      "create": {
        "filters": {
          "name": { "exact": "device-123" },
          "protocol": "http",
          "publish": true,
          "token_auth": true,
          "labels": {
            "app": "video-platform",
            "device": "device-123",
            "user": "user-456"
          }
        }
      }
    }
  }
}

Connect to selected tunnels

Use this for browser viewers, tenant-scoped clients, or service consumers. The path restriction is checked on HTTP requests that enter the protected published tunnel.

{
  "projects": ["project-id"],
  "scopes": {
    "tunnels": {
      "connect": {
        "filters": {
          "status": "online",
          "protocol": "http",
          "publish": true,
          "labels": {
            "app": "video-platform",
            "device": "device-123",
            "user": "user-456"
          }
        },
        "params": {
          "path": { "regex": "^/ws$" }
        }
      }
    }
  }
}

Watch selected tunnels

Use this when a backend or dashboard needs live state without broad tunnel access. select limits the returned fields.

{
  "projects": ["project-id"],
  "scopes": {
    "tunnels": {
      "list": {
        "filters": {
          "protocol": "http",
          "publish": true,
          "labels": {
            "app": "video-platform",
            "user": "user-456"
          }
        },
        "select": {
          "id": true,
          "status": true,
          "name": true,
          "hostname": true,
          "labels": true
        }
      }
    }
  }
}

Combine target and operation rules

Use AND when every condition must be true. Use OR when any branch may allow access. This example allows listing tunnels in one workspace and connecting only to a narrow HTTP path in one project.

{
  "OR": [
    {
      "workspaces": ["workspace-id"],
      "scopes": {
        "tunnels": {
          "list": true
        }
      }
    },
    {
      "AND": [
        { "projects": ["project-id"] },
        {
          "scopes": {
            "tunnels": {
              "connect": {
                "params": {
                  "path": { "regex": "^/tenant-a/" }
                }
              }
            }
          }
        }
      ]
    }
  ]
}

API example

The Control plane API can mint a short-lived auth token with the same model. Passing permissions narrows API and tunnel permissions. Passing resources.tunnels narrows the tunnel projects and runtime scope.

curl -X POST "https://rstream.io/api/tokens" \
  -H "Authorization: Bearer <token-with-api.tokens.create>" \
  -H "Content-Type: application/json" \
  -d '{
    "permissions": ["tunnels.tunnels.create-delete"],
    "resources": {
      "tunnels": {
        "projects": ["project-id"],
        "scopes": {
          "tunnels": {
            "create": {
              "filters": {
                "protocol": "http",
                "publish": true,
                "token_auth": true
              }
            }
          }
        }
      }
    }
  }'

Use short expirations for delegated tokens. A one-minute token is usually enough for browser bootstrap, device handoff, and other flows where a backend remains responsible for authorization.