> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ontime.hosai.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Permissions and scope

> The two-layer authorization model, and how to tell a permission problem from a scope problem from an empty result.

Authorization in OnTime is two checks, not one:

```mermaid theme={null}
flowchart LR
  R[Request] --> P{Permission key?}
  P -->|no| F1[403 forbidden]
  P -->|yes| S{Subject in scope?}
  S -->|no| F2[403 forbidden]
  S -->|subject unknown| F3[404 not_found]
  S -->|yes| OK[Proceed]
```

**Permission** answers *can you do this kind of thing*. **Scope** answers *to whom*. Both must pass.

## Permission keys

53 keys, in `resource:action` form — `leave:approve`, `attendance:write`, `payroll:read`. A route declares the key it needs; the credential either carries it or does not.

For an API key, the permission set comes from its scope, and today that is always `employee:read` plus `attendance:read`. For a session or an MCP-bound key, it comes from the employee's role.

The full catalog with the four presets is in [Roles & permissions](/help/concepts/roles-and-permissions).

## Actor scope

Scope has exactly three levels. There is no fourth.

<AccordionGroup>
  <Accordion title="self" icon="user">
    Acting on your own record. Enabled per surface — reads generally allow it, approvals never do. A request cannot be approved by whoever filed it.
  </Accordion>

  <Accordion title="team-direct" icon="users">
    The subject's **direct** manager. Not the transitive subtree — one link only.

    Always overridable: a holder of `hr:approve` clears `team-direct` for every employee in the organization.
  </Accordion>

  <Accordion title="org" icon="building">
    No per-employee narrowing at all. What `hr:approve` grants, and what administrative keys imply. It is the *absence* of scope, never a per-employee decision.
  </Accordion>
</AccordionGroup>

<Warning>
  **There is no "subtree" scope.** Skip-level authority is not automatic: a manager two levels above someone has no authority over them. That case is expressed as `org` via `hr:approve`, not as a deeper walk down the org chart.

  A transitive walk *does* exist as a **list filter** — the employee list's "reports to" parameter — but it only narrows rows you are already entitled to see. It never widens a guard.
</Warning>

## Lists agree with detail pages

A list query is filtered with the same scope rule that guards the detail endpoint each row links to.

This is a guarantee you can build on: **every row a credential can list, it can also fetch.** You will not enumerate a queue and then get `403` on half of it.

<Note>
  It also means an empty list is not necessarily an empty dataset — it may be a dataset entirely outside your scope. See below.
</Note>

## Telling the three cases apart

This is the practically important part for an integration:

| You see                  | It means                                                                                                   | Do                                              |
| ------------------------ | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `403 forbidden`          | Your credential lacks the permission key, **or** the subject is outside your scope.                        | Fix the credential or the subject. Never retry. |
| `403 stage_permission`   | The request is at an approval stage needing a key you lack — usually `hr:approve`.                         | Route it to someone who holds it.               |
| `404 not_found`          | The record does not exist, **or** belongs to another organization, **or** the subject employee is unknown. | Do not treat as malformed input.                |
| `200` with an empty list | The query succeeded and nothing matched — possibly because everything is outside your scope.               | Distinguish deliberately; see below.            |

<Warning>
  **An empty list and "no permission" are not distinguishable from the response alone.** A manager listing approvals sees only their direct reports' requests; if they have none, the result is `200` with zero items — identical to an organization with no pending requests.

  If your integration needs to know which case it is in, check the credential's expected scope explicitly rather than inferring it from an empty array.
</Warning>

## Designing an integration around this

<Steps>
  <Step title="Prefer org-scoped credentials for reporting">
    An API key resolves to organization-wide read access on the public endpoints. It is not narrowed by anyone's org-chart position, which is what you want for an export job.
  </Step>

  <Step title="Use MCP when you need a person's scope">
    An MCP key is bound to an employee and acts with exactly their permissions **and** their scope. That is the right model for an assistant acting on somebody's behalf — and the wrong model for a nightly export.
  </Step>

  <Step title="Never paper over a 403 with a retry">
    Scope and permission failures are deterministic. Retrying produces the same answer and burns your rate limit.
  </Step>
</Steps>
