Skip to content

Least privilege and trust

Least-privilege controls limit what an agent can reach and act on, and give reviewers the evidence to keep it that way. This page describes the controls of ADR-0044: connector approval, the worker egress allowlist, delegation narrowing between agents, signed delegation assertions for calls to and from remote agents, and the evidence for credential and access reviews.

A toolkit type or an MCP server gives an agent a new way to read data and act. When any project member can add one, the platform’s reach grows without anyone reviewing what the connector can do, which hosts it calls and which data it touches. An MCP server can also change the tools it offers after it was added.

A platform setting, catalogue mode, has two values:

  • open (the default): today’s behaviour. Toolkit types are offered as the toolkit type policy allows, and MCP servers can be added freely.
  • approved_only: a toolkit type, a prebuilt MCP server or a custom MCP server URL is usable only after a connector request for it is approved. The governed agent profile forces this mode, and so does every run that carries a governed security context.

In approved_only mode:

  • Unapproved connectors are refused everywhere. They are left out of the selection lists, and creating, forking or updating a toolkit that uses one is refused with 403 connector_not_approved, naming the connector to request. When an agent version is frozen for a run, unapproved connectors are dropped, and runtime admission refuses any command that still names one (CONNECTOR_NOT_APPROVED), including toolkits configured before the mode was switched.
  • Approval needs a second person. The approver holds connector.approve (granted to the platform super_admin and admin roles) and cannot be the requester; the database refuses a self-approval.
  • Approval grants and allowlists in one step. In one transaction it writes the toolkit type grant for the requested projects and adds the declared hosts to the worker egress allowlist of those projects (or to the platform allowlist for a platform-wide request), with a configuration revision naming the approver. Approving an MCP server never changes the policy of the generic mcp (or mcp_<key>) toolkit type: a platform-wide approval of one server leaves a restricted type restricted for every other server.
  • A custom MCP server’s tools are pinned at approval, by name and digest of the name, description and input schema. Tools the server adds or changes later are hidden from discovery and the tool lists, and a call to one is refused, until a new request for them is approved. This holds for agent runs and for direct tool calls alike. When a pinned tool changes on the server, the agent sees a stand-in tool that fails with tool.mcp.pin_mismatch, and the worker logs mcp_tool_pin_mismatch, so the tool does not disappear silently.
  • A prebuilt MCP server is approved for its catalogue URL only. Load tools with a URL other than the catalogue entry’s is judged as a custom MCP server URL and needs its own approval. At run time a prebuilt toolkit always connects to the catalogue entry’s URL.
  • Optional architecture review. With Require architecture attestation on, a request can be approved only when a valid architecture attestation for it exists (subject connector, subject id = the request id, recorded in the requesting project).

A member can request a connector only for projects where they may add toolkits (models.applications.tools.create). Only a connector approver can request one platform-wide. Anything else is refused with 403 connector_request_scope_refused.

A connector request records the requester, the target (toolkit type, prebuilt MCP server or custom URL), the projects, the declared tools with their categories, the egress hosts, the data classes it touches and a justification. Its states are draft, submitted, approved, rejected and withdrawn.

  1. Seed requests for what is in use

    Before switching modes, open Admin › Connector requests and select Create requests for connectors in use. It files a submitted request for every connector configured in any project, with the projects using it, the tools their toolkits select (a custom MCP server’s tools as its last Load tools stored them, so they can be pinned) and the hosts of their URL settings. A connector is skipped only when open or approved requests already cover every project that uses it; otherwise the new request names the projects not yet covered. Running it again is safe. A tool or host that cannot be requested (an invalid name, a description over 4096 bytes, a custom MCP tool with no stored schema) is left out and named in the request’s justification. A connector whose request still cannot be built is listed as failed with the reason, and the others are still created. Tool categories are not known from a toolkit’s settings, so every tool starts as write. Review and approve them. The administrator who created them cannot approve them.

    A custom MCP server whose tools were never loaded gets a request with no tools: approving it pins nothing, so all its tools stay hidden. Run Load tools on it first. Hosts are read from URL fields in the toolkit’s own settings; add hosts that come from a referenced configuration to the request by hand. A connector used in more than 64 uncovered projects is requested platform-wide.

  2. Switch the mode

    Open Configuration › Connector Catalogue and set Mode to approved_only. The page warns you how many connectors in use still have no approved request; each of them stops working at once. (The warning needs connector.approve to read the count; without it the save goes through with no warning.)

  3. Request a connector (project members)

    When a toolkit or MCP server is refused, select Request connector. For MCP, Load tools prefills the discovered tools and their schemas. Enter the projects, hosts, data classes and justification, and submit. Follow the request on the toolkit page.

  4. Approve or reject (connector approvers)

    Open Admin › Connector requests, review the declared tools, categories, hosts and data classes, and approve, or reject with a reason.

Each state change of a request (created, submitted, withdrawn, approved, rejected) is a security audit event of type ai.elitea.security.v1.connector.approval, with the actor, the request and its target, and is exported with the rest of the audit evidence. The request itself, with who approved it and when, stays in centry.connector_requests.

  • The architecture or security review behind an approval happens in your own process; record its result as an attestation.
  • Decide who holds connector.approve, and keep requesters and approvers apart.

