Hierarchy and Namespaces
SaaSLayer Permissions has a small, fixed platform hierarchy: organizations, workspaces and groups. Your product's own structure (sites, buildings, projects, folders, devices) lives in scopes and resource names that you choose. This page states what is fixed, what you can extend, and what is not supported today. Each rule names the code that enforces it in Where this is enforced.
The fixed platform hierarchy
| Container | Public API | Allowed parent | Notes |
|---|---|---|---|
| Organization | /orgs | none | Organizations never nest. |
| Workspace | /workspaces (ids tnt_…) | an organization (orgId is required) | Workspaces never nest. |
| Group | /groups | a workspace, an organization, another group, or none (a root group) | Groups nest, with a depth limit. |
These three container types are the whole platform hierarchy. You cannot add a fourth container type, put an
organization inside another organization, or put a workspace inside a workspace; the service refuses those edges.
Container types in the public API are ORG, WORKSPACE and GROUP.
Group nesting depth
A group can sit at most four levels below the top of its chain:
| Chain starts at | Group levels allowed | Example |
|---|---|---|
| Workspace | 3 | org → workspace → group → group → group |
| Organization | 4 | org → group → group → group → group |
| Root group | 5 (the root plus 4) | group → group → group → group → group |
A deeper group is refused with resulting depth 5 exceeds max depth 4 for GROUP. Cycles are also refused.
Moving things
- A group can move to a new parent with
PUT /groups/{id}/parent, inside the same organization. Moves across organizations are refused. - Workspaces and organizations cannot be moved. Not supported today.
Principals and membership
The principals that hold permissions are USER, AGENT and SERVICE_ACCOUNT. Organizations, workspaces, groups and
roles are containers or policy attachment points, never acting principals. A group cannot be a member of another
group; group nesting is structural only.
Membership follows the hierarchy:
- Joining a workspace requires membership in its organization.
- Joining a group requires membership in the nearest workspace above it, or its organization when there is no workspace. Joining a group does not make you a direct member of its parent groups.
Without that parent membership the request fails with PARENT_MEMBERSHIP_REQUIRED, unless the caller asks for the
parent membership to be created as well.
Membership alone grants nothing. Access comes from roles and policies (see Core Concepts).
All principal types share one permission domain: identity:principal (create, read, update, delete,
import) covers users, agents and service accounts alike. There is no per-type variant.
Where permissions apply
A role assignment carries one scope string, and a check is made at one scope. A check sees:
- global entries (scope
null), - when the scope is a typed scope
<kind>:<orgId>:<id>, the entries of the organization<orgId>, - the entries assigned at exactly that scope.
A deny from any of them wins.
| Assigned at | Applies to a check at |
|---|---|
null (global) | every scope |
org_acme | org_acme, and every typed scope <kind>:org_acme:<id> |
site:org_acme:site_1 | site:org_acme:site_1 only |
a workspace id, e.g. tnt_123 | tnt_123 only |
A workspace id is not a typed scope, so organization-wide assignments do not flow into a check made at a workspace scope. Check at the organization scope too when a workspace resource should honor organization-wide roles.
Policies attached to containers
| Policy attached to | Applies at |
|---|---|
| A workspace | that workspace's scope |
| A group placed directly in a workspace | that workspace's scope |
| An organization | the organization scope and its typed scopes when the deployment setting OrgScopedDocs is on; every scope when it is off (the default) |
| A group whose parent is an organization, another group, or nothing | every scope |
Attach scoped policies to workspaces or to groups placed directly in a workspace, or use roles assigned at an explicit scope.
Modeling your own hierarchy
Your product's structure belongs in scopes and resources, not in new platform containers.
Typed scopes for areas inside an organization
Use a typed scope <kind>:<orgId>:<id> for any area inside one organization, such as site:org_acme:site_1 or
building:org_acme:b-7. You choose kind. The second segment is always the owning organization, which is how the
service ties a grant to the organization being administered without knowing what a site is. Segments are non-empty
and contain no :, # or control characters. The SDK helpers are typedScope(kind, orgId, id),
siteScope(orgId, siteId) and parseTypedScope(scope).
Your backend is responsible for proving that <id> exists inside that organization before granting at it.
Typed scopes are one level deep. building:org_acme:b-7 does not inherit grants from site:org_acme:site_1, even if
building 7 is on site 1. If you need that, check each level your product cares about, or assign at the organization.
Resource names
A resource is a type plus an optional id, for example device or document/doc-123. The full form is an XRN:
xrn:<product>:<service>:<region>:<scope>:<resourceType>/<resourceId>
xrn:acme:core:*:org_acme:document/proj-1/doc-9The id is opaque to the service. Matching supports * and a trailing prefix wildcard per segment, so the pattern
xrn:acme:core:*:*:document/proj-1/* matches every document whose id starts with proj-1/. Prefix matching is string
matching, not a parent/child relationship.
Product, service and action names
Permissions are named <service>:<resource>:<action> for SaaSLayer's own services (for example
orgs:workspace:create) and <product>:<service>:<resource>:<action> for your product (for example
acme:core:device:read).
Reserved names:
| Name | Reserved as |
|---|---|
saaslayer | product |
scopes, register, registry | product names in catalog routes |
orgs, roles, policies, identity, keys, onboarding, permissions, audit, catalog, ops, users, deployer | service names, under any product |
Other rules:
- A service belongs to exactly one product. Registering a service another product owns is refused with
ServiceOwnedByOtherProduct. - A catalog manifest's service name matches `^[a-z0-9][a-z0-9-]{0,63}
SaaSLayer Permissions has a small, fixed platform hierarchy: organizations, workspaces and groups. Your product's own structure (sites, buildings, projects, folders, devices) lives in scopes and resource names that you choose. This page states what is fixed, what you can extend, and what is not supported today. Each rule names the code that enforces it in Where this is enforced.
The fixed platform hierarchy
| Container | Public API | Allowed parent | Notes |
|---|---|---|---|
| Organization | /orgs | none | Organizations never nest. |
| Workspace | /workspaces (ids tnt_…) | an organization (orgId is required) | Workspaces never nest. |
| Group | /groups | a workspace, an organization, another group, or none (a root group) | Groups nest, with a depth limit. |
These three container types are the whole platform hierarchy. You cannot add a fourth container type, put an
organization inside another organization, or put a workspace inside a workspace; the service refuses those edges.
Container types in the public API are ORG, WORKSPACE and GROUP.
Group nesting depth
A group can sit at most four levels below the top of its chain:
| Chain starts at | Group levels allowed | Example |
|---|---|---|
| Workspace | 3 | org → workspace → group → group → group |
| Organization | 4 | org → group → group → group → group |
| Root group | 5 (the root plus 4) | group → group → group → group → group |
A deeper group is refused with resulting depth 5 exceeds max depth 4 for GROUP. Cycles are also refused.
Moving things
- A group can move to a new parent with
PUT /groups/{id}/parent, inside the same organization. Moves across organizations are refused. - Workspaces and organizations cannot be moved. Not supported today.
Principals and membership
The principals that hold permissions are USER, AGENT and SERVICE_ACCOUNT. Organizations, workspaces, groups and
roles are containers or policy attachment points, never acting principals. A group cannot be a member of another
group; group nesting is structural only.
Membership follows the hierarchy:
- Joining a workspace requires membership in its organization.
- Joining a group requires membership in the nearest workspace above it, or its organization when there is no workspace. Joining a group does not make you a direct member of its parent groups.
Without that parent membership the request fails with PARENT_MEMBERSHIP_REQUIRED, unless the caller asks for the
parent membership to be created as well.
Membership alone grants nothing. Access comes from roles and policies (see Core Concepts).
All principal types share one permission domain: identity:principal (create, read, update, delete,
import) covers users, agents and service accounts alike. There is no per-type variant.
Where permissions apply
A role assignment carries one scope string, and a check is made at one scope. A check sees:
- global entries (scope
null), - when the scope is a typed scope
<kind>:<orgId>:<id>, the entries of the organization<orgId>, - the entries assigned at exactly that scope.
A deny from any of them wins.
| Assigned at | Applies to a check at |
|---|---|
null (global) | every scope |
org_acme | org_acme, and every typed scope <kind>:org_acme:<id> |
site:org_acme:site_1 | site:org_acme:site_1 only |
a workspace id, e.g. tnt_123 | tnt_123 only |
A workspace id is not a typed scope, so organization-wide assignments do not flow into a check made at a workspace scope. Check at the organization scope too when a workspace resource should honor organization-wide roles.
Policies attached to containers
| Policy attached to | Applies at |
|---|---|
| A workspace | that workspace's scope |
| A group placed directly in a workspace | that workspace's scope |
| An organization | the organization scope and its typed scopes when the deployment setting OrgScopedDocs is on; every scope when it is off (the default) |
| A group whose parent is an organization, another group, or nothing | every scope |
Attach scoped policies to workspaces or to groups placed directly in a workspace, or use roles assigned at an explicit scope.
Modeling your own hierarchy
Your product's structure belongs in scopes and resources, not in new platform containers.
Typed scopes for areas inside an organization
Use a typed scope <kind>:<orgId>:<id> for any area inside one organization, such as site:org_acme:site_1 or
building:org_acme:b-7. You choose kind. The second segment is always the owning organization, which is how the
service ties a grant to the organization being administered without knowing what a site is. Segments are non-empty
and contain no :, # or control characters. The SDK helpers are typedScope(kind, orgId, id),
siteScope(orgId, siteId) and parseTypedScope(scope).
Your backend is responsible for proving that <id> exists inside that organization before granting at it.
Typed scopes are one level deep. building:org_acme:b-7 does not inherit grants from site:org_acme:site_1, even if
building 7 is on site 1. If you need that, check each level your product cares about, or assign at the organization.
Resource names
A resource is a type plus an optional id, for example device or document/doc-123. The full form is an XRN:
xrn:<product>:<service>:<region>:<scope>:<resourceType>/<resourceId>
xrn:acme:core:*:org_acme:document/proj-1/doc-9The id is opaque to the service. Matching supports * and a trailing prefix wildcard per segment, so the pattern
xrn:acme:core:*:*:document/proj-1/* matches every document whose id starts with proj-1/. Prefix matching is string
matching, not a parent/child relationship.
Product, service and action names
Permissions are named <service>:<resource>:<action> for SaaSLayer's own services (for example
orgs:workspace:create) and <product>:<service>:<resource>:<action> for your product (for example
acme:core:device:read).
Reserved names:
| Name | Reserved as |
|---|---|
saaslayer | product |
scopes, register, registry | product names in catalog routes |
orgs, roles, policies, identity, keys, onboarding, permissions, audit, catalog, ops, users, deployer | service names, under any product |
Other rules:
- A service belongs to exactly one product. Registering a service another product owns is refused with
ServiceOwnedByOtherProduct. - A catalog manifest's service name matches , and its role ids are
<product>:<name>. - Catalog roles can be made grantable at the
account(organization) scope and thesitetyped scope only. Other typed kinds work with roles you assign directly, but not as cataloggrantableScopes.
Not supported today
| Request | Status |
|---|---|
| Extra platform levels (nested organizations or workspaces, a new container type) | Not supported. Model the level as a typed scope or a group. |
| Groups deeper than the limits above | Not supported. |
| Moving a workspace or an organization | Not supported. |
| Groups as members of other groups | Not supported. Nest groups structurally instead. |
| Inheritance between typed scopes (site → building) | Not supported. Check each level. |
| Bring-your-own hierarchy (customer-defined container types and parent rules) | Not supported. It would be built only against a concrete customer requirement. |
Cedar principal is User in Group::"admins" (typed hierarchy scopes) | Future work. The Cedar compiler parses it, but permission rebuilds do not apply the principal-type filter. |
Catalog grantableScopes beyond account and site | Not supported. |
Where this is enforced
Paths are relative to the SaaSLayer monorepo.
| Rule | Code |
|---|---|
| Container types, allowed parents, depth limit | services/permissions-service/src/hierarchy.js:13-32, :462-473 |
Public WORKSPACE name for the internal TENANT kind | services/permissions-service/api/workspace-contract.js:7-8 |
| Workspace and group creation inputs | services/permissions-service/api/workspaces.js:43-45, services/permissions-service/api/groups.js:43-57 |
| Group moves stay inside one organization | services/permissions-service/src/hierarchy-move.js:85-96 |
| Principal types, membership containers | services/permissions-service/src/principals.js:55-121 |
| Required parent membership | services/permissions-service/src/permissions-engine.js:8549-8600 |
| Scope evaluation (global, typed scope's org, exact scope) | packages/permissions-engine-core/src/permission-doc.js:285-296, packages/permissions-engine-core/src/scope-org.js:4-23 |
Where container policies apply; OrgScopedDocs | services/permissions-service/src/permissions-engine.js:1979-2000, services/permissions-service/saaslayer.module.js:31 |
| XRN format and matching | packages/permissions-engine-core/src/xrn.js:1-2, :90-142 |
| Reserved product and service names | services/permissions-service/src/builtin-services.js:4, :386-409, services/permissions-service/api/product-catalog.js:28 |
| Service ownership by one product | services/permissions-service/src/permissions-engine.js:9732-9733 |
| Catalog name rules and grantable scopes | services/permissions-service/src/product-catalog.js:10-12, :62 |
| Typed hierarchy Cedar scopes | services/permissions-service/src/cedar.js:364-379, :1052-1057 |
| Typed scope contract | services/permissions-service/docs/architecture/grant-scopes.md |