Security
Admin security invariants: server-side gates, credential tiers, and audit recording.
The admin package exposes the most privileged surface in a Neuralis deployment, so it applies the platform's deny-by-default posture (see the Security model) without exception. These are the invariants its routes and UI uphold.
Server-side gates, always
Every route exports a feature that the package route dispatcher enforces
against the caller's real granted features before the handler runs.
Hidden UI tabs are never the defence — a request crafted directly against the
API hits the same gate as one from the dashboard. Read-only routes are gated
the same way as mutations.
Privilege is also graduated inside endpoints where one gate is not enough.
Because project.dashboard is granted to every role, the routes it gates scope
by the caller's session and escalate to the matching platform.* feature —
the tier whose whole purpose is crossing the project boundary, and which no role
holds by default:
- Dashboard stats include the platform-wide user census only with
platform.usersand the project census only withplatform.projects; the rest of the payload still works without either, and the counters read 0 rather than erroring. - The usage endpoint requires
platform.projectsfor platform scope andplatform.usersfor per-user grouping, on top of itsproject.dashboardmodule gate, because per-user spend and cross-tenant series are user-data surfaces. The Codex-savings endpoint at platform scope requires BOTH — it aggregates across every tenant AND labels per-user rows. The chart UI mirrors the gates by not offering those dimensions to ungranted callers. - The project list returns only projects the caller belongs to, and project
detail is
404for a project they are not a member of — unless they holdplatform.projects. The Projects browser tab mirrors this with a "your projects" hint. - Canvas user detail is returned only for members of the session project
(
platform.userslifts the restriction), and never includes auth-flow fields such asmustChangePassword. - Source listing returns
400rather than fanning out to every project when the session carries no project id. - Caller-supplied path and query ids (agent / user / package ids) are rejected if they contain path-traversal segments before any filesystem path is built.
Two credential tiers, values write-only
Credential reads (project.credentials) return only the catalog: id,
source, and a hasValue flag per scope. Stored values are never returned to
the UI, to agents, or in any response body.
Credential writes (PUT/DELETE) require the separate
project.credentials.write feature; a deployment can grant it to any role. The
target scope is also ownership-checked: a write holder may only write to their
own session scope (their project, their user, the agents they own). Reaching
BEYOND it — the platform-wide scope, which injects a key every project resolves,
or another project / user / agent — additionally requires platform.scope,
which no role holds by default.
Holding that key is not a membership bypass. Every cross-scope arm is
confined to principals inside the caller's own projects: another project must be
one they belong to, another user must share a project with them, another agent
must live in one of their projects. An unresolvable target is refused. The one
arm with no membership bound is the platform-wide scope itself — a value every
project resolves has no tenant to confine it to, which is exactly why
platform.scope carries no default grant.
Every write and delete is logged with the credential id and target scope, never
the value, and both are capped at 10 mutations per minute per user — a
further attempt answers 429 until the window rolls. The cap lives in code
rather than in an editable setting on purpose: the generic per-route rate limit
does not apply to first-party packages, so this surface carries its own.
Priority-gated role assignment and role editing
Assigning a role honors the platform-wide rule: a caller may only hand out a role whose priority is equal to or weaker than their own (see Features and access). It is enforced server-side at every assignment boundary — invitations, member role changes and agent provisioning — and removing a member follows the same rule, so a caller cannot remove someone currently stronger than themselves.
Editing what a role IS is a stricter act and follows two further rules, both keyed on the priority number rather than on any role name:
- Strength. You may edit only roles strictly weaker than your own priority. A role at or above your strength cannot be modified and cannot be deleted — including your own role, which is what makes locking yourself out of your own project structurally impossible. Defining a new role is the one place equality is allowed.
- Conservation. You may grant only, and revoke only, features you hold yourself. Deleting a role runs the same check over everything that role held, so deletion is not a way around it.
Role and membership maps are submitted whole, so leaving an entry out is a deletion and is authorized as one.
The admin package's own config/roles PATCH is one of these boundaries: it
derives the project id from the session (a body project id may only agree with
it), requires the caller to be able to manage roles, and then runs the write
through the same shared body the host's project route uses — one implementation,
not a second copy, so the two surfaces cannot answer differently for the same
request. It does not write the project record itself: the decision and the write
both happen in the host, on the record as it is on disk at that moment, so the
record's structural invariant runs on the same write and a role save concurrent
with another change to the project cannot erase it. Built-in priorities stay immutable and every priority must be an integer
in 1..99. The admin UI clamps priority inputs and renders out-of-reach roles
read-only as a courtesy; the server decision is authoritative. Fuller treatment:
Roles and features.
Real identity for skill calls
The five admin skills authenticate with the
per-stream session ticket, which resolves to the caller's real
SessionContext — there is no service account, synthetic owner, or
admin-token path that a skill could escalate through. Admin routes are
privileged by their declared feature, checked against that real identity,
not by the token itself. The prefix carries the tier: project.* acts inside
the session project, platform.* crosses the project boundary. Successful skill calls are audited as
skill.session_call with the calling bundle's id; failed ticket
verifications are audited with their failure reason and refused.
Explicit destructive actions
The vector store reset drops collections and the embedding lock — and with
them every brain:// file and its version history, which live only in the
vector store — so it
requires the literal confirmation phrase RESET in the request body. There
is no GET-triggered variant and no implicit confirmation — a misrouted
request cannot wipe the vector store. Because the reset is platform-global
(it drops every project's collections by env prefix), it is gated by the
dedicated platform.vector.reset feature rather than the platform.vector read
tier that gates vector/status — the blast radius matches the feature.
Moving the vectors to a new embedding model is the non-destructive path: the
vector/collection rebuild re-checks platform.vector.maintain in the handler
before anything else, accepts only its three rebuild actions, and changing the
target model through config starts nothing on its own.
Validated mutation paths
Source configuration changes go through brain-core's single validated mutation point; the admin route never writes source JSON to disk directly. Platform settings persist through the platform config store, with environment-derived settings exposed strictly read-only.
Audit trail
Privileged operations are recorded to the audit log. That log is platform-global
— its entries carry no project id — and lives in the platform app zone rather
than in the package's own data zone, so what protects it is the route gate:
platform.audit, the module feature of the audit reader and an in-handler
re-check on the global log source, not a path policy.
A second platform-global file sits beside it: the route log, one structured
line per package-route dispatch — matched pattern, method, status, duration, the
verified caller, and which gate refused a call. It answers a different question
from the audit log ("what was refused, and what faulted" rather than "who
changed what") and, because it spans every tenant, it rides the same
platform.audit gate rather than declaring one of its own. It never records a
request path.
The project-scoped log sources (a project-installed package's log and the per-agent
run logs) stay on project.audit; a first-party package's own logs live in the
platform zone and need platform.audit like the audit and route logs. The path
each source reports is a display key
(<package>/logs/<file>), never an absolute host path. The Logs tab and the
inspect-admin skill read events through those gated log routes. The manifest's
URI policy is a defensive baseline over the package's own namespace:
data://<self>/audit/** and data://<self>/logs/** are read-denied below the
owner/admin tier and write-disabled for every role. The logs rows now guard
only log files written before the move to the platform zone (see
URI policies). The admin
package declares no exec permission and no exec URI policy.
Provider status without environment secrets
The health report counts configured LLM providers by querying the encrypted
credential store — it never inspects *_API_KEY environment variables for
provider availability, so what it reports reflects exactly what the credential
pipeline will resolve at stream time.
One environment secret is deliberately in scope, and only for authenticating the
admin panel's own vector-database calls: QDRANT_API_KEY is boot-critical
infrastructure rather than a credential, because the vector client is
constructed synchronously at boot, before anything in the encrypted store can be
decrypted. It is never read as a provider signal, it is sent only to the vector
database (the vector page's embedding-host probe targets a different service and
never carries it), and its value never appears in a response — the health check
reports reachability and the endpoint URL, and the Config tab shows the key as
(set) or (unset).