An agent’s tools make outbound HTTP calls from the worker: web fetches, toolkit clients (Jira, Confluence, GitHub and others), OpenAPI tools, remote MCP servers and index runs. Without a restriction, a tool can reach any public host. A prompt injection, a malicious document or a misconfigured toolkit can then send data to a host that nobody approved. The private, link-local and cloud-metadata ranges were already refused for web fetches, but any public host was allowed.

The platform has a worker egress mode:

ModeWhat a run may reach
open (the default)Any public host. This is the behaviour of earlier releases.
allowlistOnly the hosts in the run’s allowlist. Any other host is refused.

A run uses allowlist mode when the platform’s mode is allowlist, or when the run is governed (credential governance is on; see Credential governance). A governed run always uses allowlist, whatever the platform’s mode is.

The main service builds the allowlist for each run when it sends the run to a worker. The allowlist is the union of:

  • the platform entries, set under Configuration › Worker Egress;
  • the project entries, from the project’s worker_egress setting;
  • the host of every URL that the run’s toolkits, MCP servers and configurations are configured with (for example, a Jira toolkit’s base URL);
  • the fixed API hosts that a toolkit type always calls, even when its settings do not name them (for example, api.figma.com for Figma, graph.microsoft.com for SharePoint, and *.googleapis.com for Google Cloud toolkits);
  • for an OpenAPI toolkit, the host of its specification URL and the host that its operations call: the base_url setting when it is set, otherwise the first servers entry of the specification, with each {variable} replaced by its default;
  • for a GitHub toolkit with the base URL https://api.github.com, the host github.com, which the pull-request push of a workspace uses (a GitHub Enterprise base URL already names its push host);
  • for a governed run, the target hosts of the credentials that the run may use (for example, Microsoft Graph for a SharePoint credential).

Only toolkit and configuration settings add hosts. User input, chat history and tool arguments never add a host, so nothing that a user or a document types can widen a run’s allowlist. A project entry only adds hosts to that project’s runs. It does not change the mode, which is the platform’s decision.

The allowlist travels inside the signed worker command, so a worker cannot widen it. The worker enforces it on every outbound connection of the run: web fetches and every redirect hop, all toolkit clients, OpenAPI tools, delegated sign-in token calls and metadata discovery, remote MCP servers, index runs, and the pull-request push of a workspace. A client opened outside any run is refused too: the worker has no unrestricted default. A refused call fails with the tool error egress_host_not_allowed, which names the host. The agent sees this error and can report it. The call never fails silently.

The allowlist narrows the existing rules. It never widens them. For web fetches, private, link-local, cloud-metadata and reserved addresses stay refused in every mode, even if an entry names them.

Each entry is one of:

EntryMatches
api.example.comThat host, on any port
api.example.com:8443That host on that port only
*.example.comEvery subdomain of example.com (not example.com itself)
*.example.com:443Every subdomain, on that port only
203.0.113.0/24Every address in that block

The platform list holds at most 512 entries, and a project list at most 256. A run’s assembled allowlist holds at most 1024 entries and 24 KiB.

If the allowlist for a run cannot be built, the run fails at once. It is not retried, and it does not run with a shorter list. This happens when:

  • a platform or project entry is malformed;
  • the assembled list is longer than 1024 entries or larger than 24 KiB;
  • a governed run’s security context is missing.

The run fails with the code EGRESS_POLICY_INVALID. Its message names the bad entry and who must correct it, for example:

The platform's worker egress allowlist entry "https://jira.example.com" is not a valid host, host:port, *.domain or CIDR block (...). A platform administrator must correct it under Configuration, Worker Egress.

