# Build a team hierarchy

**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](../reference/admin-api.md) for request and response contracts.

For the architecture and precise role/policy inheritance rules, read [policy-based authorization](../design/authorization.md). Scoped administrative grants are enabled by default; the stored tree request path remains opt-in.

All admin calls use Basic auth:

```bash
A="$POINTSMAN_ADMIN_USERNAME:$POINTSMAN_ADMIN_PASSWORD"
```

## 1. The organisation, and the scope it brings for free

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

```bash
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

```bash
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.

```bash
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 `ADMIN` may grant only `DEVELOPER` or
  `VIEWER` at an authorized target; granting `ADMIN` or `OWNER` is refused with 403.
  An `OWNER` may grant any role, including `OWNER`.
- **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

```bash
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

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

```bash
curl -s -u "$A" "localhost:8080/admin/scopes/$TEAM/limits/preview" | jq .binding
```

```json
[{"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](../design/authorization.md#failure-boundaries-and-rollout-checks).
