---
title: Identity and access
description: How Hubzoid decides who may open an agent and call each restricted tool, from subjects and permissions to surfaces, decision logs and fail-closed rules.
canonical_url: https://hubzoid.com/docs/concepts/identity-and-access
last_updated: 2026-09-27
---

# Identity and access

How Hubzoid decides who may open an agent and call each restricted tool, from subjects and permissions to surfaces, decision logs and fail-closed rules.

Hubzoid enforces access in the agent runtime, in code that runs before a tool executes. The model is never the gate. This page describes the model that code implements: who a caller is, what they hold, which channels may reach restricted tools, where each decision is recorded and what happens when something goes wrong.

## When to use this

Read this before you put a tool in `restricted/`, grant access to a person or the account a workflow runs as, or open a chat surface such as Slack or WhatsApp to restricted tools. For the hands-on version, follow [Restrict a tool](https://hubzoid.com/docs/guides/restrict-tools). To manage grants with a screen instead of the CLI, see [Agents and access](https://hubzoid.com/docs/console/agents-and-access).

## The model

A grant is one row: a **subject** holds a **permission** in a **domain**. Hubzoid keeps grants in the `hz_grants` table of the operational database and evaluates them with Casbin, using direct grants only. In a managed hub no role or group carries permissions, so the question "what can this person do in this agent?" has exactly one answer in one table.

```text title="hubzoid/access/store.py (Casbin model)"
[request_definition]
r = sub, dom, act

[policy_definition]
p = sub, dom, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = (r.sub == p.sub || p.sub == "*") && (r.dom == p.dom || p.dom == "*") && (r.act == p.act || p.act == "*")
```

A request is allowed when any grant matches. Each part matches exactly or through the `*` wildcard. Subjects, domains and permissions are compared in lowercase with surrounding spaces trimmed, so `Ledger`, `LEDGER` and `ledger` are the same key. The source is in [hubzoid/access/store.py](https://github.com/hubzoid/hubzoid/blob/main/hubzoid/access/store.py).

### Subjects

| Subject | Who it is | Notes |
| --- | --- | --- |
| `priya@example.org` | A person, or an ordinary account a workflow runs as, keyed by the email they sign in with | The Console's **Add user** creates the account and its first grants together. To grant an email before its account exists, use `hubzoid grant` or the management API. The access applies once someone signs in with that email. |
| `workflow:<name>`, `workflow:md:<task>` | Legacy service identities of a code workflow or a markdown task | Runs now act as an ordinary account, so these grants are kept but not used by those runs. A legacy hub with no account configured keeps running under the old identity. The Console shows them as **Legacy service identity** and cannot add new ones. |
| `*` | Everyone who can sign in | No new grants: the Console, the API, `hubzoid grant` and the store refuse them. Migration can carry one over from a legacy hub that was open to everyone, as an **Everyone signed in** row that keeps working until an organization administrator removes it. |

### Domains

| Domain | Meaning |
| --- | --- |
| Hub folder name in lowercase, for example `finance` | One agent. The folder name is the access domain, so folder names must be unique, ignoring case, within one deployment. |
| `*` | The organization. Only `manage_access` can be granted here, and it applies in every agent. |

### Permissions

| Permission | What it allows | Where it comes from |
| --- | --- | --- |
| `use_hub` | Open the agent: chat with it, read its knowledge and use its unrestricted tools | Built in. The Console labels it "Use this agent". |
| `manage_access` | Grant and remove access in one agent, or in every agent when held in `*`. In one agent it also includes chat with that agent | Built in. The Console labels it "Manage access". |
| One per `restricted/<module>.py` | Call every `@function_tool` defined in that file | The file stem in lowercase. `restricted/ledger.py` defines `ledger`. |
| `curator` | Use the built-in `remember` tool, which saves knowledge from a chat | Built in. The Console labels it "Save shared knowledge". See [Memory and history](https://hubzoid.com/docs/concepts/memory-and-history). |
| `jev`, `share_public_links`, `connector_<app>` | Use `call_jev` in chat, create public links to artifacts you own, and use your own connection to that app | Built in. The Console labels them "Call Jev", "Share artifacts publicly" and "Connect" with the app's name. Nobody holds them by default. |

These rules apply to every write, from the Console or the CLI:

- Granting any permission other than `use_hub` in an agent also grants `use_hub` there, in the same transaction. Anyone who can use a tool can open the agent that has it.
- Removing `use_hub` removes every permission the subject holds in that agent.
- `*` is never a grantable permission, and new grants to the `*` subject are refused.
- The last organization administrator, meaning the last `manage_access` grant in `*`, cannot be removed, blocked, demoted or deleted.
- Every write increments a policy revision in the same transaction. Each process compares the revision before a check and reloads its in-memory policy when it has moved, so a change applies on the next check in every bridge that shares the database, with no restart.

## How a decision is made

Caller identity → Surface check → Account check → Grant check → Decision logged → Tool runs

Every call to a restricted tool takes this path. A failure at any step refuses the call.

1. **Caller identity.** Each request binds an identity: the user (an email, or on a legacy hub a workflow subject) and the surface the request arrived on. On a hub that still uses legacy group access, the identity also carries the caller's groups. Outside a request, for example in `hubzoid test`, the identity is anonymous.
2. **Surface check.** An anonymous caller is refused. A caller whose surface is not in the restricted surfaces list is refused before any grant is read. A grant is necessary but never sufficient.
3. **Account check.** A blocked account is refused. If the access store cannot be read, the call is refused.
4. **Grant check.** On a managed hub, Casbin answers `can(subject, hub, permission)`. On a hub with legacy group access, the permission must match one of the caller's groups.
5. **Decision logged.** The guard writes one row to `hz_access_decisions`. If that row cannot be written, the call is refused even when it was allowed, so every call that ran has a row.
6. **Tool runs.** Only now does the tool's own code execute.

A refused call does not raise an error into the conversation. The model receives a message it can explain to the person:

```text
[access denied: 'payroll_run' requires the 'payroll' permission, which the current user does not have. This attempt was logged.]
```

Each row stores a short reason. The Console's Activity page explains each one in words.

| `reason` | Decision | Meaning |
| --- | --- | --- |
| `grant` | allow | The subject holds the permission in a managed hub |
| `group` | allow | The caller is in the matching group in a legacy hub |
| `no-grant` | deny | No grant matches |
| `no-group` | deny | The caller is not in the matching group in a legacy hub |
| `anonymous` | deny | No signed-in caller |
| `surface:<name>` | deny | That surface may not reach restricted tools |
| `blocked` | deny | The account is blocked |
| `store-error` | deny | The access store could not be read |

### Entering an agent

A managed hub checks `use_hub` before any tool is involved. The chat endpoint takes the caller from headers set by the trusted front end (`X-OpenWebUI-User-Email` or `X-Hubzoid-User`), never from the request body. A request with no verified identity, or without `use_hub`, receives 403. If the access store cannot be read, the request fails with 503 rather than passing. The hosted MCP server applies the same `use_hub` check when it verifies a caller's key. A blocked account is turned away at both doors on every hub.

## Surfaces

The surface records the channel a request came through. By default only surfaces where each request carries one verified identity, a signed-in person or the account a workflow run acts as, may reach restricted tools.

| Surface | Where it comes from | Who the caller is | Restricted tools by default |
| --- | --- | --- | --- |
| `owui` | The chat app (Open WebUI). Also used when a request declares no surface | The signed-in email the chat app forwards | Yes |
| `web`, `api` | Direct callers behind a trusted front that sets `X-Hubzoid-Surface` | The user in the trusted headers | Yes |
| `mcp` | The hosted MCP server | The owner of the Open WebUI API key | Yes |
| `workflow` | Code workflows and markdown tasks | The account the run acts as: `run_as`, else `HUBZOID_WORKFLOW_USER`, else the owner recorded at setup | Yes |
| `slack-dm` | A Slack direct message or the assistant sidebar | The sender's Slack profile email when `SLACK_IDENTITY_MAPPING=true` | No |
| `slack-channel` | A mention in a Slack channel or group thread | The person who mentioned the bot, when mapping is on | No, and it must stay off |
| `whatsapp`, `telegram` | Inbound chat surfaces | The email the hub roster resolves for the sender's handle | No |
| `system` | No request identity, for example `hubzoid test` | Anonymous | Always refused |

Change the list with `HUBZOID_RESTRICTED_SURFACES`. It is a comma-separated, complete list that replaces the default of `owui,web,api,mcp,workflow`. Restart the hub after changing it.

```bash title="finance/.env"
HUBZOID_RESTRICTED_SURFACES=owui,web,api,mcp,workflow,slack-dm
```

> **Warning: Keep workflow in the list**
>
> Because the variable replaces the default, leave `workflow` in it if your workflows or markdown tasks use restricted tools. Without it, their granted calls are refused with the reason `surface:workflow`.

> **Caution: Never add slack-channel**
>
> A channel thread combines messages from many people into one prompt, then answers as the person who mentioned the bot. Anyone in the thread could steer a tool that only that person may use. A `slack-dm` conversation carries one person and is the Slack surface to opt in.

`SLACK_IDENTITY_MAPPING` only supplies the identity. Slack requests reach restricted tools when that identity holds the grant and `slack-dm` is in the list. WhatsApp and Telegram senders are identified through the hub roster in `identity/`. See [Slack](https://hubzoid.com/docs/chat/slack) and [WhatsApp and Telegram](https://hubzoid.com/docs/chat/whatsapp-and-telegram).

## Hidden versus refused

Each restricted tool gets two layers, built from the same tool registry that every backend uses.

| Where the agent runs | Hidden from callers without access | Refused and logged when called |
| --- | --- | --- |
| OpenAI Agents SDK runtime (LiteLLM models) | Yes. The tool's `is_enabled` check runs the same decision for each run, so the model never sees the tool | Yes |
| Claude Agent SDK runtime (`claude-local`) | Yes. The tool is left out of the tools that turn offers the model | Yes |
| Local Codex runtime (`codex-local`) | Yes. The tool is left out of that turn's tools | Yes |
| Hosted MCP server | Yes. `tools/list` is filtered with the same decision | Yes. The refusal returns as an MCP error result |

Hiding is the clean experience. The refusal at call time is the wall. It holds when a tool is reached some other way: a prompt that names it, or an MCP client that calls a tool by name. Hiding writes nothing, so a person who never sees a tool leaves no row in Activity. Only calls are logged.

Two more details keep the wall intact. Sub-agents that the main agent delegates to are built from the same guarded registry. When a restricted tool has the same name as a built-in or `tools_local/` tool, the restricted one replaces it, so an unguarded copy can never shadow it.

## The decision log

Each decision is one row in `hz_access_decisions`, written by the runtime at the moment of the call. The chat app never sees tool calls, so this is the only record of them.

| Column | Content |
| --- | --- |
| `ts` | When the decision was made |
| `hub` | The agent, as its folder name in lowercase |
| `subject` | The caller, or `anonymous` |
| `surface` | The surface the call arrived on |
| `tool` | The tool name |
| `decision` | `allow` or `deny` |
| `reason` | One of the reasons above |

Read it in the Console under **Activity**, **Tool decisions**, or on the server:

```bash
hubzoid audit ./finance --denied --user priya@example.org --limit 100
```

Changes to access are a separate record, `hz_access_audit`, with the actor, action, subject, agent and permission of every grant, removal, account creation, approval, password reset, role change and deletion, block, migration, schedule pause and run cancellation. Hubs upgraded from releases that wrote monthly `logs/access-*.jsonl` files import those files once, the first time the hub records or reads a decision, and leave the files in place.

## Fail-closed behavior

Every failure denies. No failure falls back to a more permissive path.

| Situation | Result |
| --- | --- |
| No signed-in caller | Restricted tools refused with `anonymous` |
| The access store is unreachable, or the Casbin check errors, on a managed hub | Refused with `store-error`, never answered from legacy groups. Entering the agent returns 503 |
| The decision row cannot be written | The call is refused, even if it was allowed |
| The roster file `identity/access.csv` is unreadable or half-written | It contributes no groups |
| `identity/access.py` raises an error | That lookup contributes no groups |
| A different chat account signs in with an email already bound to an earlier account | That email's direct grants are removed and it is blocked until an organization administrator reviews it |
| A blocked account | Refused everywhere, including agents with a carried-over **Everyone signed in** row |

## Managed hubs and legacy group access

Each hub has its own authority marker, `casbin_authoritative:<hub>` in the `hz_meta` table. Hubs in one deployment therefore move to managed access one at a time, and an unmigrated hub keeps working exactly as before.

|  | Legacy group access | Managed access |
| --- | --- | --- |
| Source of tool permissions | The union of the caller's Open WebUI groups, groups from the hub roster (`identity/access.csv`, or `groups_for_email` in `identity/access.py`) and `X-Hubzoid-Groups` from a trusted front. A group whose name matches the permission grants it | Direct grants in `hz_grants` |
| Entering the agent | Chat app model visibility | `use_hub`, checked by Hubzoid on every request |
| MCP front door | Any valid Open WebUI API key, optionally limited to members of the `MCP_ACCESS_GROUP` group | `use_hub` |
| Console | Read only, tagged **Legacy access** | Editable, tagged **Managed here** |
| Decision reasons | `group`, `no-group` | `grant`, `no-grant` |

A hub created with `hubzoid init` becomes managed the first time the configured owner signs in, and that owner becomes the first organization administrator. `hubzoid access bootstrap` does the same from the server:

```bash
hubzoid access bootstrap --admin you@example.org --authoritative ./finance
```

An existing group-based hub moves with `hubzoid access migrate`. It previews the change, checks effective access for both permitted and denied users, and cuts over in one transaction after writing a backup. The whole procedure, including rollback, is in [Access administration](https://hubzoid.com/docs/deploy/access-administration).

## Next steps

- [Restrict a tool](https://hubzoid.com/docs/guides/restrict-tools): Gate a tool behind a permission, grant it and read the decision end to end.
- [Agents and access](https://hubzoid.com/docs/console/agents-and-access): Add users, grant capabilities and review changes in the Console.
- [People and activity](https://hubzoid.com/docs/console/people-and-activity): Account states, deleting users and the two activity records used for audits.
- [Access administration](https://hubzoid.com/docs/deploy/access-administration): Bootstrap administrators and migrate group-based hubs to managed access.