A chat answer shows this as error_code: egress_policy_invalid. A synchronous toolkit tool call answers HTTP 422 with reason: egress_policy_invalid, and nothing is admitted. Configuration validation makes no network call, so it is never refused for its policy. If the main service only could not read a source (for example, the database was briefly unavailable), the dispatch is retried until the run’s deadline.

  • Endpoints that the operator configured for the worker itself, such as web-search provider origins in ELITEA_WEB_EGRESS_ALLOWLIST, are not judged by this policy, because the main service cannot list them. They are still reported as reached.
  • Toolkit clients reach operator-approved hosts, including hosts on your private network, so they never had the private-address guard of web fetches. For them, the allowlist is the only egress check inside the platform.
  • The task status of a run that failed with EGRESS_POLICY_INVALID can show DEADLINE_EXCEEDED. The run’s events, the chat answer and index records show the refusal code.
  • A refused policy is settled at the next dispatch pass. A chat answer and a synchronous tool call report it immediately.
  • A governed toolkit that holds only a credential digest uses the credential grants that exist when the run is dispatched.
  • The worker’s summary does not count bytes (bytes_up and bytes_down are 0). If the worker restarts before it sends the summary, or if the run is not settled within 12 hours, that summary is lost. The refusal itself still reaches the agent and the run’s events.
  • An index run counts each request to a host, not each document.
  • A remote MCP server with no selected tools is checked when the run starts, and appears only in the refused hosts if it is refused.
  • The main service does not download an OpenAPI specification that a toolkit names by URL. If that specification’s first server is an absolute URL on a different host, set the toolkit’s base_url, or add that host to the platform or project entries. A relative server, or no server, uses the host of the specification URL, which is added automatically.
  1. See which hosts your agents reach

    Open Configuration › Worker Egress in the admin console. The panel under the form lists the hosts that runs reached and the hosts that were refused, per project and agent, for the last 7, 30 or 90 days. It reads the worker’s summaries, which are kept 30 days by default. Sandbox runs go through the egress proxy, which has its own per-project allowlists, so their hosts are not listed here; the API’s source=proxy returns them.

  2. Seed the platform allowlist

    Select Copy as allowlist. This copies the reached hosts as entries, one per line: a bare host (any port) where only ports 80 and 443 were used, and one host:port entry for each port otherwise. Remove the hosts that you do not approve, and paste the rest into the Platform Allowlist field. Toolkit base URLs, the base URL of each configuration that a toolkit uses, and the fixed API hosts of each toolkit type are added to each run automatically, governed runs included, so you do not need to list them.

  3. Add project entries if necessary

    A project that needs more hosts than the platform list can add its own. A project admin (permission models.project_settings.edit) creates one configuration of type worker_egress:

    Terminal window
    curl -X POST "$DEPLOYMENT_URL/api/v2/configurations/configurations/$PROJECT_ID" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d '{"elitea_title":"worker_egress","label":"Worker egress","type":"worker_egress",
    "section":"project_settings","data":{"allowlist":["api.partner.example","*.internal.example.com"]}}'
  4. Switch the mode

    Set Mode to allowlist and save. Each replica of the main service reads the section again after at most 5 seconds, so new runs use the allowlist within 5 seconds. If the database cannot be read, a replica keeps the last value that it read and tries again after a short wait (from 0.5 seconds, doubled after each failure, up to 30 seconds). Runs in flight keep the policy that they started with.

  5. Watch the refused hosts

    For the first days, check the refused hosts in the panel. A refused host that a real workflow needs is either added to the platform or project list, or is a call that the allowlist correctly stopped.

To find the same data with the API, call GET /api/v2/admin/worker_egress/hosts (permission configuration.governance). The parameters are days (1 to 90, default 30), source (worker, proxy or all) and project_id.

  • One audit event per run turn. The audit trail gets an event of type tool with entity type execution and the run’s execution id as entity name. Its action lists the hosts reached and refused, for example worker egress: reached api.example.com:443, jira.example.com:443; refused paste.example:443 (host_not_allowed x2). The structured log line egress_audit.turn_summary carries the full summary. These events go to your SIEM with the other audit events (see Audit evidence and retention).
  • The summaries table. elitea_runtime.egress_run_summaries keeps each run’s summary for 30 days (ELITEA_EGRESS_AUDIT_RETENTION_DAYS, at most 365). Rows written by the worker have a replica key worker:<command id>.
  • The configuration history. Every save of the platform section (/api/v2/admin/…) and of a project’s worker_egress setting (/api/v2/configurations/…) is recorded as an api audit event with the user, the route and the result. The event does not include the entries; read the current list from the section.
  • The run’s events. A run refused for its policy has an execution.failed event with the code EGRESS_POLICY_INVALID.
  • Decide which public hosts your agents may reach, and review the platform list on a schedule.
  • Seed the allowlist from the observed hosts before you switch to allowlist, and watch the refused hosts after the switch.
  • Keep network egress control at your perimeter as well. This allowlist is enforced inside the worker; a firewall or egress gateway is your independent second layer.
  • Keep the list of hosts that the operator configured for the worker (web-search providers, model endpoints) under your change process, because this policy does not judge them.

An agent can delegate work: it calls a saved agent or a saved pipeline as a tool, a pipeline node runs a saved agent or pipeline, or the agent starts a sub-agent. Without a limit, the delegated agent (the callee) uses its own grants. A caller that cannot reach a toolkit, a credential or a host can then reach it through a callee that holds it. A chain of delegations can also grow deeper than any reviewer expects.

  • The callee runs with the intersection of grants. Its effective grants are the grants that both the callee and its caller hold. The worker computes this when it assembles the callee, before the callee runs. Five kinds of grant are compared:
    • Toolkits. A toolkit is one configured toolkit, by its id. A built-in toolkit with no configuration is identified by its type.
    • Tools. When the callee selects all tools of a toolkit and the caller selects only some, the callee gets only the caller’s tools. When the callee selects a specific tool that the caller does not hold, the delegation is refused.
    • Credentials. A callee toolkit that uses a credential reference (ADR-0039) can use it only when the caller holds the same reference. The governed credential checks still apply to each use.
    • Data classes. The callee can declare only the data classes that its caller declares. A callee that declares no data classes gets the caller’s data classes. This check starts when an administrator saves Configuration › Delegation, as described in Configure and use below.
    • Egress hosts. Each host that a callee toolkit is configured to reach must be on the run’s worker egress allowlist. The refusal names the host.
  • A missing grant refuses the delegation. The callee does not run. The call returns a tool result with the code DELEGATION_GRANT_MISSING, the kind of grant (toolkit, tool, credential, data_class or egress_host) and its name. The model reads the result and can tell the user. The run does not fail.
  • The depth is limited. The root agent is depth 0. Each delegation adds 1. Saved agents, saved pipelines, pipeline nodes and sub-agents count together. A delegation deeper than the maximum is refused with DELEGATION_DEPTH_EXCEEDED. The existing limits on agent nesting and on the number of pipeline participants still apply.
  • The first saved agent sets the limit. The root grants are the toolkits, tools and credentials of the agent or pipeline that the run starts with, the data classes of its registry record, and the run’s egress allowlist. A root agent with no registry record, or with no data classes, imposes no data class limit.

