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. To manage grants with a screen instead of the CLI, see 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.
[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 (opens in a new tab).
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. |
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_hubin an agent also grantsuse_hubthere, in the same transaction. Anyone who can use a tool can open the agent that has it. - Removing
use_hubremoves 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_accessgrant 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
- 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. - 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.
- Account check. A blocked account is refused. If the access store cannot be read, the call is refused.
- 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. - 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. - 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:
[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.
HUBZOID_RESTRICTED_SURFACES=owui,web,api,mcp,workflow,slack-dmKeep 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.
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 and 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:
hubzoid audit ./finance --denied --user priya@example.org --limit 100Changes 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:
hubzoid access bootstrap --admin you@example.org --authoritative ./financeAn 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.
Next steps
Restrict a tool
Gate a tool behind a permission, grant it and read the decision end to end.
Agents and access
Add users, grant capabilities and review changes in the Console.
People and activity
Account states, deleting users and the two activity records used for audits.
Access administration
Bootstrap administrators and migrate group-based hubs to managed access.
Workflows and schedules
How Hubzoid runs recurring work as markdown tasks or code workflows on one durable engine, with triggers, concurrency, retries, catch-up rules and the account each run acts as.
Design an agent
Plan a Hub before you build it. Choose what the agent knows and can reach, agree the plan with the people it serves, then map each part to a folder.
