Skip to content

Apps With Spaces ​

For a team whose app has partitions that carry their own access, such as warehouses, projects or stores, or whose partitions are shared by several apps. This page covers both shapes with documents that run: one app with many spaces (per app) and one space used by several apps (shared). A space is an opaque string; the roles say where they apply. Without spaces, see An app without spaces.

What you need ​

Spaces
DocumentsCatalogs with spaced groups for what applies in every space; roles with a space when a role belongs to one
TokensOne token per app, with roles written role@space, issued only after checking the user belongs to the space
RequestsCoSec-Space-Id: <space> to work in a space; without it the request is in the default space
ConceptsThe default space "", scope, spaced, a role's space

How a space is chosen ​

The token lists the spaces its principal may use, as role@space. The client picks one with CoSec-Space-Id. A space the token does not list is refused (403 scope-rejected), never read as the default space. That is a choice, not a grant: what the principal may do in the space comes from the roles it holds there. Why this is safe to take from the client is in Trust boundaries.

The documents ​

The app has a catalog with two groups. Warehouse is not spaced: its permissions apply only in the default space, so they are operations of the whole company. Inbound is spaced: its permissions apply in any space a role holding them applies in. Two system roles grant them, and acme adds a role that belongs to one warehouse.

json
{
  "schema": 2,
  "revision": 1,
  "publishedAt": "2026-11-01T08:00:00Z",
  "catalog": {
    "groups": [
      {
        "name": "Warehouse",
        "permissions": [
          {
            "id": "warehouse.read",
            "name": "Read warehouses",
            "action": "wms:warehouse.read"
          },
          {
            "id": "warehouse.edit",
            "name": "Edit warehouses",
            "action": "wms:warehouse.edit"
          }
        ]
      },
      {
        "name": "Inbound",
        "spaced": true,
        "permissions": [
          {
            "id": "inbound.read",
            "name": "Read inbound orders",
            "action": "wms:inbound.read",
            "resource": "inbound/**"
          },
          {
            "id": "inbound.ship",
            "name": "Ship inbound orders",
            "action": "wms:inbound.ship",
            "resource": "inbound/{id}"
          }
        ]
      }
    ]
  },
  "roles": [
    {
      "id": "wms.clerk",
      "name": "Clerk",
      "permissions": [
        "warehouse.read",
        "inbound.read"
      ]
    },
    {
      "id": "wms.manager",
      "name": "Manager",
      "permissions": [
        "warehouse.read",
        "warehouse.edit",
        "inbound.read",
        "inbound.ship"
      ]
    }
  ]
}
json
{
  "schema": 2,
  "revision": 1,
  "publishedAt": "2026-11-01T08:00:00Z",
  "roles": [
    {
      "id": "wh-01-keeper",
      "app": "wms",
      "space": "wh-01",
      "name": "Keeper of warehouse 1",
      "permissions": [
        "inbound.read",
        "inbound.ship"
      ]
    }
  ]
}
A rolespaceApplies
wms.manager (system)noneIn any space the member holds it in, in every tenant
wh-01-keeper (custom)wh-01Only in wh-01, and only where the member holds it

A permission of a group that is not spaced never applies in a named space, so a role tied to wh-01 that lists one gets a warning when it is saved: it would grant nothing there.

One app, many spaces ​

The scenarios in wiki/examples/spaces run on every build:

Token rolesRequestResult
wms.manager@wh-01ship inbound order 42, space wh-01Permit
wms.manager@wh-01the same, space wh-02403 scope-rejected: the token does not list wh-02
wms.manager@wh-01read a warehouse, space wh-01Deny: warehouse.read is not spaced
wms.managerread a warehouse, no spacePermit
wms.managership inbound order 42, no spacePermit: a spaced permission applies in the default space too
wh-01-keeper@wh-01ship inbound order 42, space wh-01Permit
wh-01-keeper@wh-02ship inbound order 42, space wh-02Deny: the role belongs to wh-01 wherever it is held

The login issues what the user holds, for the app the token is for:

kotlin
TokenSubject(
    id = user.id, app = "wms", tenant = "acme",
    roles = setOf("wms.manager@wh-01", "wms.manager@wh-02", "wms.clerk"),
)

One space, several apps ​

The space store-1 is used by wms and by pos. Nothing about the space is declared: each app has its own roles, and a member holds them in the same space id. Each app has its own token, because a token is for one app:

json
{
  "schema": 2,
  "revision": 1,
  "publishedAt": "2026-11-01T08:00:00Z",
  "catalog": {
    "groups": [
      {
        "name": "Sales",
        "spaced": true,
        "permissions": [
          {
            "id": "sale.create",
            "name": "Ring up a sale",
            "action": "pos:sale.create"
          }
        ]
      }
    ]
  },
  "roles": [
    {
      "id": "pos.cashier",
      "name": "Cashier",
      "permissions": [
        "sale.create"
      ]
    }
  ]
}
Token (issued for)RolesRequestResult
wmswms.clerk@store-1read inbound order 42, space store-1Permit
wmswms.clerk@store-1ship inbound order 42, space store-1Deny: the clerk does not ship
pospos.cashier@store-1ring up a sale on pos, space store-1Permit
pospos.cashier@store-1any request to wms403 scope-rejected: the token is for pos

The permissions of each app are granted by its own roles, so pos.cashier gives nothing in wms, and wms.clerk nothing in pos. To grant one role in several apps, bind it in each app explicitly; that is a choice, not a default.

A user who works in both apps gets one token per app from the login, each with that app's roles. A client that holds both calls each service with the matching token.

If it fails ​

  • A space you hold is refused: the token lists it as role@space, with the role written exactly as the document's id (Roles and spaces).
  • Permissions work in the default space and not in a named one: the group is not spaced (Short-form permissions).
  • Roles in the token grow with the number of spaces: move the memberships to a MembershipProvider (Memberships).

Next: limit a machine client to one space, in A service account in one space.

Licensed under the Apache License, Version 2.0.