Hubzoid
Concepts

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.

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 (opens in a new tab).

Subjects

SubjectWho it isNotes
priya@example.orgA person, or an ordinary account a workflow runs as, keyed by the email they sign in withThe 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 taskRuns 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 inNo 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

DomainMeaning
Hub folder name in lowercase, for example financeOne 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

PermissionWhat it allowsWhere it comes from
use_hubOpen the agent: chat with it, read its knowledge and use its unrestricted toolsBuilt in. The Console labels it "Use this agent".
manage_accessGrant and remove access in one agent, or in every agent when held in *. In one agent it also includes chat with that agentBuilt in. The Console labels it "Manage access".
One per restricted/<module>.pyCall every @function_tool defined in that fileThe file stem in lowercase. restricted/ledger.py defines ledger.
curatorUse the built-in remember tool, which saves knowledge from a chatBuilt 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 appBuilt 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

  1. Caller identity
  2. Surface check
  3. Account check
  4. Grant check
  5. Decision logged
  6. 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:

[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.

reasonDecisionMeaning
grantallowThe subject holds the permission in a managed hub
groupallowThe caller is in the matching group in a legacy hub
no-grantdenyNo grant matches
no-groupdenyThe caller is not in the matching group in a legacy hub
anonymousdenyNo signed-in caller
surface:<name>denyThat surface may not reach restricted tools
blockeddenyThe account is blocked
store-errordenyThe 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.

SurfaceWhere it comes fromWho the caller isRestricted tools by default
owuiThe chat app (Open WebUI). Also used when a request declares no surfaceThe signed-in email the chat app forwardsYes
web, apiDirect callers behind a trusted front that sets X-Hubzoid-SurfaceThe user in the trusted headersYes
mcpThe hosted MCP serverThe owner of the Open WebUI API keyYes
workflowCode workflows and markdown tasksThe account the run acts as: run_as, else HUBZOID_WORKFLOW_USER, else the owner recorded at setupYes
slack-dmA Slack direct message or the assistant sidebarThe sender's Slack profile email when SLACK_IDENTITY_MAPPING=trueNo
slack-channelA mention in a Slack channel or group threadThe person who mentioned the bot, when mapping is onNo, and it must stay off
whatsapp, telegramInbound chat surfacesThe email the hub roster resolves for the sender's handleNo
systemNo request identity, for example hubzoid testAnonymousAlways 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.

finance/.env
HUBZOID_RESTRICTED_SURFACES=owui,web,api,mcp,workflow,slack-dm

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.

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 runsHidden from callers without accessRefused 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 toolYes
Claude Agent SDK runtime (claude-local)Yes. The tool is left out of the tools that turn offers the modelYes
Local Codex runtime (codex-local)Yes. The tool is left out of that turn's toolsYes
Hosted MCP serverYes. tools/list is filtered with the same decisionYes. 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.

ColumnContent
tsWhen the decision was made
hubThe agent, as its folder name in lowercase
subjectThe caller, or anonymous
surfaceThe surface the call arrived on
toolThe tool name
decisionallow or deny
reasonOne 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 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.

SituationResult
No signed-in callerRestricted tools refused with anonymous
The access store is unreachable, or the Casbin check errors, on a managed hubRefused with store-error, never answered from legacy groups. Entering the agent returns 503
The decision row cannot be writtenThe call is refused, even if it was allowed
The roster file identity/access.csv is unreadable or half-writtenIt contributes no groups
identity/access.py raises an errorThat lookup contributes no groups
A different chat account signs in with an email already bound to an earlier accountThat email's direct grants are removed and it is blocked until an organization administrator reviews it
A blocked accountRefused 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 accessManaged access
Source of tool permissionsThe 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 itDirect grants in hz_grants
Entering the agentChat app model visibilityuse_hub, checked by Hubzoid on every request
MCP front doorAny valid Open WebUI API key, optionally limited to members of the MCP_ACCESS_GROUP groupuse_hub
ConsoleRead only, tagged Legacy accessEditable, tagged Managed here
Decision reasonsgroup, no-groupgrant, 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 ./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.

Next steps

Read this page as Markdown

Choose which cookies Hubzoid can use. You can change this at any time from Cookie settings in the footer. Read the Cookie Policy for details.