---
title: "Approve or deny an access request"
method: POST
path: "/access-request/{id}/resolve"
tags: ["Access requests"]
---

# Approve or deny an access request

`POST /access-request/{id}/resolve`

## Request

- Base URL: `https://api.elaichi.ai`
- URL: `https://api.elaichi.ai/access-request/{id}/resolve`
- Auth: HTTP bearer

Approves or denies someone else’s access request. Requires `member:manage`. Only an interactive browser session can decide: an organization API token (`Bearer elch_…`) is refused with `403`, however many permissions it holds, because approving an access request is a person’s decision. The session must also pass step-up reauthentication in the app; without a valid `X-Step-Up-Token` the call answers `428 step_up_required`, and `error.details` names the action and resource to prove. A decision is recorded once and never re-opened, so the person recording it re-proves they are still the person holding the session. Refuses with `409` when the request has already left `pending`.

**An admin may not decide their own request: `403`**, whichever decision they send and however many permissions they hold. Self-approval of a restriction request would be a self-grant, and the record would read as approved by a second person when it was not. The requesting admin keeps `can_withdraw` on that row instead.

**Approving a `restriction`-reason request with `resolution: "grant"` (the default) lifts that restriction for the requester alone**, either a whole connector or one tool on the connector a tool request names in `connector_slug`, by writing an access grant (who, what, approved by, when, and optionally an expiry). The requester’s own member-level rules are edited (and a block rule the approval empties is removed); where their role’s rules stand in the way, the grant is what lifts them. Role rules themselves are never edited or copied, so every other role rule, including one added later, keeps binding the requester. A tool on a connector blocked whole cannot be carved out and is not lifted. `resolution: "policy"` instead records the decision without writing a grant, for when the admin is fixing the underlying role or connector rule directly through the restriction APIs. If the requester has a personal rule that would need editing to open the target, an approval that lists `remove_member_rules` (the ids of the personal rules the caller confirms editing) must list exactly the conflicting ids, or the call answers `409 member_rule_conflict` naming them in `error.details.member_rule_conflicts`. Omitting `remove_member_rules` skips that check and edits the personal rules without confirmation, for clients that predate the field. `expires_at` (ISO, in the future, at most about 10 years out; any UTC offset is accepted and stored as UTC) sets when a grant expires; omit it or send `null` for no expiry. `will_lift_restriction` on the admin view previews whether approving will change anything. For a fork blocked through its original (in the requester’s role or in their own rules), approving opens that fork for that person only and `lifts_inherited_restriction` says so: the original stays restricted for them, the grant is audited with `lifted_inherited_restriction: true`, and revoking it re-blocks the fork. Every other approval (a `permission`-reason request, or a tool request with no `connector_slug`) records a decision only; widening access there is a separate act through `PATCH /member/{userId}` or the restriction APIs.

## Path parameters

- `id` string, required

## Headers

- `X-Organization-Id` string

## Request body

- object
  - `decision` 'approved' | 'denied', required
  - `note` string
  - `resolution` 'grant' | 'policy': Only meaningful on an APPROVED, `reason: "restriction"` request. `grant` (default) writes an access grant; `policy` approves without writing one — the admin is changing the underlying rule directly instead.
  - `expires_at` string, date-time, nullable: When the grant this approval writes auto-expires. `null`/omitted means no expiry. Ignored unless this approval actually writes a grant.
  - `remove_member_rules` string[]: The ids of the requester’s personal rules that this approval would edit, as the caller confirms them. Must equal the conflicting set exactly (an empty array asserts there are none), or the call answers `409 member_rule_conflict` with the real ids in `error.details`. Omit the field to skip the check; the rules are then edited without confirmation.

## Response `200`

Resolved (admin view, `can_resolve: false`)

