Crux Daemon · 7. Auth and scopes
The route-auth middleware defaults to shadow mode: contract violations are logged and never blocked. Only the per-handler scope checks bite out of the box. Everything else in this chapter is detail on top of that fact.
This chapter is reference. For the daemon's overall shape see chapter 1; for identity and capability tokens see chapter 14.
7.0 In plain English
Two separate questions get asked of every request that reaches the daemon, and it helps a great deal to keep them apart. The first is who are you, which is authentication, and it is answered by CORECRUXD_AUTH_MODE: either a JWT proves the caller's identity, or, under off and dev_scopes, nothing does. The second is are you allowed to do this particular thing, which is authorisation, and it is answered by scopes: short strings like a read or admin permission that the caller must be carrying for the handler to proceed.
Scopes are checked at two independent layers, and the difference between them is the single most important thing in this chapter. The layer that actually enforces is the per-handler check: each handler calls a require_http_* function directly and returns 403 if the caller lacks the scope. The other layer is route-auth middleware, which classifies every route against a hand-maintained contract of what it should require, and compares that with what the caller presented. In its default posture that middleware is a camera, not a lock. It observes the mismatch, records it, and lets the request through.
That is a defensible design, because a contract table maintained by hand will disagree with reality while it is being built, and a lock built on a wrong table locks out legitimate traffic. It is only dangerous when it is misread. If you look at the route-auth contract, see that a route is classified as admin-write, and conclude the route is therefore protected, you have concluded something the default configuration does not do. The protection on that route is whatever require_http_* call the handler itself makes, and nothing else.
You will come here at two moments. When you first move the daemon off loopback, because that is when authentication stops being theoretical, and §7.1 plus §7.5 tell you what is exposed and what is public by design. And when a call returns 403 and you need to know which scope it wanted, which is §7.2 for the vocabulary and §7.3 for the failure shapes, including the missingScopes array in the response body that names exactly what was absent.
The trap worth stating twice, because it is not obvious from reading the code: there is no axum extractor doing scope checks for you. Handlers call the check imperatively, so a handler that forgets the call has no compile-time backstop. Nothing will fail to build. Under CORECRUXD_AUTH_MODE=off, separately, the scope primitives return success immediately without checking anything at all, which is correct for local development and is exactly why §7.9 gathers the sharp edges in one place.
7.1 CORECRUXD_AUTH_MODE: the four values
There is no default. The daemon refuses to start without it, see chapter 4 §4.3, conditions 1 and 2. It can be supplied either as the environment variable or as daemon.auth_mode in the YAML config file.
AuthMode::parse (auth.rs:58-69) matches on s.trim() and is case-sensitive per arm, each casing is enumerated literally, so mixed casing such as Off or Jwt_Hs256 does not parse and aborts startup.
| Accepted literals | Resolves to | Defined at |
|---|---|---|
"" (empty), off, OFF | Off | auth.rs:60 |
dev, DEV, dev_scopes, DEV_SCOPES, devscopes, DEVSCOPES, dev-scopes, DEV-SCOPES | DevScopes | auth.rs:61 |
jwt, JWT, jwt_hs256, JWT_HS256, jwt-hs256, JWT-HS256 | JwtHs256 | auth.rs:64 |
jwt_jwks, JWT_JWKS, jwt-jwks, JWT-JWKS, jwks, JWKS, oidc, OIDC, jwt_oidc, JWT_OIDC, jwt-oidc, JWT-OIDC | JwtJwks | auth.rs:65 |
| anything else | invalid, startup aborts | auth.rs:67 |
Canonical serialisation, used in evidence and /v1/version, is off, dev_scopes, jwt_hs256, jwt_jwks (auth.rs:71).
What each mode does
| Mode | Credential | Behaviour |
|---|---|---|
Off | none | http_ctx returns an empty-scope context with TenantAllow::Any; every require_http_* and require_grpc_* returns Ok(()) immediately; scope_bypass = true, so has_scope() is true for every string, including scopes that do not exist (auth.rs:849, auth.rs:1096) |
DevScopes | X-Corecrux-Scopes header, or Authorization: Bearer <space- or comma-separated scopes>, the bearer is the scope list, unsigned | Scopes are taken verbatim from the request. No identity, no tenant claim. A request with neither input gets a 401 (auth.rs:385) |
JwtHs256 | Authorization: Bearer <HS256 JWT> | Verifies the HS256 signature with CORECRUXD_JWT_HS256_SECRET. exp and nbf validated with 30s leeway; optional iss and aud pinning (auth.rs:514) |
JwtJwks | Authorization: Bearer <JWT> | Verifies against a JWKS, inline JSON, file, URL or OIDC discovery. Algorithm allowlist defaults to RS256; kid lookup with rate-limited refresh-on-miss (auth.rs:698) |
Both JWT modes may additionally accept a registered MCP agent token as an HTTP fallback, see §7.6.
DevScopes is unauthenticated self-assertion. The bearer token is the scope list. Its only guard is the loopback-bind rail, which CORECRUXD_ALLOW_INSECURE_DEV_AUTH_BIND=1 disables. Do not run it on a reachable interface.
Per-mode environment requirements
Off, none.DevScopes, none.JwtHs256, requiredCORECRUXD_JWT_HS256_SECRET, raw bytes orbase64:-prefixed, at least 32 bytes unlessCORECRUXD_ALLOW_WEAK_HS256_SECRETis set. OptionalCORECRUXD_JWT_ISS,CORECRUXD_JWT_AUD(auth.rs:237).JwtJwks, one ofCORECRUXD_JWT_JWKS_JSON,_PATH,_URL, orCORECRUXD_JWT_OIDC_DISCOVERY_URL, each with a legacyCORECRUXD_JWKS_*alias. OptionalCORECRUXD_JWT_ISS,_AUD,_ALGS(defaultRS256),_JWKS_MIN_REFRESH_SECONDS(default 30) (auth.rs:254).
The startup posture gates
| Gate | Rule | Location |
|---|---|---|
| Dev-auth network bind | Off and DevScopes may not bind non-loopback HTTP or gRPC without CORECRUXD_ALLOW_INSECURE_DEV_AUTH_BIND=1 | main.rs:2030 |
| Replicated commit plus JWT | A JWT mode with CommitLevel::ReplicatedCommit requires CORECRUXD_REPLICATION_AUTH_BEARER | main.rs:2039 |
| MCP bind | MCP on a non-loopback address with an empty agent registry aborts without the same override | main.rs:2050 |
| Agent-token strength | An agent-token variable present but invalid aborts unless CRUX_MCP_ALLOW_EMPTY_AGENT_REGISTRY=1 | main.rs:1967 |
| HS256 secret strength | The secret must decode to at least 32 bytes unless CORECRUXD_ALLOW_WEAK_HS256_SECRET=1 | auth.rs:348 |
One sharp edge for anyone embedding the config loader: config.auth_mode silently falls back to DevScopes when parsing fails (config.rs:886). That is safe only because main aborts first. Any other caller of load_config() that does not replicate the two guards inherits a fail-open default.
7.2 The complete scope list
Scopes are plain strings, not an enum. There is no central registry. They are declared inline at route contracts, at handler require_http_* calls, and in a handful of named constant arrays. The list below is the complete set that any authorisation check compares against.
Core HTTP and gRPC scopes
| Scope | Grants | Canonical definition |
|---|---|---|
admin:read | Universal read override. Satisfies every read-class route and bypasses the tenant check on the any-of variant. Also the console read plane and /v1/admin/* GET | route_auth.rs:123; check auth.rs:1379 |
admin:write | Universal write override: /v1/admin/* mutations, POST /v1/passports, /v1/local/ingest, /v1/credits/*, /v1/legal-holds, device-grant approve. Also grants the passport-impersonation override | route_auth.rs:122; override auth.rs:1217 |
facts:read | /v1/features/capabilities GET, /v1/sync/tenants/* GET, /v1/orchestrators, /v1/punchcards, /v1/activity, /v1/cost, /v1/observe/* | route_auth.rs:239 |
facts:write | Fact, entity, edge, kind and session mutations; /v1/cases; /v1/memory/import; /v1/append; /v1/studio/library/* install; /v1/work writes; /v1/coord/* writes | route_auth.rs:227; handler facts.rs:139 |
query:read | The general read scope: /v1/query/*, /v1/projections/entity/*, /v1/receipts/*, /v1/replay/*, /v1/events/*, /v1/observations/*, /v1/ops/*, /v1/bootstrap/*, /v1/audit/*, /v1/studio/*, /v1/cases/retrieve, /v1/context, /v1/quota | route_auth.rs:134 |
sessions:read | Session reads; part of the broad read union on /v1/work, /v1/passports, /v1/agents/*; /v1/coord/* GET | route_auth.rs:405 |
sessions:write | Session mutations, /v1/coord/* writes, the OpenAI shim | route_auth.rs:227 |
receipts:read | Receipt reads; part of the read union on /v1/receipts/, /v1/replay/, /v1/events/, /v1/observations/, /v1/ops/, /v1/bootstrap/, /v1/audit/ | route_auth.rs:194 |
exports:read | Export reads; the same union, plus /v1/incidents/*/export | route_auth.rs:474 |
replication:write | The only scope for /v1/internal/replication/*, the leader-to-follower segment push | route_auth.rs:103 |
events:read | gRPC only: ReadStream, tenant-checked | grpc.rs:777 |
events:write | gRPC only: AppendBatch, tenant-checked | grpc.rs:764 |
compute:embed | POST /v1/compute/embed. The only accepted scope, no admin override | compute.rs:29 |
provenance:write | /v1/provenance/*, alongside admin:write | provenance.rs:622 |
integrations:install | Install an integration pack. Treated as a write scope by the route-auth invariants test | integrations_github.rs:44 |
integrations:grant | Grant an installed pack | console.rs:2251 |
integrations:disable | Disable a pack | integrations_github.rs:92 |
passport:impersonate | Permits X-Corecrux-Passport-Id to differ from the verified token identity. The only alternative to admin:write for that override | auth.rs:1216 |
tenant:chunks:read | Console per-tenant chunk listing, tenant-checked | console.rs:3037 |
tenant:content:preview | Console chunk content preview, tenant-checked. Classified read-only by the route-auth invariants | console.rs:3107 |
enrichers:first_party | POST /v1/actions/enrich, an alternative to admin:write | actions.rs:16 |
Entitlement-shaped scopes
These are capability claims (product.rs:78) that are also accepted as bearer scopes on their own surface, checked with an any-of against the claim plus a fallback.
| Scope | Surface | Definition |
|---|---|---|
agent_brief:pro | /v1/workbench/brief | workbench.rs:48 |
context_pack:budgeted | /v1/workbench/context-pack | workbench.rs:49 |
impact:preflight | /v1/workbench/impact-preflight | workbench.rs:50 |
ledger:history | /v1/workbench/command-ledger | workbench.rs:51 |
audit:triage | /v1/workbench/audit-triage | workbench.rs:52 |
reasoning:timeline | /v1/workbench/reasoning-timeline | workbench.rs:53 |
handoff:v2 | /v1/workbench/handoff | workbench.rs:54 |
route_probe:lab | /v1/workbench/route-probe | workbench.rs:55 |
api_drift:check | /v1/workbench/api-drift | workbench.rs:56 |
policy:simulate | /v1/workbench/policy-simulation | workbench.rs:57 |
gpu1:answer, gpu1:rerank, gpu1:enrich, gpu1:coverage, gpu1:developer | /v1/gpu1/*, tenant-checked with an admin:write fallback. Only present with --features hosted-surfaces | gpu1.rs:48 |
replay:answer | The replay-answer lane | product.rs:88 |
console:workbench | The console workbench entitlement | product.rs:108 |
The full claim catalogues are entitlement claims, not bearer scopes, except for the subset above. Do not document the whole catalogue as an auth scope list.
Two namespaces that are not HTTP scopes
- The MCP capability ladder,
tool:list,tool:invoke:read,tool:invoke:metered,tool:invoke:side_effect,tool:invoke:destructive. Derived from a passport's reputation tier and returned byresolve_principal(principal.rs:72). Federation caps this totool:listandtool:invoke:read(policy.rs:60). - Sync-peer capabilities, the pull and push capabilities from the capability-token crate, used only by the mutual-auth sync plane (sync.rs:53).
Scope-string semantics
- Parsing splits on comma or ASCII whitespace, trims, drops empties, and collects into a
BTreeSet(auth.rs:363). - Comparison is exact string equality. There is no prefix or wildcard matching (auth.rs:804).
- The one prefix rule anywhere:
matched_scope.starts_with("admin:")skips the tenant check (auth.rs:1379). - Claim sources for scopes in JWT modes:
scope(string),scp(string and array),scopes(array),permissions(array), the union of all four (auth.rs:422).
7.3 How scopes are checked
There are two independent layers, and the first never replaces the second, the module doc says so explicitly (route_auth.rs:22).
- Route-auth middleware, coarse, contract-driven, and in shadow mode by default. See §7.4.
- Handler-level checks, direct
require_http_*calls inside each handler. This is the layer that actually enforces.
The primitives
| Function | Semantics | Location |
|---|---|---|
require_http_scopes(auth, headers, &[..]) | all-of; returns Ok immediately when the mode is Off | auth.rs:1320 |
require_http_any_scope(auth, headers, &[..]) | any-of; Ok when the mode is Off | auth.rs:1340 |
require_http_any_scope_for_tenant(…, tenant_id) | any-of plus a tenant check, skipped for any admin:* match | auth.rs:1359 |
require_http_scopes_for_tenant(…, tenant_id) | all-of plus an unconditional tenant check | auth.rs:1388 |
require_grpc_scopes | all-of: dead code, no live caller | auth.rs:1413 |
require_grpc_scopes_for_tenant | all-of plus a tenant check; the only live gRPC gate | auth.rs:1434 |
There is no axum extractor. Handlers take headers: HeaderMap and call these functions imperatively. That is a deliberate design, and it means a handler that forgets the call has no compile-time backstop.
Failure shapes: an all-of failure is a 403 carrying code: "MISSING_SCOPE" and a missingScopes array; an any-of failure carries missingAnyScope. See chapter 8.
7.4 The route-auth middleware, and its shadow default
classify_route(method, path) (route_auth.rs:75) is a hand-maintained ordered if-chain over axum route templates, first match wins. It returns a RouteAuthContract of { class, scopes, feature_gate }, where scopes is an any-of accepted set.
RouteAuthClass is one of Public, Read, Write, AdminRead, AdminWrite, InternalReplication, FeatureGated (route_auth.rs:34).
The enforcement posture comes from CORECRUXD_ROUTE_AUTH, read once at router build time (route_auth.rs:564):
| Value | Behaviour |
|---|---|
off | The middleware is pass-through (route_auth.rs:612) |
enforce | Classified routes require their scopes. Unclassified routes and requests with no matched template fail closed with 403 (route_auth.rs:621) |
| anything else, including unset | shadow: the default. The contract is evaluated, a route_auth_shadow_mismatch line is logged, and the request continues (route_auth.rs:586, route_auth.rs:685) |
Say it plainly: out of the box, the route-auth layer blocks nothing. It is an observation tool. Set CORECRUXD_ROUTE_AUTH=enforce to make it an enforcement tool, and expect the console asset routes to start returning 403 when you do, see §7.5.
One more thing that is documentation rather than enforcement: feature_gate on a contract is never read by the middleware (route_auth.rs:50). Flag gating lives in the handler.
The middleware's decision order (route_auth.rs:607):
- Mode
off, pass. - Read the method and matched path; a missing template is 403 in enforce, a warning in shadow.
- No classification is 403 in enforce, a warning in shadow.
- Sync mutual-auth bypass: if
sync_mutual_authis on and the path is one of the five peer routes, the middleware defers entirely; those routes are authorised cryptographically in the handler (route_auth.rs:664). That bypass is an exact-path list; a new/v1/sync/tenants/...route will not inherit it. Publicpasses with no auth in every mode.- Otherwise, an any-of scope check against the contract.
7.5 The public routes
Exhaustive, from classify_route (route_auth.rs:79).
Exact-match public: GET /healthz, GET /readyz, GET /metrics, /session, /invocation/verify, GET /v1/openapi.json, GET /v1/version, GET /v1/witness/smoke, POST /v1/sync/handshake/nonce.
Prefix-public: everything under /v1/auth/ (route_auth.rs:94), the seven registered rails: GET /v1/auth/whoami, POST /v1/auth/tailscale/token, and the five device-grant routes start, token, approve, refresh, revoke.
That prefix has no per-route narrowing. Any future /v1/auth/* route is public by default. device/approve is the sensitive one, and it is gated inside the handler by require_http_scopes(&["admin:write"]) (auth_device.rs:292), not by its route class.
The console HTML routes are outside the auth matrix entirely. /, /console, /console-assets/{name}, /console-v2/{name}, /console-3d/{*path} and /activate are registered at console.rs:294 and merged after .with_state, so they are absent from classify_route. The completeness test does not parse console.rs. Consequence: they are served with no auth under off and shadow, and would return 403 under enforce. They are gated only by CORECRUXD_CONSOLE_ENABLED.
The OpenAPI route table carries a parallel, purely documentary auth column (openapi.rs:118): 14 public, 77 read, 60 write, 51 admin-read, 26 read-write, 24 admin-write, 43 feature-gated, 5 admin, 1 internal. It is drift-tested against the router but not against classify_route.
7.6 Token formats accepted
The daemon terminates plain HTTP/1.1 and HTTP/2 over TCP (main.rs:2217). There is no TLS or mTLS anywhere in corecruxd. TLS is an upstream-proxy concern, and every credential below rides plaintext until one is in front.
| Format | Mode | Detail |
|---|---|---|
| Dev scope bearer | DevScopes | X-Corecrux-Scopes: receipts:read,exports:read or Authorization: Bearer receipts:read exports:read. The header wins over the bearer. No signature, no identity, no expiry. Both Bearer and bearer casings accepted (auth.rs:371) |
| HS256 JWT | JwtHs256 | HS256 only. exp and nbf required-checked with 30s leeway; iss pinned only if CORECRUXD_JWT_ISS is set; aud likewise (auth.rs:514) |
| JWKS or OIDC JWT | JwtJwks | Algorithms from CORECRUXD_JWT_ALGS, default RS256. Supported: RS256/384/512, ES256/384, PS256/384/512. HS256 is not accepted in this mode. The header alg must be in the allowlist before decode. Key sources, first non-empty wins: inline JSON, file, OIDC discovery, URL. JWK types honoured are RSA and EC; use must be sig if present; kid is required or the key is skipped. A single-key JWKS resolves a kid-less token; multi-key without a kid is rejected (auth.rs:698) |
| MCP agent token | MCP plane, and HTTP as an opt-in fallback | Opaque, 32 to 256 bytes, charset [A-Za-z0-9._~-]. Stored as BLAKE3 hashes only; lookup is constant-time (agent.rs:178) |
| OAuth bearer | MCP plane | Hosted-client bearer validated by RFC 7662 introspection, when configured. Grants read-only; writes are rejected before dispatch by a method allowlist (server.rs:173) |
| Device grant | /v1/auth/device/* | RFC 8628. Codes TTL 600s, poll interval 5s, user-code alphabet excludes 0, O, 1 and I. Tenant and scopes come from the approver, never the polling client (auth_device.rs:31) |
| Tailscale identity | /v1/auth/whoami, /v1/auth/tailscale/token | The Tailscale-User-Login header is trusted only when the peer is loopback or inside CORECRUXD_TS_TRUSTED_PROXY_CIDRS. The allowlist maps the identity to a tenant and scopes, and the mapped tenant is authoritative (auth_rails.rs:47) |
| Sync peer capability token | /v1/sync/* when mutual auth is on | Four headers: x-crux-peer-token (base64 capability token, canonical round-trip enforced, 16 KiB cap), x-crux-peer-pubkey, x-crux-peer-nonce (32 bytes, issued by the public nonce route), x-crux-peer-sig. Not TLS (sync.rs:60) |
| Replication bearer | leader to follower | CORECRUXD_REPLICATION_AUTH_BEARER, a client-side credential the leader attaches, auto-prefixed with Bearer if absent (grpc.rs:860). The server side of /v1/internal/replication/* is authorised by the ordinary replication:write scope |
Tokens the daemon mints: mint_scoped_jwt_from_env signs HS256 with the same secret (loopback_auth.rs:179), with nbf backdated 30 seconds. Issuance rails cap the TTL at 300 seconds (auth_rails.rs:49).
The agent-token HTTP fallback, and its default
Under a JWT mode, if JWT verification fails, the token is retried against the agent registry when CORECRUXD_HTTP_ACCEPT_AGENT_TOKENS is truthy (auth.rs:146). Agent tokens carry no claims, so every accepted token maps to one operator-configured scope and tenant pair:
- Scopes from
CORECRUXD_AGENT_TOKEN_HTTP_SCOPES, default{admin:read, admin:write, facts:write, query:read, sessions:read, sessions:write}, the default includesadmin:write. - Tenant from
CORECRUXD_AGENT_TOKEN_HTTP_TENANT, default*, meaning every tenant. - Identity is stamped
agent:<name>for both subject and passport id.
Enabling this flag without also setting the scopes and tenant grants a claimless opaque token full admin write across every tenant. The fallback is not wired into the gRPC path.
Not implemented
- mTLS and client certificates, none. There is no TLS at all in
corecruxd. - An
X-API-Keyheader, not implemented in this daemon. - Cookies, deliberately excluded. The console CORS layer does not allow credentials, because the JWT rides an
Authorizationheader (console.rs:285).
7.7 Headers the daemon reads
| Header | Purpose |
|---|---|
Authorization: Bearer <token> | A token, or in DevScopes a literal scope list (auth.rs:373) |
X-Corecrux-Scopes | Scope list, comma- or whitespace-separated (auth.rs:363) |
X-Corecrux-Passport-Id | The acting passport. See the binding rules below |
X-Corecrux-Tenant-Id | Tenant selector for writes (auth.rs:1151) |
x-request-id, traceparent | Correlation, sanitised at ingress |
Passport binding rules (auth.rs:1186):
| Mode | Token claim | Header present? | Result |
|---|---|---|---|
Off or DevScopes | - | any | Header taken verbatim, unverified, any caller may assert any passport id |
| JWT | any | absent | The claim wins |
| JWT | present and equal | present | Accepted |
| JWT | any | present, differs, caller has passport:impersonate or admin:write | The header wins |
| JWT | absent | present | 403 PASSPORT_HEADER_UNBOUND |
| JWT | present, differs, no override scope | present | 403 PASSPORT_HEADER_MISMATCH |
The header is shape-validated at ingress: 1 to 128 ASCII characters from [A-Za-z0-9._:-], else 400 (ingress.rs:207).
canonical_passport_claim_verified is true only when the passport_id claim was present and non-empty on a cryptographically verified JWT. Legacy aliases and sub are valid identity for ordinary routes but cannot authorise four-eyes boundaries (auth.rs:405).
7.8 Tenant isolation
Tenant authority has exactly one source: the bearer token's claims (auth.rs:442). Never a request body. Off and DevScopes both yield TenantAllow::Any; there is no tenant isolation whatsoever in the dev modes.
The enforcement primitive, require_tenant_allowed (auth.rs:822):
Anyallows.Only(set)allows only if the set contains the tenant, else 403TENANT_FORBIDDENwith atenantIdextension.Missinggives 403TENANT_CLAIM_MISSING.
Any admin:* scope match short-circuits the tenant check entirely on the any-of variant (auth.rs:1379). An admin:read token is cross-tenant by construction.
Write stamping is dark by default
TenantStampMode from CORECRUXD_TENANT_WRITE_STAMP (auth.rs:964): 1, true, on or enforce mean on; shadow or audit mean shadow; anything else, including unset, means off.
| Mode | Writes stamp | Reads resolve |
|---|---|---|
Off (default) | always default | always default |
Shadow | always default, but logs what On would have done | default |
On | the resolved tenant | a single-tenant token resolves to that tenant, else default |
Under On: Missing resolves to default; Any uses the x-corecrux-tenant-id selector or default; a single-tenant token uses that tenant; a multi-tenant token without a selector gets 403 TENANT_SELECTOR_REQUIRED; a selector outside the set gets 403 TENANT_FORBIDDEN.
Blast radius: resolve_write_tenant and resolve_read_tenant have exactly two call sites in the whole daemon, facts.rs:179 and facts.rs:187. Every other write plane stamps default unconditionally. The in-source note at facts.rs:161 states that the MCP write plane has no per-token tenant claim and uniformly stamps default; MCP-plane multi-tenancy is an open follow-up.
Other tenant surfaces: gRPC AppendBatch and ReadStream check req.tenant_id; sync routes check the path segment and reject a / in the tenant id; the console chunk and preview routes are tenant-checked; POST /v1/passports is hard-coded to tenant default (passports.rs:152).
Agent-private fact visibility is a separate mechanism: the __agent::<owner>:: entity prefix, visible to the owning identity only (scope.rs:12). Group-shared private visibility is explicitly not implemented (scope.rs:49).
7.9 The sharp edges, gathered
Everything in this list is stated elsewhere in the chapter. It is repeated here because an operator reviewing a deployment needs one place to look.
| # | Finding | Evidence |
|---|---|---|
| 1 | Route-auth is shadow-only by default. Contract violations are logged, never blocked | route_auth.rs:578 |
| 2 | Tenant write-stamping is off by default and reaches only two call sites. Every write lands on tenant default | auth.rs:986 |
| 3 | /v1/auth/ is public by prefix. Any route added under it is unauthenticated by default | route_auth.rs:94 |
| 4 | Console HTML routes are outside the auth matrix, open in shadow, 403 in enforce | console.rs:294 |
| 5 | require_grpc_scopes is dead code. Only two gRPC RPCs are scope-gated at all; the rest have no scope check | auth.rs:1413 |
| 6 | The agent-token HTTP fallback defaults to admin:write across all tenants | auth.rs:173 |
| 7 | admin:* bypasses tenant checks via the only prefix match in the scope system | auth.rs:1379 |
| 8 | DevScopes is unauthenticated self-assertion | auth.rs:857 |
| 9 | An empty MCP agent registry serves the MCP plane anonymously | server.rs:337 |
| 10 | Device-grant state is process-local. A restart invalidates every pending grant and refresh credential; there is no external revocation | auth_device.rs:29 |
| 11 | feature_gate on a route contract is documentation only | route_auth.rs:50 |
| 12 | config.auth_mode silently defaults to DevScopes when parsing fails, safe only because main aborts first | config.rs:886 |
| 13 | In Off mode has_scope() returns true for every string, including scopes that do not exist | auth.rs:1096 |
| 14 | No TLS or mTLS. Every credential rides plaintext unless a proxy terminates TLS | main.rs:2217 |
| 15 | The passport header is unverified in Off and DevScopes | auth.rs:1191 |
| 16 | Group-shared private-fact visibility is not implemented, deferred and documented | scope.rs:49 |
| 17 | The sync mutual-auth deferral is an exact-path list. A new sync route will not inherit it | route_auth.rs:551 |
| 18 | CRUX_PASSPORT_REVOCATION fails open on any set value other than 1 or true | dispatch.rs:115 |
| 19 | The RCX router never checks revocation. A revoked-but-unexpired token is still authorised | crux-router/src/lib.rs:120 |
Sources
- crates/corecruxd/src/auth.rs:24,
AuthMode - crates/corecruxd/src/auth.rs:1320,
require_http_scopes - crates/corecruxd/src/auth.rs:822,
require_tenant_allowed - crates/corecruxd/src/http/route_auth.rs:75,
classify_route - crates/corecruxd/src/http/route_auth.rs:607,
route_auth_middleware - crates/crux-mcp/src/agent.rs:74, the agent-token registry
- crates/crux-mcp/src/server.rs:336,
authenticate_agent - crates/corecruxd/src/principal.rs:33,
ResolvedPrincipal

