Role-based tool set
The Nortinia AI Assistant is a single chat surface, but the tool catalogue underneath is role-shaped. A viewer does not see the same actions as an admin, and a super_admin does not automatically inherit every tenant-bound tool. This article describes the MCP scope guard and the bug we had to fix along the way.
The four roles
viewer
Reads only. getInvoice, searchCustomer, listOrders, getAuditEvents â but no create, update, delete. A small icon next to the chat input signals that the user is in read-only mode. If a viewer tries to issue a mutation, the assistant politely says the role does not allow it and offers to notify an editor.
editor
Mutations behind preview-and-confirm. Every write tool runs in two steps: the assistant first shows a diff-like summary, and only after explicit user confirmation does the action run. Editors see their own actions in the audit log; other people's actions are hidden.
admin
RBAC actions (role swap, permission grant), full audit trail, tenant config edits, integration management. Above admin level the userManagement.* and audit.full tool families become available. Preview-and-confirm is stricter here: every destructive action (delete, role downgrade) requires a second confirmation with a written reason.
super_admin
Cross-tenant actions: a Netorigo-internal role that can look across customer tenants. This is where tenant.*, billing.crossTenant, featureFlag.global tool families appear. The role logs a double audit trail (tenant + Netorigo-internal), and every cross-tenant tool call fires a separate notify event to the affected tenant admin.
The scope guard
Every MCP tool declares a requiredScopes list, for example:
refund.initiate â ['payments:write', 'role:editor']
user.changeRole â ['users:write', 'role:admin']
tenant.delete â ['tenant:write', 'role:super_admin']
The chat agent pulls the role token from the user's JWT at session start, and the tools/list response only includes tools whose scopes are satisfied by that token. In other words: the viewer never sees refund.initiate listed â they cannot request it, they cannot even see it exists.
The bug we fixed
In May 2026 we noticed the guard was incorrectly accepting a role:* wildcard for super_admin. An early version of the implementation worked like this: if a user had a role:super_admin token, every tool was included in the tools/list response â including tools whose explicit requirement was role:admin and which had not been re-declared with role:super_admin.
In practice this was a week-long leak: a super_admin user could see editor-level tools through chat that were not meant for the role. They could not actually run mutations through them (the guard re-checks at tool-call time), but the catalogue leaked.
The fix is two-sided: tools/list filtering now does strict scope matching (not wildcard-permissive), and the tool-call guard double-checks. The super_admin user must hold an explicit role:admin scope as well to use admin-level tools â a deliberate UX tradeoff in favour of security.
What this means for development
Two things are mandatory when defining a new tool:
- Exact scope list. Not
role:*, but a concrete role. If several roles can use a tool, list them:['role:editor', 'role:admin']. - Per-scope unit test. Every new tool ships a
tool.spec.tsthat exercises every role combination.
This slows development, but in six months we have found one bug (the one above) and even that one within a day of release.
Why not a static tool list per role
We tried. The first version statically encoded viewer_tools.json, editor_tools.json, etc. It was unmaintainable. A new tool meant four file edits. A tool rename meant four file edits. Now the tool catalogue is the source, and scope filtering runs at runtime. One source of truth.
What comes next
The next iteration introduces a granular scope system. Today role:editor enables every editor-level tool. Soon we will be able to constrain by sub-module: role:editor + module:finance permits writes only inside finance, not logistics. This matters for larger customers where finance and warehouse teams share one Netorigo instance.