# Policy-based authorization: scopes, grants and inheritance

Pointsman separates **who can administer a resource** from **which models and limits apply to an application request**. Both use scopes, but they have different decisions and different defaults. Authentication establishes identity first; a valid login or virtual key does not bypass authorization.

> **Defaults:** scoped grants decide administrative authorization. Application requests use the fixed organization → application → user → key policy path. The stored scope-tree request path is separately opt-in; creating a team, adding an admin grant or viewing a preview does not enable it.

## Architecture and responsibilities

![Authorization architecture: authenticated administrators use scoped grants; authenticated application requests select the default fixed path or optional stored tree, then compose model permissions and enforce limits](authorization-architecture.svg)

| Component | Question it answers | Authority |
|---|---|---|
| Authentication | Who is calling, and is their credential valid? | Admin login/session or virtual-key identity resolution |
| Scoped authorization | May this administrator perform this action at this target? | Active principal, applicable grants, target ancestry and required role |
| Request policy composition | Which models may this credential use? | Effective allow-list from the selected request path, then registry lifecycle/capability checks |
| Limit evaluation | May this request consume more requests, tokens, spend or concurrency? | Matching limit rules and the counter/reservation machinery |
| Python policy service | What routing parameters have been learned from outcomes? | Background learner; it does not decide scope grants or administrator access |

The authorization core and policy composer run inside the Java gateway. PostgreSQL stores principals, scopes, grants and policy values. Request policy snapshots and cached identity bundles reduce database work; administrative writes trigger the appropriate snapshot refresh/invalidation. There is no remote authorization-service call to the Python learner.

## What is enabled by default

These are the application defaults when the deployment does not override them. Inspect the effective process configuration before relying on an environment-file comment.

| Setting | Default | Effect |
|---|---|---|
| `POINTSMAN_AUTHZ_CORE_DECIDES` / `pointsman.authz.core.decides` | `true` | Existing administrative gates use the scoped core when their principal and target can be resolved; compatibility fallback is described below |
| `POINTSMAN_SHADOW_AUTHZ_ENABLED` / `pointsman.shadow.authz.enabled` | `true` | When core deciding is disabled, compare its answer with the old gate without changing that gate's result |
| `POINTSMAN_DATAPATH_TREE_ENABLED` / `pointsman.datapath.tree.enabled` | `false` | Application requests use the fixed chain; setting it true selects the credential's stored scope path and tree model composition, subject to fallback |
| `POINTSMAN_SHADOW_ALLOW_ENABLED` / `pointsman.shadow.allow.enabled` | `false` | Optional comparison of fixed-chain and tree allow sets; comparison alone never enables enforcement |

The native `/admin/scopes` endpoints authorize directly against principals and grants. Turning off `AUTHZ_CORE_DECIDES` changes the compatibility seam for existing administrative gates; it does not turn native scope endpoints into legacy role checks. Turning off shadow comparison does not disable authorization.

## The model: identities live outside the scope tree

A **principal** is an actor with an ID, tenant association and status. Current administrative accounts project into principals using the same account ID. A **credential** is an API key, with its own identity, expiry and scope home. Application user IDs and administrative principal IDs are not interchangeable.

A **scope** is a place to attach authority and controls. Its kinds are structural:

```text
PLATFORM
└── TENANT: Acme
    ├── GROUP: ML team
    │   └── GROUP: Search project
    └── GROUP: Support team
```

The platform root is unique; a tenant sits directly below it. Below a tenant, authored nodes are groups. Labels such as team, project or environment do not introduce new authorization roles. Principals can hold grants at several scopes; they are not extra containment nodes. Credentials point to a home, rather than adding a fourth tree level.

The scope creation API infers kind from the parent. It requires `OWNER` at that parent, rejects duplicate sibling slugs and unknown parents, and defaults to at most **two levels below a tenant** through `pointsman.scopes.max-depth-below-tenant`. The limit is configurable; responses warn beyond three levels. Current scope ancestry is immutable: there is no supported reparent operation.

Organizations and applications retain their existing APIs and mirror into tenant and group scopes. Account roles and workspace memberships project into grants. For example, a platform `ADMIN` account projects to `OWNER` at the root; a `TENANT_ADMIN` projects to `ADMIN` at its tenant; workspace administrator/developer membership projects at the application's scope. These legacy account labels are distinct from the four core grant roles.

## Grants: authority flows down and combines by strength

A grant associates `principal_id`, `scope_id`, `role` and optional `expires_at`. Stored records also retain their author, creation time and revocation. Roles have this explicit order:

**`VIEWER < DEVELOPER < ADMIN < OWNER`**

Each administrative operation declares its required role. For native scope APIs, reading a path, grants, policy or preview requires `VIEWER`; writing policies or limit rules requires `ADMIN`; creating child structure requires `OWNER`. Grant delegation has its own rules below. These roles do not grant inference models by themselves.

For an ordinary scoped permission check, the core:

1. Denies a principal whose status is not `ACTIVE`.
2. Enforces the tenant boundary. Crossing it requires a live grant at the platform root; the required-role check still applies.
3. Considers only this principal's unrevoked, unexpired grants at the target or an ancestor. A grant expiring exactly at the decision time is expired.
4. Takes the strongest applicable role. No applicable grant means denial.
5. Allows only if that role meets the operation's required role.

A grant at ML team reaches Search project, but not Support team, Acme itself or another tenant. An `ADMIN` grant at ML team plus a `VIEWER` grant at Search project still yields `ADMIN` at Search project. A weaker child grant cannot remove inherited power; there is no negative or deny grant. Revoke or reduce the relevant ancestor authority to remove it. Removing one grant does not remove other applicable grants, including projected memberships.

### Delegation and revocation

Delegation depends on the actor's strongest applicable role at the target. The actor must also be active and have authority on that target's ancestry.

| Actor role | Roles they may grant |
|---|---|
| `VIEWER` | None |
| `DEVELOPER` | `VIEWER` |
| `ADMIN` | `DEVELOPER`, `VIEWER` |
| `OWNER` | Any role, including `OWNER` |

Revocation normally requires the standing to grant that role. An actor may also revoke a grant they authored if their current applicable role is **at least** that grant's role. Authorship alone is insufficient after losing the necessary authority. The API checks tenant association when creating grants: tenant principals receive grants within their tenant; platform staff act through root grants. Revocation marks the stored grant rather than deleting its audit history.

## Policies: model permissions only narrow

![Inheritance comparison: role grants use the strongest ancestor role, while model policies intersect along the path; a weaker child role cannot downgrade an ancestor and a child model policy cannot widen it](authorization-inheritance.svg)

The generic policy-value vocabulary currently contains one key, **`allowed_models`**, with the **`INTERSECT`** operator. It is not an arbitrary expression language. Budgets, rate limits and guardrails have their own rule/configuration machinery; they are not additional implemented keys in this table.

A policy value has an attachment scope, subject kind, optional subject ID, key and non-empty `value` list:

| Subject kind | Stored ID | Meaning and current boundary |
|---|---|---|
| `SCOPE` | Omit `subject_id` | Applies at this scope and descendants through composition |
| `PRINCIPAL` | `principals.id` | Stored and previewable for that administrative principal; not consumed as a general principal override by the live request composer |
| `CREDENTIAL` | `api_keys.id` | Credential-specific restriction; the live tree composer takes the first matching credential row on the root-to-home path |

The write API validates subject IDs, key vocabulary and non-empty, non-blank values. A missing row means **no additional restriction**, not deny-all. An explicit empty policy list is rejected. However, the **result** of intersecting restrictions can be empty, in which case no model is permitted. Existing explicit-empty legacy user/key contributions also remain deny-all, rather than becoming unrestricted during composition.

For example, model restrictions narrow at each level:

| Scope | Authored policy | Effective models |
|---|---|---|
| Tenant | `{model-a, model-b, model-c}` | `{model-a, model-b, model-c}` |
| Team | `{model-a, model-b}` | `{model-a, model-b}` |
| Project | `{model-b}` | `{model-b}` |

Omitting the team row leaves tenant/project restrictions intact. A child cannot add `model-c` back. Creation rejects entries excluded by the relevant scope composition; a later ancestor change can still make descendants incompatible. Review descendants when tightening an ancestor.

All levels silent means unrestricted **by this allow-list**, not unrestricted access to every provider. Registry lifecycle, successor rules, capabilities, credential validity, budgets and guardrails still constrain execution. Both request paths use the same registry post-processing after obtaining the raw allow set.

### Preview versus live composition

`GET /admin/scopes/{id}/policy/preview` composes scope rows root-first and optionally all rows for the requested principal or credential along that path. It returns `unrestricted`, `value`, `conflict_empty` and per-level provenance: the contributed value, entries removed and whether that level removed entries (`binding`). The first restriction can constrain the result without removing entries from a prior finite set, so `binding=false` does not necessarily mean irrelevant.

`enforced_on_request_path` reports **only the tree-path feature flag**. It is not a per-request receipt, a fallback check or a guarantee that every subject preview matches live traffic. In the current live composer:

- Scope rows along the selected path participate.
- Only the **first** matching credential row in root-first order participates; the subject preview can compose several. Do not rely on stacked credential rows at multiple ancestors as cumulative live restrictions.
- The application user's legacy allow-list is folded in from the identity bundle. Arbitrary stored `PRINCIPAL` rows are not loaded into live request composition. Do not use that preview as proof of an enforced user restriction.

These are implementation boundaries even with the tree flag enabled. For current per-user enforcement, use the supported user allow-list workflow. For inherited team restrictions on the optional tree path, use scope policies and verify actual key behavior as well as previews.

## Fixed four-level path versus stored scope path