- object: The admin queue’s row shape — returned by `GET /access-request`, `GET /access-request/{id}` when the caller holds `member:manage`, and `POST /access-request/{id}/resolve`.
  - `id` string: Access request id (`areq_…`).
  - `requester_user_id` string: User id (`usr_…`) of whoever filed the request.
  - `tool` string: What was asked for: a tool name, or a connector slug when `resource_type` is `"connector"`.
  - `resource_type` 'tool' | 'connector'
  - `connector_slug` string, nullable: On a `"tool"` request, the connector the tool lives on when the request named one — what makes approving it able to lift the restriction on that tool. Always null on a `"connector"` request.
  - `connector_label` string: Display name of `connector_slug` (or, on a `"connector"` request, of `tool` itself), falling back to the slug. Present on every view: the requester reads it in the self view exactly as an admin does in the queue.
  - `connector_logo` string, nullable: The connector’s logo for the slug `connector_label` names. `null` when the catalog has no logo for the slug; present under the same conditions as `connector_label`.
  - `reason` 'permission' | 'restriction': What kind of refusal this request is asking to be reconsidered.
  - `permission` string, nullable: Present only when `reason` is `"permission"` — never populated for a `"restriction"`-reason request, on either write or read. That is the disclosure rule: a restriction refusal never names the rule that blocked the caller, so this field must not become a second channel for the same fact.
  - `note` string, nullable: Optional free text from the requester, ≤ 2000 characters.
  - `status` 'pending' | 'approved' | 'denied' | 'withdrawn'
  - `created_at` string, date-time
  - `resolved_at` string, date-time, nullable
  - `resolved_by_user_id` string, nullable: User id (`usr_…`) of the admin who resolved it.
  - `resolution_note` string, nullable
  - `restriction_lifted` boolean, nullable: What an approval did to the requester’s access, on every view: `true` when it wrote a grant, `false` when the request was approved and nothing was lifted (a `resolution: "policy"` approval, access that was already open, or a shape no grant can open), `null` when the question does not apply (not approved, a `permission` request, a tool request with no `connector_slug`). It never says why; the connector’s own `restricted`, `restricted_by` and `restricted_inherited_from` do, within what the caller may be told. An older approval may read `false` for access it did give, so read the connector’s `restricted` for the live answer. It is the outcome at resolve time: a later revoke, expiry or replacement does not change it.
  - `updated_at` string, date-time
  - `requester` object: Resolved member profile. Falls back to `{ id }` alone when the profile row no longer resolves.
    - `id` string: User id (`usr_…`).
    - `name` string, nullable
    - `email` string, email
    - `avatar_url` string, nullable: The person's picture: their stored profile picture, else a Gravatar URL (`d=404`, 96px) derived from their email (the address itself is not sent), else null. Draw initials when it is null or the image fails to load.
  - `resolved_by` object, nullable: Resolved member profile. Falls back to `{ id }` alone when the profile row no longer resolves.
    - `id` string: User id (`usr_…`).
    - `name` string, nullable
    - `email` string, email
    - `avatar_url` string, nullable: The person's picture: their stored profile picture, else a Gravatar URL (`d=404`, 96px) derived from their email (the address itself is not sent), else null. Draw initials when it is null or the image fails to load.
  - `can_resolve` boolean: True while `status` is still `pending` **and** the row is not the caller’s own — the two preconditions `resolve` itself enforces (`409` and `403` respectively).
  - `can_withdraw` boolean: True when the caller is the requester and `status` is still `pending`. Present on this shape as well as on the self view: an admin’s own request sits in their own queue, and withdrawing it is the one verb on that row that is theirs.
  - `will_lift_restriction` boolean: On a pending restriction-reason request approving could act on (a connector request, or a tool request naming its `connector_slug`): whether approving it would actually lift anything. Absent on every other row.
  - `connector_restricted_for_requester` boolean: On the same rows, for a TOOL request only: true when approving lifts nothing because the requester’s WHOLE connector is restricted — one tool cannot be carved out of a connector restricted whole, and an approval never opens the whole connector for a one-tool ask. Their connector access is what an admin would have to grant instead.
  - `lifts_inherited_restriction` boolean: On the same rows, for a CONNECTOR or TOOL request: true when approving opens this FORK (or, for a tool request, that one tool on it) by lifting a block it inherits from the connector it was forked from — through the requester’s role OR through a rule written about them personally. A request for a fork is its own ask: approving it opens THAT FORK for THAT PERSON and nothing else; the original stays restricted for them, a rule naming the fork itself is not overridden, a connector approval never lifts their personal tool rules, and the exception is standing and tied to the fork (revoking the grant re-blocks it).
  - `lifts_inherited_member_rule` boolean: With `lifts_inherited_restriction`: true when the block it lifts is a rule written about the requester personally rather than their role’s. Only then does the grant carry the marker that keeps lifting it while the grant is active — for the originals that blocked it when it was approved, so a block written later on a different ancestor is not bypassed. A lift of a role-layer block alone does not carry it, so a personal block written later on the original is not overridden by it.
  - `inherited_from_member_rule` boolean: On the same rows, for a CONNECTOR request only: true when approving lifts nothing even though the connector is a fork blocked through its original in the requester’s own restrictions. This is now rare: most such forks are covered by `lifts_inherited_restriction` instead.
  - `inherited_from_connector_label` string, nullable: With `lifts_inherited_restriction` or `inherited_from_member_rule`: the display name of the connector it was forked from, or `null` when it cannot be named to this caller (private to someone else, deleted, or the catalog did not answer). Never a slug or id.

## Other responses

- `400`: Bad request
- `401`: Unauthorized
- `403`: Forbidden
- `404`: Not found
- `429`: Rate limited

---

[API](https://skmtc.dev/elaichi/apis/elaichi-api.md) · [All operations](https://skmtc.dev/elaichi/apis/elaichi-api/llms.txt) · [OpenAPI document](https://skmtc.dev/elaichi/apis/elaichi-api/revisions/b22cfb7b6d15?raw)
