Skip to content

Security

Fallen-8’s hosted API has one access control: an API key that gates access to the whole service (all or nothing). Set it and every request needs it; leave it unset and the whole service is open. There is no dynamic-code switch, compiling and running submitted C# fragments is Fallen-8’s core “queries are C#” model and is always available (auth permitting). Runtime plugin registration compiles submitted C# too (plugin registration); it is on by default and can be turned off globally or per namespace. This page is the one home for that posture; other docs reference it.

Plugin registration is one of four operator capability switches the authorization layer enforces. A capability is orthogonal to authentication: when it is off the endpoint answers 403 to every caller, valid key-holder or not.

Control Config key Gates Default
API key Fallen8:Security:ApiKey Access to every endpoint unset → open
Plugin registration Fallen8:Security:EnableDynamicPluginLoading (global default) + per-namespace pluginRegistration override Registering plugins from source (POST /plugins/*, plugin registration) true → allowed; override per namespace via PATCH /ns/{name}
Embedding / chat / ingestion capabilities Fallen8:Embedding:Enabled, Fallen8:Chat:Enabled, Fallen8:Ingestion:Enabled /embedding/*, POST /chat and GET /chat/models, /document/* (each group is also the only path to an external sidecar; the catalog read is the one route that proxies a credentialed backend’s model list: which models the backend has) false → refused on a bare run (401 while no key is configured, 403 once one is); the compose environment turns all three on (see running)

Set a key and the entire service requires it, reads, mutations, and code endpoints alike. Leave it unset and the whole service is open (the API-key scheme authenticates nobody); the server logs a prominent UNAUTHENTICATED warning at startup. There is no per-endpoint, per-role, or per-namespace model: it is all or nothing, and the single key opens every namespace. Namespaces isolate data, not tenants; the only per-namespace security state is the plugin-registration override, and a separate instance per tenant is the only real split today.

Key Env (compose) Default Meaning
Fallen8:Security:ApiKey Fallen8__Security__ApiKey (F8_API_KEY) null The secret. Supply from environment or user-secrets, never checked in.
Fallen8:Security:ApiKeyHeader Fallen8__Security__ApiKeyHeader X-Api-Key Request header carrying the key.

The client sends the key in the X-Api-Key header (or, as a fallback, Authorization: Bearer <key>, the same key). The comparison is constant-time and the key is never logged. Behaviour when a key is configured:

Request Result
Correct key in the header Authenticated; proceeds
Missing key 401 Unauthorized
Wrong key 401 Unauthorized

Anonymous exemptions (reachable without a key even when one is set): GET /status, GET /vertex/count, GET /edge/count, the health probes GET /healthz and GET /readyz, GET /metrics by default (see observability), and everything served from wwwroot in the all-in-one image: the Studio shell, its hashed assets, and the bundled sample datasets under /samples/. Static files are served by middleware that runs before authentication, so no file in wwwroot is ever key-gated. Everything else requires the key, GET /statistics included: it exposes schema-shaped data (label names, property keys, index names), unlike /metrics, whose inventory is aggregate numbers only. GET /status is also the connection probe: it reports apiKeyRequired (server config) and authenticated (this request) so a client can tell “reachable” from “authorized”.

# Probe first: is a key required? Then send it on real calls.
curl http://localhost:8080/status
curl -H "X-Api-Key: <your-key>" http://localhost:8080/storedquery

The path and subgraph endpoints compile inline C# filter/cost fragments with Roslyn and run them in-process; see delegates. This is Fallen-8’s query model, so there is no switch to disable it. The only gate is authentication: with a key configured, a code-introducing request needs the key like any other request (401 without it); with no key, anyone who can reach the service can run arbitrary in-process C#.

Endpoint class Needs a credential (when a key is configured) Notes
Inline path fragments (POST /path/... with filter/cost) yes Compiled per request (cached by fragment).
Inline subgraph fragments (PUT /subgraph with vertexFilter/edgeFilter/patterns) yes
Stored-query registration (POST /storedquery) yes Compiled once at registration (stored queries).
POST /delegates/validate yes Compile-checks a fragment without running it.
Stored-query invocation by name, list / get / delete as any request No new code is introduced; the pre-compiled artifact runs.
Filterless path search ({} body) or a semantic-only path request as any request Compiles nothing: with no filter and no cost fragment the generator short-circuits, so no Roslyn run and no assembly load. The semantic block itself is pure data.
Analytics, change feed, reads/scans, mutations, and a PUT /subgraph with no fragments at all as any request Compile no C# at all.
Plugin registration (POST /plugins/algorithm, /plugins/function, /plugins/algorithm/validate, /plugins/function/validate) yes and plugin registration enabled On by default; disable globally (EnableDynamicPluginLoading=false) or per namespace (PATCH /ns/{name} pluginRegistration), see plugin registration. Invoking/listing/deleting a registered plugin is never gated by this.

Honest limit. A compiled fragment (inline or stored) runs in-process with the server’s full authority. Authentication is access control (who may reach the code endpoints at all); it is not a sandbox. Anyone who can present the key has full code execution as the server process. Running genuinely untrusted code would need out-of-process or WASM isolation, which Fallen-8 does not provide. Therefore, never expose an unauthenticated instance off-box: set an API key (or front the service with an authenticating proxy) before it is reachable beyond localhost or a trusted network.

That authority now extends one step further, and it is worth stating rather than leaving to be inferred. A key holder can change what other callers see: with Fallen8:Security:EnableConfigurationWrite on, PATCH /config writes this instance’s own configuration, so a key holder can move a limit or a ceiling that everyone else is subject to. Nothing under Fallen8:Security is writable that way, and neither is anything that addresses stored data or an address the server dials, so the write can never reach authentication, a storage path or the identity of data you already hold. The reasoning behind that boundary, and why roughly half of the settings are excluded from it, is on configuration. It also takes two deliberate acts to enable at all: without an API key configured the write is refused whatever the capability says, because a keyless instance would otherwise let anyone persist a posture change that outlives the process.

The read side is deliberately more relaxed, and asymmetric on purpose. GET /config answers without a key on an instance that has configured none, and it carries the OpenTelemetry endpoint and the embedding model identity, so on such an instance an anonymous caller can read them. Withholding them would buy nothing: that same caller can already execute code in the process. A never-writable setting’s value is withheld from the settings inventory, but that is defence against casual exposure such as a screenshot or a log, not a boundary. The boundary is the API key, here as everywhere else.

The execution budget is cooperative, and only that. POST /path accepts an optional timeBudgetSeconds, is always bound to the client’s abort, and answers 408 when either trips, which contains an accidentally expensive traversal and lets a caller give up. It cannot contain a hostile one: the budget is checked between filter and cost invocations, so a fragment that never returns (return (v) => { while(true){} };) still holds its thread until the process restarts. That is the trust model, not an oversight: a fragment author is already trusted as the process. The other guards are pre-compile only: the fragment and source length caps described in delegates, plus the shared sensitive-endpoint rate limit below as a coarse brake.

Posture Who can reach it Who can run in-process C#
Key unset (open): default anyone who can reach the service anyone who can reach the service. Localhost / trusted network only.
Key set only key-holders (reads exempted above) only key-holders. The recommended exposed posture.
Posture API key Plugin registration TLS Shape
Localhost dev unset (open) on (the default) none dotnet run; iterate on fragments freely on your own machine.
Trusted network set on (the default); turn it off globally or per namespace if callers should not introduce plugin code none / optional Every request authenticated; code runs in-process, keep the network trusted.
Exposed set on (the default); leaving it on means every key-holder can register full-trust C# terminate upstream Every request authenticated behind a TLS-terminating reverse proxy.

TLS. The app serves plain HTTP (the container listens on 8080, the dev profile on 5000); it does not terminate TLS and ships no certificate, so UseHttpsRedirection is a no-op without an HTTPS port. When exposing the service, put a TLS-terminating reverse proxy in front of it.

Bind address. The REST app has no setting for it, deliberately: its bind address is whatever ASPNETCORE_URLS / Kestrel / the launch profile sets (the container binds all interfaces on 8080). An earlier Fallen8:Security:AllowRemoteAccess flag was removed because nothing read it, so it advertised a loopback guarantee the app never had. There is no “loopback-by-default”: set the API key before the service is reachable off-box. (The MCP server is the exception: it is a separate deployable with its own Mcp:Security:BindAddress and Mcp:Security:AllowRemoteAccess, which it does enforce.)

The API key protects the REST port only. The compose environment also starts the MCP server as a second listener onto the same graph (published on 8090; it has no compose profile, so it comes up with the rest of the environment). It authenticates its own callers with its own settings, and it holds F8_API_KEY as the downstream credential it presents to the REST API on every bridged call. In the shipped compose defaults it is anonymous (F8_MCP_AUTH_MODE=None) and read-only (write/admin/code tiers off). Before that environment goes off-box, set F8_MCP_AUTH_MODE=StaticToken with F8_MCP_TOKEN (or OAuth), or stop publishing 8090: setting F8_API_KEY alone does not close it.

Additional settings under Fallen8:Security. Only the rate limit is scoped to the sensitive endpoints; the CORS policy is app-wide.

Key Default Effect
AllowedCorsOrigins [] The CORS default policy, applied to every endpoint (there is no per-endpoint CORS attribute). Empty denies all cross-origin; a listed origin gets any header and method, with the preflight cached for 600 s. No wildcard-with-credentials. Preflight (OPTIONS) is answered before authentication. As an environment variable the array binds only in indexed form, Fallen8__Security__AllowedCorsOrigins__0=http://localhost:8081; a bare Fallen8__Security__AllowedCorsOrigins=… binds nothing. A standalone UI on another origin does not work until its origin is listed.
SensitiveRateLimitPermitPerWindow / RateLimitWindowSeconds 30 / 10 Fixed-window rate limit; breach → 429 with no queueing. The window is process-wide, not per caller: 30 requests from any one client inside 10 s rate-limit everyone. The guarded set is wider than the code endpoints: POST /path/…, PUT /subgraph, POST /storedquery, POST /delegates/validate and POST /plugins/*, plus GET /statistics, namespace create / rename / drop, PUT /save/all, HEAD /tabularasa/all, POST /chat, GET /chat/models (guarded because one read fans out to the backend rather than making a single request: how wide), every /embedding/* route, and POST /document, /document/text, /document/search.
(request body) 1 MiB Fixed per endpoint (a [RequestSizeLimit] attribute), not configurable: a fragment or registration body over it → 413. There is deliberately no configuration key for it. Two endpoints carry their own, much larger bound because a file travels in the body: document upload (unstructured ingestion) and POST /integrations/job (integrations). For the latter the bound is 768 MiB, set above any legal job on purpose, and it is judged from the declared Content-Length before the body is uploaded: a body over it answers 413 naming both numbers, having read nothing, and a body sent without a Content-Length answers 411, because one that cannot be measured cannot be refused before it arrives. The real per-file ceiling remains the runtime’s Integrations:MaxFileBytes, which refuses a legal-sized body with a message naming both sizes.
  • Delegates: the Roslyn-compiled C# fragments (always available), and /delegates/validate
  • Stored queries: named, pre-compiled queries, where registration needs a credential and invocation reuses the artifact
  • Semantic traversal: the code-free semantic block
  • Change feed: declarative, compiles no code
  • Plugin registration: the EnableDynamicPluginLoading global default + the per-namespace override
  • Observability: metrics/health endpoints and whether /metrics requires the key
  • MCP server: the second listener’s own auth modes and tool tiers
  • Running Fallen-8: setting F8_API_KEY in compose
  • Studio: registering an instance with its key
  • REST API: the auth header on the endpoint surface