| Aspect | Fixed path — default | Stored tree path — opt-in |
|---|---|---|
| Identity | Authenticated organization/application/user/key bundle | Same authenticated bundle, plus the credential's stored scope ancestry |
| Ancestry used by limit rules | Derived platform/organization/application scopes; user/key rules use subject conditions | Stored home to root, so authored team/project ancestors can participate |
| Raw model permissions | Intersect explicit organization, application, user and key allow-lists; missing levels inherit | Scope policy rows, first matching credential policy and computed legacy user contribution |
| A key homed in a new team | Stored home is retained, but that team's inherited controls do not become live merely by creating it | The home/ancestors participate when resolved and the tree composition succeeds |
| Administrative grants | Scoped core remains enabled independently | Same administrative authorization; the data-path switch does not change grant strength |

A service key can omit a user or application; the fixed four-level model is not a requirement that every request have all four objects. Omitting `scope_id` when issuing a key chooses its application/organization mirror. An explicit authored home is validated against the issuing tenant/application boundary. Key-owned restrictions remain distinct from restrictions inherited at a deeper home; a dormant team policy must not be confused with the key's own limits.

The optional tree path resolves ancestry when building the identity bundle and carries it with cached identity state. It does not query the scope database at every admission step. Policy values are read from an in-memory snapshot. This keeps request work bounded, but requires correct projection, refresh and identity invalidation; it is not an instantaneous globally consistent policy engine.

## Administrative API workflow

Use [team hierarchy](../how-to/build-a-team-hierarchy.md) for organization/account/key setup and [admin API reference](../reference/admin-api.md) for surrounding endpoints. These examples show request bodies; replace model names and IDs with values from your deployment. Admin authentication and CSRF/origin requirements still apply.

### 1. Create a group

Use `POST /admin/scopes` (requires `OWNER` at its parent):

```json
{"parent_id":"<tenant-scope-id>","name":"ML team","slug":"ml-team"}
```

### 2. Delegate access

Use `POST /admin/scopes/{teamScopeId}/grants`:

```json
{"principal_id":"<admin-account-id>","role":"ADMIN"}
```

Omit `expires_at` for no expiry, or supply an ISO-8601 instant. An existing unrevoked grant for the same principal and scope is a conflict, even if expired; changing that grant requires revoke then grant. List grants with `GET /admin/scopes/{id}/grants`; revoke through `DELETE /admin/scopes/grants/{grantId}`.

### 3. Restrict models

Use `POST /admin/scopes/{teamScopeId}/policy` (requires `ADMIN`):

```json
{"subject_kind":"SCOPE","key":"allowed_models","value":["model-a","model-b"]}
```

The field is **`value`**, not `entries`. Omit `subject_id` for `SCOPE`. A credential row uses `subject_kind: "CREDENTIAL"` and its API-key ID; a principal row uses `"PRINCIPAL"` and its principal ID, subject to the live limitations above.

### 4. Preview and verify

Read scope policy with `GET /admin/scopes/{id}/policy`. Preview scope composition at `GET /admin/scopes/{id}/policy/preview?key=allowed_models`; optionally add both `subject_kind=CREDENTIAL` and `subject_id=<key-id>` (or `PRINCIPAL` and its ID). There is no policy `PUT`: a duplicate scope/subject/key returns `409`; replacement requires delete then create. Those two calls are not atomic, so removing a restriction can temporarily broaden access. Plan changes accordingly rather than treating replacement as a single transaction.

## Failure boundaries and rollout checks

| Condition | Current behavior |
|---|---|
| Inactive principal, expired grants, insufficient role or wrong tenant | Core authorization denies |
| Native scope endpoint cannot find a projected principal | Denies; no legacy fallback on that surface |
| Existing admin gate cannot compare because a principal/scope is missing, or its core evaluation errors | Compatibility seam uses the old gate's answer and records fallback; do not describe this as universal fail-closed core authorization |
| Tree path has no resolved home | Admission falls back to the derived fixed path and records `no_resolved_path` |
| Tree allow composition finds a projection gap or throws | Admission falls back to the fixed allow calculator and records `policy_gap` or `error`; authored-only restrictions are not guaranteed during that fallback |
| Effective model set is empty | Admission denies; it does not widen to the catalog |

Before opting into the tree request path, inspect the credential's actual ancestry, projected rows and refresh state. Check scope and subject previews, then validate allowed and denied requests for representative keys in an isolated environment. Include cross-tenant access, ancestor changes, expiry, revoke/delegation, key-owned limits and fallback behavior. Monitor the `pointsman.authz.core.fallback`, `pointsman.datapath.tree.fallback` and comparison counters; a healthy process alone does not prove the intended policy decided.

The [qualified release scope](../release-status.md) does not turn every optional tree/subject configuration into a qualified deployment. This guide documents current behavior and its limits; it changes no runtime flags or acceptance thresholds.

Implementation anchors: gateway `core/authz/Authz`, `core/grant/Role`, `core/scope/ScopePath`, `core/store/CoreProjector`, `core/policy/PolicyComposer`, `core/shadow/ShadowAuthz`, `identity/AllowSetShadow`, `pipeline/RequestAdmission` and `api/admin/ScopeAdminController`. Continue with [authentication](authentication.md), [rate limiting](rate-limiting.md), [security](../security.md) and the separate [routing policy service](policy-service.md).
