What you get at the end: an organisation with a team scope below it, a team admin who can manage that team while retaining tenant-wide read access, a key homed at the team, and a budget authored there - with the server telling you which rule binds, and an honest note on when it binds.
The examples use the administrative API to create scopes, delegate roles and attach a key. Replace names, IDs and credentials with your own values. See the admin API reference for request and response contracts.
For the architecture and precise role/policy inheritance rules, read policy-based authorization. Scoped administrative grants are enabled by default; the stored tree request path remains opt-in.
All admin calls use Basic auth:
A="$POINTSMAN_ADMIN_USERNAME:$POINTSMAN_ADMIN_PASSWORD"
1. The organisation, and the scope it brings for free#
ORG=$(curl -s -u "$A" -X POST localhost:8080/admin/organizations \
-H 'Content-Type: application/json' -d '{"name":"acme"}' | jq -r .id)
Creating an org also creates its TENANT scope in the tree - mirrored automatically, nothing to ask for. Find it by walking the tree from the root:
ROOT=$(curl -s -u "$A" localhost:8080/admin/scopes/root | jq -r .id)
TENANT=$(curl -s -u "$A" "localhost:8080/admin/scopes/$ROOT/tree" \
| jq -r '.children[] | select(.name=="acme") | .id')
2. Author the team scope#
TEAM=$(curl -s -u "$A" -X POST localhost:8080/admin/scopes \
-H 'Content-Type: application/json' \
-d "{\"parent_id\":\"$TENANT\",\"name\":\"ml-team\",\"slug\":\"ml-team\"}" | jq -r .id)
A row, not a release. You did not say what kind of scope this is - position decides (below a
tenant means GROUP), and the door refuses rather than guesses everything else: an unknown
parent, a sibling slug collision, and depth beyond the configured maximum
(pointsman.scopes.max-depth-below-tenant, default 2 - the refusal names the setting, and
past three levels a warning rides the response even when it succeeds). Creating scopes needs
OWNER at the parent: handing out structure is delegation, not configuration.
3. A team admin, granted at the team#
Create an explicit tenant VIEWER account for the team lead. Do not omit role: the
account-creation API defaults an omitted role to global ADMIN. This starting role
projects a read-only grant across the tenant; the team grant below adds management
authority only within the team.
LEAD=$(curl -s -u "$A" -X POST localhost:8080/admin/users \
-H 'Content-Type: application/json' \
-d "{\"username\":\"ml-lead\",\"password\":\"change-me-234567\",\"role\":\"VIEWER\",\"org_id\":\"$ORG\"}" \
| jq -r .id)
curl -s -u "$A" -X POST "localhost:8080/admin/scopes/$TEAM/grants" \
-H 'Content-Type: application/json' \
-d "{\"principal_id\":\"$LEAD\",\"role\":\"ADMIN\"}"
201, and the grant row names who granted it. The lead now holds ADMIN at ml-team and
everywhere below it. That grant adds no management authority above or beside the team.
The account's separate tenant VIEWER grant still permits tenant-wide reads; inherited
authority is additive. This recipe therefore does not create a team-only visibility boundary.
Two delegation boundaries to check:
- Granting above your own strength. An
ADMINmay grant onlyDEVELOPERorVIEWERat an authorized target; grantingADMINorOWNERis refused with 403. AnOWNERmay grant any role, includingOWNER. - Granting across the tenant wall. Granting a platform account a role inside a tenant's subtree gets a 400 spelling out the geography - platform staff act through grants at the root; tenant members are granted inside their tenant.
4. A key homed at the team#
MINT=$(curl -s -u "$A" -X POST "localhost:8080/admin/organizations/$ORG/keys" \
-H 'Content-Type: application/json' \
-d "{\"label\":\"ml-team-key\",\"kind\":\"service\",\"scope_id\":\"$TEAM\"}")
KEY=$(echo "$MINT" | jq -r .key)
echo "$MINT" | jq .scope_id # the response disclosing the home
Leave scope_id out and the key homes at the org's mirror scope instead - the old model's
position, deliberately not an error. Name a scope and the door validates it hard: the
platform root is refused by name, and a scope that does not exist or belongs to another
tenant's subtree answers the same 404 unknown scope, so the door confirms nothing about
trees that are not yours.
5. A budget at the team, and who binds#
curl -s -u "$A" -X POST "localhost:8080/admin/scopes/$TEAM/rules" \
-H 'Content-Type: application/json' \
-d '{"amount":"requests","budget":3,
"window":{"kind":"rolling","count":1,"unit":"minute"},
"action":"block"}'
Then ask the server which rule actually governs this scope, rather than working it out:
curl -s -u "$A" "localhost:8080/admin/scopes/$TEAM/limits/preview" | jq .binding
[{"amount": "REQUESTS", "scope_id": "...", "rule_id": "...",
"value": "3", "scope_name": "ml-team"}]
The full response lists every level from your scope to the root with each level's rules;
binding names, per amount, the one that wins (every level is checked independently and the
minimum decides). Add a looser org-level rule and the binding entry stays on ml-team;
tighten the org below the team and it moves. You never have to deduce it.
6. When does it bind - the honest note#
Today, with the shipped defaults, that team budget binds in the preview and not on live
traffic: requests walk a path derived from the fixed org/app/user/key chain, and
pointsman.datapath.tree.enabled (default false) is what would put the key's stored home on
the walked path. In a flag-on window the same setup refuses the fourth request with a 429
from exactly this rule - scripts/demo-deep-key.sh runs that end to end, verifies the flag
actually reached the process before claiming anything, and restores the default afterwards.
So what you have built is real and inert by one flag: structure authored ahead of need, a team admin with the stated tenant read access, a key with its stored home, and a budget whose binding you can already watch. Why the flag is off, and what evidence flips it: authorization defaults and rollout boundaries.