A nested pipeline is checked before its parent runs. When any agent or pipeline inside it is refused, the whole pipeline is refused, and the refusal names the grant.

Example of a refused call, as the model reads it:

{
"error": "DELEGATION_GRANT_MISSING: the delegated agent needs the toolkit 'jira-prod', and its caller does not hold it. ...",
"failure": {
"code": "DELEGATION_GRANT_MISSING",
"recoverable": false,
"retryable": false,
"recovery_action": "ask_administrator"
},
"delegation_refusal": { "code": "DELEGATION_GRANT_MISSING", "kind": "toolkit", "name": "jira-prod" }
}

A depth refusal carries {"code": "DELEGATION_DEPTH_EXCEEDED", "depth": 5, "max_depth": 4}.

Open Configuration › Delegation in the admin console (permission configuration.governance).

SettingValuesDefaultEffect
Maximum Delegation Depth (max_depth)1 to 164The deepest delegation a run can make.
Mode (mode)enforce, reportenforceenforce refuses a delegation that needs a missing grant. report records the missing grant on the delegation step and lets the callee run. The depth limit applies in both modes. Runs under the governed credential profile always use enforce.

Until an administrator saves this section, the main service does not send it to the workers. The workers then use the defaults in the table, and they do not compare data classes. Narrowing of toolkits, tools, credentials and egress hosts, and the depth limit, apply from the time that a worker runs the release.

Save the section only after every worker runs the release. The main service is upgraded first and the workers after it (the default order of the Helm chart). A worker from an earlier release refuses a command that carries this section. When you save the section, also with the default values, data class narrowing starts.

Each replica of the main service reads the section again after at most 15 seconds. The setting applies to runs that start after that. A run keeps the policy that it started with.

To find compositions that enforcement would refuse, set Mode to report for a period, and look for delegation steps with delegation_enforced: false. For each one, give the grant to the calling agent, or remove it from the callee. Then set Mode to enforce.

Each delegation is a delegation run step. This includes a sub-agent start (step name spawn_agent). The step summary adds these fields:

  • delegation_depth: the depth of the callee.
  • effective_grant_digest: the SHA-256 of the callee’s effective grants, in a canonical sorted form (schema elitea.delegation.grants.v1). Two delegations with the same digest had the same grants.
  • delegation_refusal: only on a refused or reported delegation. It has the code, and the kind and name of the missing grant, or the depth and maximum.
  • delegation_enforced: only with delegation_refusal on a saved agent or pipeline. true when the delegation was refused, false when it was reported in report mode and the callee ran.

Example summary of a permitted delegation:

{
"tool_call_id": "call_7",
"status": "ok",
"agent_type": "pipeline",
"tool_name": "invoice_review",
"model": "gpt-5.1",
"delegation_depth": 2,
"effective_grant_digest": "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881"
}
  • Data classes are declared metadata. They are the labels that the agent owners set in the registry. They are not the output of a classifier, and the platform does not examine the data that an agent reads. The check makes sure that a callee declares no class that its caller does not declare.
  • An ad hoc conversation has no root limit. A conversation without a saved agent imposes no toolkit grants, because the user selected its participants. Narrowing starts at the first saved agent or pipeline.
  • Agent and pipeline references are not grants. A caller does not need to hold the callee itself. The callee’s own toolkits are compared.
  • Hosts are read from the toolkit settings. The host check uses the URL settings of the callee’s toolkits that have an explicit scheme (https://…). Hosts in an OpenAPI specification are not read at assembly. The worker egress allowlist still checks every call at run time.
  • Decide the delegation depth that your agents need, and keep it as low as possible.
  • Keep the data classes in the agent registry correct. The check is only as good as the labels.
  • Review the refused and reported delegation steps, and fix the agent definitions.

Credentials an agent can use (personal access tokens, toolkit secrets, OAuth tokens, governed credential grants) accumulate. Without one view, nobody can tell which ones are still used, who owns them, and which agents depend on them, so unused credentials stay valid indefinitely.

Admin › Credential inventory (permission credentials.inventory.read) lists every credential an agent can use, never its value:

KindExamples
Personal access tokenTokens users and integrations authenticate with
Toolkit secretSecrets referenced by a toolkit’s settings
MCP OAuth tokenTokens an MCP server’s OAuth flow issued
Provider OAuth grantOAuth grants for model or service providers
Credential reference and use grantGoverned credentials (ADR-0039)

Each row shows the owner, project, scope, creation, expiry, last used, the agents that use it, and whether it is stale. Filter by kind, project and stale.

Last use is recorded when a token authenticates, when a toolkit secret or OAuth token is resolved for a run, and when the credential broker redeems a reference. It is precise to one hour: each credential is written at most once an hour.

Staleness. A daily routine, main.credential_staleness, flags credentials unused for longer than the threshold (90 days by default) and notifies their owners once. A credential with no recorded use counts from the later of its creation and the first time the routine saw it, so an upgrade does not flag every older token at once.

Open Configuration › Credential Review (permission credentials.inventory.manage):

SettingDefaultMeaning
stale_after_days90Days without use before a credential is stale
stale_actionflagflag only flags and notifies. revoke also revokes stale personal access tokens and credential use grants.
trimming_window_days30The window the permission trimming report compares against

To revoke one credential by hand, select Revoke on its row (permission credentials.inventory.manage) and enter a reason. Toolkit secrets, MCP and provider OAuth tokens and credential references are issued by other systems: Elitea flags them, but revoking them is answered with 409 credential_not_revocable; rotate or revoke them where they were issued.

  • Audit events credential.stale.flagged and credential.revoked (with the reason and who revoked it).
  • Owner notifications credential_stale and credential_revoked_stale.

Agents are granted more than they use: a toolkit added for a test, a tool nobody calls, a credential grant or an egress host left after a change. Every unused grant is reach an attacker or a confused agent could use.

The trimming report compares what each agent is granted (tools, toolkits, credential grants and their scopes, egress hosts) with what its run step records and egress summaries show it used in the window (30 days by default). Each grant shows its uses and last use; one not used in the window is a removal candidate, but only once it has been granted for the whole window. A grant younger than the window (a toolkit attached last week, an egress host added yesterday) shows as Too new to judge: it is not a candidate and its owners are not notified. A toolkit is dated by when it was attached and a credential grant by its last change; a tool added to an edited selection and an egress allowlist entry, which carry no date of their own, by when the weekly routine first saw them.

  • Per agent: the Permission trimming tab in the agent’s ⋮ › Governance dialog (permission agents.trimming.manage, held by the project admin role).
  • Across projects: Admin › Permission trimming (credentials.inventory.read to read, credentials.inventory.manage to remove).

Remove acts on one item at a time through the normal edit path, with its validation: a tool or toolkit is removed from the agent’s draft version, a credential grant is revoked, and an egress host is removed from the project or platform allowlist (which also needs models.project_settings.edit for a project entry). Published versions are not changed; publish the edited draft through the usual approval.

A weekly routine, main.permission_trimming, recomputes the report and notifies the agent’s owners (its registry owners, else its author) of new candidates only.

A removal re-checks the item when it runs: an item that has since been used, is too new, or is no longer granted is refused (trim_item_used, trim_item_too_new, trim_item_unknown) rather than removed from a stale report. Taking an egress host off an allowlist on the settings page also resets its age, so a host added back waits a full window again. Each removal is an audit event permission.trimmed with the agent, the item and who removed it. Owner notifications are permission_trimming_candidates.

  • Decide the window that reflects how your agents are used; a grant used only at quarter end needs a longer window.
  • Act on the report: the platform lists candidates and removes on request, it does not remove on its own.

An access review asks who holds which role, where, and what that role allows. Without an export, a reviewer reads the Users and Roles pages by hand, and a governance tool cannot run a campaign at all.

  • CSV and JSON download. GET /api/v2/admin/entitlements (permission entitlements.export, held by the platform super_admin and admin roles) answers one row per user, scope (platform, project or agent), role, capability and how it is held (role, owner for agent registry owners). format=csv streams every row from one snapshot as RFC 4180 CSV; format=json pages with limit and offset. Filters are user_id and project_id. The admin console’s Users page has an Export entitlements button for the CSV.
  • A read-only SCIM 2.0 tree at /api/v2/scim/v2/entitlements for identity governance tools: /Users with their groups, roles and grants, /Groups (one per platform role and per project role), and the discovery documents. Paging is startIndex and count; filter takes userName eq "…" and the other attributes in the discovery documents. Write requests answer 405.
  • Capabilities, not only role names. Each role’s permissions are mapped to governance capabilities such as configure_agents, approve_change_requests, attest, approve_connectors and read_run_evidence.

The SCIM tree uses the same SCIM client credential as provisioning (see Authentication), but no client can read it until you allow it:

  1. Create or choose a SCIM client

    Create a SCIM client for the governance tool on Configuration › Authentication, or choose an existing one. Note its id.

  2. Allow the client

    Open Configuration › Entitlement Export (permission entitlements.export) and add the client id to SCIM Clients Allowed (scim_client_ids). The list is empty by default, and every client that is not on it gets 403 with a SCIM error on every path of the tree. Provisioning is not affected.

  3. Point the governance tool at the tree

    Configure the tool with the base URL https://<host>/api/v2/scim/v2/entitlements and the client’s credential.

The list is read on every request, so removing an id stops that client at once.

CSV columns, one row per user, scope, role, capability and grant path:

ColumnMeaning
user_id, user_name, display_nameThe account
user_activefalse for a suspended user; their grants are dormant
scope_typeplatform, project or agent
scope_idThe platform mode (administration, developer), the project id or the agent id
scope_nameIts display name
project_idThe project of a project or agent scope; empty for platform rows
scope_activefalse for a suspended project
roleThe role, or the registry owner role (business_owner, technical_owner)
capabilityOne governance capability the role carries; empty when the role carries none, so every role holder still appears
permissionsThe permissions behind the capability, space-separated
granted_viarole or owner (agent registry ownership)

The file is UTF-8 with CRLF line ends and a header row. Fields with commas, quotes or line breaks are quoted, and a cell starting with =, +, - or @ is prefixed with ' so spreadsheets do not run it. The X-Entitlement-Rows response header carries the number of data rows, so an import can detect a cut-off file.

user_id,user_name,display_name,user_active,scope_type,scope_id,scope_name,project_id,scope_active,role,capability,permissions,granted_via
101,alice@example.com,Alice,true,platform,administration,Platform (administration),,true,admin,suspend,safety.suspend,role
101,alice@example.com,Alice,true,project,201,Alpha,201,true,admin,approve_change_requests,models.applications.governance,role
101,alice@example.com,Alice,true,agent,31,Support Bot,201,true,technical_owner,edit_agent_registry,,owner
102,bob@example.com,Bob,true,project,201,Alpha,201,true,viewer,,,role

Capabilities: configure_agents, configure_toolkits, configure_credentials, approve_credentials, publish_agents, approve_publication, approve_change_requests, override_release_gates, edit_agent_registry, attest, mark_recertification_due, suspend, override_caps, approve_connectors, manage_retention, manage_legal_hold, read_run_evidence, read_audit_trail, review_credentials, revoke_credentials, trim_agent_permissions, manage_access, manage_secrets, manage_agent_identity, approve_agent_identity, manage_projects, administer_platform, export_entitlements. A test fails the build when a new permission is neither mapped to a capability nor marked as not relevant to access reviews, so the export cannot silently miss one.

SCIM resources. A User carries groups (its role groups), roles (project:<id>:<role>, agent:<project>/<agent>:<role>, platform:<mode>:<role>), entitlements (<scope>:<capability>) and the extension urn:elitea:params:scim:schemas:extension:entitlements:2.0:User with every grant as in the CSV. A Group (platform-role-<id> or project-role-<id>) lists its members and, in its extension, the scope, role, capabilities and permissions. attributes and excludedAttributes=members are honoured.

{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", "urn:elitea:params:scim:schemas:extension:entitlements:2.0:User"],
"id": "101",
"userName": "alice@example.com",
"active": true,
"groups": [{ "value": "project-role-301", "display": "project Alpha / admin", "type": "direct" }],
"roles": [{ "value": "project:201:admin", "display": "admin on project Alpha", "type": "project" }],
"entitlements": [{ "value": "project:201:approve_change_requests", "type": "approve_change_requests" }]
}

A Group shows what its role grants on its own. The per-user rows also apply the rule that a project’s own role definition takes precedence over the platform default role of the same name, so use the User resources or the CSV for the effective grants.

  • Every export is audited in centry.audit_events before any row is sent: entity type entitlements, entity name csv, json or scim, the row count in the action, and the administrator or scim:<client> as actor. When the event cannot be written, the export is refused with 503.
  • Refused SCIM reads (403) are recorded as api audit events like other refused requests under /api/v2/scim/.
  • Changes to the allowed clients are recorded as api audit events with the user, the route and the result, like every other configuration save.
  • Decide which governance tools may read the entitlement tree, and review the allowed clients on a schedule.
  • Run access reviews on the exported data, and remove the roles that a review rejects.

An agent can call an MCP server that another system runs, and that server can be an agent too: for example, the MCP endpoint of another Elitea deployment. A plain call tells the remote side which credential called, but not which agent called, for which run, for which user and with which tools. The remote side cannot limit what it does for the caller, and it cannot tell an instruction from a verified agent from text that anyone typed. In the other direction, a remote agent that calls this platform’s MCP endpoint with a personal access token looks like the human who owns the token.

Outbound. When outbound assertions apply to a call, the worker adds the header Elitea-Agent-Assertion: <jwt> to each request to the remote MCP server. The value is a delegation assertion: a JWT that the main service signs with the platform’s agent assertion key (the same key ring as Elitea-native agent assertions). The assertion is not the credential of the MCP server. The server’s own OAuth token or key is sent as before.

What the remote server receives. The assertion tells the remote server the calling agent, the user who started the run, the project, the run, the run’s mandate and the toolkit types of the run (see Assertion claims). This is data about your deployment, so the platform sends it to no third party unless you say so:

outbound_assertionsWhich calls carry an assertion
off (the default)None
allowlistOnly calls to an endpoint whose origin is in assertion_audiences
onEvery call to a remote MCP server

A governed run (one with a security context) always sends an assertion, whatever the setting is. Governance means that every call that a governed agent makes is attributable to the agent, the run and the user, also on the receiving side.

  • The worker asks the main service for each assertion over the runtime control connection (gRPC over mTLS, MintDelegationAssertion). The worker never holds the key. The mint runs beside the worker’s lease renewal, so a slow answer never delays the renewal.
  • The worker sends the endpoint URL as configured. The main service normalizes it with the same function that it applies to the run’s admitted MCP toolkit URLs, and signs aud in that normalized form.
  • The main service signs only for a run that is still live (its lease fence is current), and only for an audience that is the URL of an MCP toolkit admitted in that run or in one of its sub-agents. Any other audience is refused. It also refuses the audience of a configured federation relying party, so a delegation assertion can never stand in for a federation assertion.
  • An assertion lives at most 5 minutes. The worker keeps one assertion per endpoint and gets a new one 1 minute before it expires, so a long MCP session always sends a valid one. When the main service answers that no assertion goes to an endpoint, the worker does not ask again for that endpoint during the run. After a failed mint, it does not ask again for 30 seconds.
  • Without the agent assertion key, no assertion is signed. For a run that is not governed, the worker then calls without one, and the main service only reads the run to give that answer.
  • MCP toolkits that the platform runs itself do not get an assertion.
  • If a governed run cannot get an assertion, the call is refused: the agent gets the tool error agent_assertion_unavailable, and the MCP server is not called. Any other run that cannot get an assertion logs a warning and calls without it.

Inbound. The platform’s MCP endpoint (/app/<project id>/mcp…) reads the Elitea-Agent-Assertion header after it has authenticated the caller:

  • The issuer (iss) must be on the trusted issuer list. The signature must verify against that issuer’s JWKS. The header typ must be elitea-delegation+jwt (a federation assertion, elitea-agent+jwt, is refused), and elitea_purpose must be delegation. If the issuer’s JWKS cannot be fetched (network, timeout, a 5xx answer), the answer is 503 with agent_assertion_unavailable, not a signature refusal.
  • The audience (aud) must be this endpoint: this deployment’s origin and the request path, for example https://elitea.example.com/app/12/mcp.
  • exp must be in the future, and exp must be at most 5 minutes after iat. A clock difference of 30 seconds is tolerated.
  • Each jti is recorded until the assertion expires, with a digest of the credential that first presented it. The same credential can send the same assertion again in one MCP session. A different credential that sends it is refused as a replay. After exp, the assertion is refused as expired.
  • When the assertion is valid, the toolkit list in its elitea_scope claim limits every tool that the caller calls through the endpoint. An agent (or one of its sub-agents) that uses a toolkit type outside the list, and a toolkit tool whose type is not in the list, are refused with agent_invocation_scope_exceeded, and the error names the toolkit. Platform tools that have no toolkit type (for example, the toolkit builder and the secret tools) are never in a scope, so a caller with an assertion cannot call them; the error names elitea_internal. Nothing runs.
  • In the governed profile, a call that invokes an agent must come with an OAuth access token or a verified assertion. A personal access token or a browser session alone is refused with agent_invocation_requires_agent_credential. Humans can continue to use their personal access tokens for tools that are not agents.
  • In the governed profile, an agent invocation from an OAuth client without a verified assertion still runs, but its task is given to the agent as untrusted data (ADR-0043 decision 4): the text is JSON-escaped inside an <untrusted_remote_task> block, after an instruction to the agent to not follow it where it conflicts with its own instructions. A caller with a verified assertion is never framed, and outside the governed profile no task is framed.

A request without the header is not changed by these checks, except in the governed profile (the two rules above).

A decoded example:

{
"alg": "ES256",
"kid": "agent-es-2026-10",
"typ": "elitea-delegation+jwt"
}
{
"iss": "https://elitea.example.com/agent-issuer",
"sub": "agent:binding:6f2c1a9e-0d4b-4c1f-9a51-2b7e3c8d4f10",
"aud": "https://partner.example.com/app/12/mcp",
"iat": 1793000000,
"nbf": 1793000000,
"exp": 1793000300,
"jti": "1c9b7f0e-5a43-4c6e-b8d2-7e0f3a9d2b64",
"sub_profile": "ai_agent",
"elitea_purpose": "delegation",
"agent_instance_id": "8d3e0b7a-2f61-4a9c-9e15-4b0c7d2a6e93:3",
"elitea_tenant": "elitea.example.com",
"elitea_project": "42",
"elitea_run": "8d3e0b7a-2f61-4a9c-9e15-4b0c7d2a6e93",
"elitea_user": {"sub": "user:17", "evidence": "asserted"},
"elitea_mandate": "mandate-7",
"elitea_scope": {
"digest": "sha256:4f1d…",
"toolkits": ["confluence", "github", "mcp"]
}
}
ClaimValue
issThe issuer: this deployment’s origin and /agent-issuer
subThe calling agent: agent:binding:<id> for an agent with a native identity, agent:application:<project>:<agent id> otherwise
audThe remote endpoint, normalized: https, the host in lower-case ASCII (an international host name in punycode, an IPv6 address in brackets), the port when it is not 443, and the path with . and .. segments resolved and no trailing slash
expAt most 5 minutes after iat
jtiA new random id for each assertion
elitea_runThe execution id of the calling run. agent_instance_id adds the run’s generation.
elitea_userThe user who started the run. It is absent for a run that no user started.
elitea_mandateThe run’s mandate, when it has one
elitea_scopeThe canonical toolkit types that the run was admitted with, its sub-agents included, sorted (at most 64), and digest, the SHA-256 of that set

The assertion has no email address, display name, token or toolkit setting. It is at most 8 KiB.

A receiver that is not Elitea verifies the assertion as a JWT:

  1. Get the keys from https://<issuer host>/.well-known/elitea-agent-jwks.json. The issuer metadata is at <iss>/.well-known/openid-configuration.
  2. Find the key by kid, and verify the signature with ES256 or RS256.
  3. Make sure that iss is an issuer that you trust, typ is elitea-delegation+jwt, elitea_purpose is delegation, and aud is the URL that was called, normalized as the table above says.
  4. Refuse the assertion when exp has passed or when exp - iat is more than 300 seconds.
  5. Record jti until exp, and refuse a second use by a different caller.
  6. Limit what you do for the caller to elitea_scope, and log sub, elitea_run and elitea_user with the call.

An inbound assertion that is refused answers HTTP 401 with {"error": "agent_assertion_refused", "code": "<code>"}, and nothing runs. When the check itself cannot be done (the database or the issuer’s JWKS is not available), the answer is 503.

CodeMeaning
agent_assertion_malformedThe header is not one JWT of the expected type and claims, or it was sent more than once
agent_assertion_issuer_not_trustedThe issuer is not on the trusted issuer list
agent_assertion_signature_invalidThe signature does not verify against the issuer’s JWKS
agent_assertion_audience_mismatchaud is not this endpoint
agent_assertion_expiredexp has passed, or iat or nbf is in the future
agent_assertion_lifetime_too_longexp is more than 5 minutes after iat
agent_assertion_replayedA different caller already presented this assertion
agent_assertion_unavailableThe check could not be done (503): the database, or the issuer’s JWKS could not be fetched. On the worker, the tool error of a governed run that could not get an assertion.
agent_invocation_requires_agent_credentialGoverned profile: an agent was invoked with a personal access token or a browser session alone (a tool error, not an HTTP error)
agent_invocation_scope_exceededThe called agent or tool uses a toolkit that the assertion’s scope does not allow; the error names the toolkit, or elitea_internal for a platform tool (a tool error)
  • A2A. The platform has no Agent2Agent (A2A) client. Outbound assertions cover remote MCP servers only, including the MCP endpoint of another Elitea deployment.
  • Other agent endpoints. The inbound checks apply to the MCP endpoint. Other APIs that start an agent keep their own authentication.
  • A separate key. Delegation assertions are signed with the same key ring as federation assertions. The separate token type and the refusal of relying-party audiences keep the two apart.
  • External secrets. The rotation of the credentials that a remote MCP server uses (its OAuth client or API key) stays with the owner of that credential.
  1. Configure the signing key

    Outbound assertions need the agent assertion key ring. Set main.env.ELITEA_AGENT_ASSERTION_CONFIG with at least one key (see Elitea-native agent assertions). Relying parties are not necessary for delegation assertions. Without the key ring, no assertion is signed: a governed run then refuses its remote MCP calls, and any other run calls without an assertion, as before.

  2. Set the outbound switch

    Open Configuration › Remote Agents (permission configuration.governance). Outbound Assertions (outbound_assertions) is off by default. To send assertions to the remote MCP servers that verify them (for example, another Elitea deployment), set it to allowlist and add each server’s origin (https://host or https://host:port, at most 256) to Assertion Audiences (assertion_audiences). Use on only when every remote MCP server that your agents call may receive the claims. A governed run always sends an assertion, whatever this setting is.

  3. Allowlist the issuers that you trust

    Add one entry to Trusted Issuers (trusted_issuers) for each deployment whose agents may call this platform’s MCP endpoint. Each entry is <issuer> for an Elitea deployment (its keys are at the issuer host’s /.well-known/elitea-agent-jwks.json), or <issuer> <jwks_url> for another issuer. Both must be https URLs. The list holds at most 32 entries and is empty by default, so no assertion is accepted until you add an issuer. To bootstrap the list at install time, set main.env.ELITEA_REMOTE_AGENT_TRUSTED_ISSUERS (entries separated by a comma or a new line). These entries are added to the stored list, and a malformed entry stops the main service from starting.

  4. Use the governed profile for agent callers

    In the governed profile, agent invocations through the MCP endpoint need an OAuth access token or a verified assertion. Give each calling agent an OAuth client, or have it send an assertion from an allowlisted issuer.

  • Security audit events. Each mint and each inbound decision is written to the tamper-evident security audit log with the event type ai.elitea.security.v1.remote_agent.assertion. The actions are remote_agent.assertion.minted, remote_agent.assertion.mint_refused, remote_agent.assertion.accepted, remote_agent.assertion.refused and remote_agent.invocation.refused. A mint event names the run, the agent, the mandate, the remote host and the jti. An inbound event names the issuer, the calling agent (sub), the jti, the presenting user and token, and the refusal code. The events are exported with the rest of the audit evidence.
  • Fail closed on evidence. If the mint event cannot be written, no assertion is issued. If the acceptance event cannot be written, the inbound call is refused with 503.
  • The replay record. elitea_identity.remote_agent_assertion_replays keeps the issuer, the jti and a digest of the presenting credential of each accepted assertion until it expires.
  • The configuration history. Every save of Configuration › Remote Agents is recorded as an api audit event with the user, the route and the result.
  • Decide which remote MCP servers may receive the claims of your runs (user, project, run, mandate and toolkit types), and keep Assertion Audiences to those servers.
  • Decide which issuers you trust, and keep the trusted issuer list short. An issuer on the list can make its agents act on this platform within the scope it asserts.
  • Configure and rotate the agent assertion keys of your deployment, and tell the operators of the remote MCP servers that you call to trust your issuer.
  • Run agents that call or are called by remote agents in the governed profile.
  • Give OAuth clients, not personal access tokens, to the remote agents that call this platform.