policy: server 3-state default-deny matrix (MR-723) (#105)

Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.

Three startup states, classified deterministically:

- **Open** — no tokens, no policy. Requires explicit
  `--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
  `serve()` refuses to start. Forces the operator to opt in to
  "fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
  rejects every action except `Read` with 403. The warn-log on
  startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
  request, unchanged from pre-MR-723.

What landed:

- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
  on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
  (`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
  allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
  `serve()` calls it before opening the engine and bails with a clear
  error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
  but the bearer-auth middleware delivered an authenticated actor, any
  action other than `Read` returns 403 with a message that names the
  misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
  integration tests that need a custom workload (`new_with_workload`)
  can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
  `app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
  install a permit-all policy alongside tokens — they previously
  represented the "tokens but no policy" state that MR-723 makes
  default-deny, and tests that don't care about policy were
  inadvertently coupled to the loophole.

Tests:

- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
  /snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
  rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
  /schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
  builds the State-2 fixture without policy. The pre-MR-723 helpers
  `app_for_loaded_repo_with_auth*` shift semantics to "tokens +
  permit-all" so existing tests retain their original intent.

docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Andrew Altshuler 2026-05-18 17:02:26 +03:00 committed by GitHub
parent a275306a15
commit f3f2a051ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 407 additions and 23 deletions

View file

@ -50,7 +50,7 @@ use subtle::ConstantTimeEq;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tower_http::trace::TraceLayer;
use tracing::{error, info};
use tracing::{error, info, warn};
use tracing_subscriber::EnvFilter;
use utoipa::OpenApi;
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme};
@ -114,6 +114,15 @@ pub struct ServerConfig {
pub uri: String,
pub bind: String,
pub policy_file: Option<PathBuf>,
/// Operator opt-in for fully-unauthenticated dev mode (MR-723).
/// When neither bearer tokens nor a policy file are configured,
/// `serve()` refuses to start unless this is true (set via
/// `--unauthenticated` or `OMNIGRAPH_UNAUTHENTICATED=1`). The
/// motivation is that "no tokens + no policy" looks like protection
/// (no Cedar errors at boot) but is actually fully open — operators
/// who set up auth and forgot the policy file would otherwise ship
/// the illusion of protection.
pub allow_unauthenticated: bool,
}
#[derive(Clone)]
@ -246,6 +255,17 @@ impl AppState {
}
}
/// Install a `PolicyEngine` post-construction (MR-723). Used by
/// integration tests that need to thread custom workload limits
/// alongside a permit-all policy — the existing `new_with_*` and
/// `new_with_workload` constructors don't compose. Production
/// callers should use `open_with_bearer_tokens_and_policy` which
/// installs the policy on both the HTTP state and the engine.
pub fn with_policy_engine(mut self, engine: PolicyEngine) -> Self {
self.policy_engine = Some(Arc::new(engine));
self
}
pub async fn open(uri: impl Into<String>) -> Result<Self> {
Self::open_with_bearer_token(uri, None).await
}
@ -535,20 +555,77 @@ pub fn load_server_settings(
cli_uri: Option<String>,
cli_target: Option<String>,
cli_bind: Option<String>,
cli_allow_unauthenticated: bool,
) -> Result<ServerConfig> {
let config = load_config(config_path)?;
let uri =
config.resolve_target_uri(cli_uri, cli_target.as_deref(), config.server_graph_name())?;
let bind = cli_bind.unwrap_or_else(|| config.server_bind().to_string());
let policy_file = config.resolve_policy_file();
// Either `--unauthenticated` or `OMNIGRAPH_UNAUTHENTICATED=1` flips
// this. Treat any non-empty, non-"0"/"false" string as truthy —
// standard 12-factor "any value is true" reading of the env var.
let env_unauth = std::env::var("OMNIGRAPH_UNAUTHENTICATED")
.ok()
.map(|v| {
let trimmed = v.trim();
!trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false")
})
.unwrap_or(false);
let allow_unauthenticated = cli_allow_unauthenticated || env_unauth;
Ok(ServerConfig {
uri,
bind,
policy_file,
allow_unauthenticated,
})
}
/// MR-723 server runtime state, classified from the three-state matrix
/// of (bearer tokens configured) × (policy file configured) at startup.
///
/// * **Open** — neither tokens nor policy; requires explicit
/// `allow_unauthenticated`. Effectively a "trust the network" dev
/// mode. `serve()` refuses to start in this shape without the flag,
/// so the only way to reach this state at runtime is via deliberate
/// operator opt-in.
/// * **DefaultDeny** — tokens configured but no policy file. The
/// server requires a valid bearer token; once authenticated, every
/// action except `Read` is denied with 403. Closes the "tokens but
/// forgot the policy file" trap.
/// * **PolicyEnabled** — policy file configured. Cedar evaluates every
/// authenticated request. Tokens may also be configured (typical) or
/// not (unusual but valid — every request fails 401 without a
/// bearer, which is effectively "locked").
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ServerRuntimeState {
Open,
DefaultDeny,
PolicyEnabled,
}
/// Compute the [`ServerRuntimeState`] from the configured inputs.
/// Pulled out as a pure function so the 3-state matrix is unit-testable
/// without standing up the full server.
pub fn classify_server_runtime_state(
has_tokens: bool,
has_policy: bool,
allow_unauthenticated: bool,
) -> Result<ServerRuntimeState> {
match (has_tokens, has_policy, allow_unauthenticated) {
(false, false, false) => bail!(
"server has no bearer tokens and no policy file configured. This is a fully \
open server pass `--unauthenticated` (or set OMNIGRAPH_UNAUTHENTICATED=1) \
if you actually want that, otherwise configure bearer tokens (see \
docs/user/server.md) and/or `policy.file` in omnigraph.yaml."
),
(false, false, true) => Ok(ServerRuntimeState::Open),
(true, false, _) => Ok(ServerRuntimeState::DefaultDeny),
(_, true, _) => Ok(ServerRuntimeState::PolicyEnabled),
}
}
pub fn build_app(state: AppState) -> Router {
let protected = Router::new()
.route("/snapshot", get(server_snapshot))
@ -586,9 +663,28 @@ pub fn build_app(state: AppState) -> Router {
pub async fn serve(config: ServerConfig) -> Result<()> {
let token_source = resolve_token_source().await?;
info!(source = token_source.name(), "loaded bearer token source");
let tokens = token_source.load().await?;
let runtime_state = classify_server_runtime_state(
!tokens.is_empty(),
config.policy_file.is_some(),
config.allow_unauthenticated,
)?;
match runtime_state {
ServerRuntimeState::Open => warn!(
"running with --unauthenticated: no bearer tokens, no policy file, all \
requests permitted. This is for local dev only do not expose to a \
network you don't fully trust."
),
ServerRuntimeState::DefaultDeny => warn!(
"bearer tokens are configured but no policy file is set — running in \
default-deny mode (only `read` actions are permitted for authenticated \
actors). Configure `policy.file` in omnigraph.yaml to enable Cedar rules."
),
ServerRuntimeState::PolicyEnabled => {}
}
let state = AppState::open_with_bearer_tokens_and_policy(
config.uri.clone(),
token_source.load().await?,
tokens,
config.policy_file.as_ref(),
)
.await?;
@ -708,6 +804,27 @@ fn authorize_request(
mut request: PolicyRequest,
) -> std::result::Result<(), ApiError> {
let Some(engine) = state.policy_engine() else {
// MR-723 default-deny path. We're here when no PolicyEngine is
// installed. Two startup-validated shapes can reach this:
//
// * **Open mode** (`--unauthenticated`): no tokens, no policy.
// `require_bearer_auth` short-circuits before this is called,
// but defense in depth — if a future change makes the
// middleware call here for an unauthenticated request, we
// want every action to remain Ok rather than 403. The
// operator opted in.
// * **DefaultDeny mode**: tokens configured but no policy. The
// request went through bearer auth, so `actor` is Some and
// identifies a known actor. Only `Read` is permitted; every
// other action returns 403. This closes the "configured auth
// but forgot the policy file" trap from MR-723.
if actor.is_some() && request.action != PolicyAction::Read {
return Err(ApiError::forbidden(
"server runs in default-deny mode (bearer tokens configured but no \
policy file). Only `read` actions are permitted; configure \
`policy.file` in omnigraph.yaml to enable other actions.",
));
}
return Ok(());
};
let Some(actor) = actor else {
@ -1641,8 +1758,8 @@ fn server_bearer_tokens_from_env() -> Result<Vec<(String, String)>> {
#[cfg(test)]
mod tests {
use super::{
hash_bearer_token, load_server_settings, normalize_bearer_token, parse_bearer_tokens_json,
server_bearer_tokens_from_env,
ServerRuntimeState, classify_server_runtime_state, hash_bearer_token, load_server_settings,
normalize_bearer_token, parse_bearer_tokens_json, server_bearer_tokens_from_env,
};
use std::env;
use std::fs;
@ -1695,7 +1812,7 @@ server:
)
.unwrap();
let settings = load_server_settings(Some(&config), None, None, None).unwrap();
let settings = load_server_settings(Some(&config), None, None, None, false).unwrap();
assert_eq!(settings.uri, "/tmp/demo.omni");
assert_eq!(settings.bind, "0.0.0.0:9090");
}
@ -1722,6 +1839,7 @@ server:
Some("/tmp/override.omni".to_string()),
None,
Some("0.0.0.0:9999".to_string()),
false,
)
.unwrap();
assert_eq!(settings.uri, "/tmp/override.omni");
@ -1748,16 +1866,69 @@ server:
.unwrap();
let settings =
load_server_settings(Some(&config), None, Some("dev".to_string()), None).unwrap();
load_server_settings(Some(&config), None, Some("dev".to_string()), None, false)
.unwrap();
assert_eq!(settings.uri, "http://127.0.0.1:8080");
}
#[test]
fn server_settings_require_uri_from_cli_or_config() {
let error = load_server_settings(None, None, None, None).unwrap_err();
let error = load_server_settings(None, None, None, None, false).unwrap_err();
assert!(error.to_string().contains("URI must be provided"));
}
#[test]
fn classify_open_requires_explicit_unauthenticated_flag() {
// State 1: no tokens, no policy, no flag → refuse to start.
let error = classify_server_runtime_state(false, false, false).unwrap_err();
let msg = error.to_string();
assert!(
msg.contains("--unauthenticated"),
"expected refusal message mentioning --unauthenticated, got: {msg}"
);
// Same matrix cell but with the flag set → Open mode permitted.
assert_eq!(
classify_server_runtime_state(false, false, true).unwrap(),
ServerRuntimeState::Open
);
}
#[test]
fn classify_tokens_without_policy_is_default_deny() {
// State 2: tokens configured, no policy → DefaultDeny regardless
// of the flag (the flag opts into the fully-open dev mode; it
// doesn't downgrade default-deny back to open).
assert_eq!(
classify_server_runtime_state(true, false, false).unwrap(),
ServerRuntimeState::DefaultDeny
);
assert_eq!(
classify_server_runtime_state(true, false, true).unwrap(),
ServerRuntimeState::DefaultDeny
);
}
#[test]
fn classify_policy_enabled_always_wins() {
// State 3: any setup with a policy file → PolicyEnabled. The
// flag doesn't matter and tokens-or-not doesn't matter (no
// tokens + policy is unusual but valid — every request fails
// 401 without a bearer, which is effectively "locked").
assert_eq!(
classify_server_runtime_state(true, true, false).unwrap(),
ServerRuntimeState::PolicyEnabled
);
assert_eq!(
classify_server_runtime_state(false, true, false).unwrap(),
ServerRuntimeState::PolicyEnabled
);
assert_eq!(
classify_server_runtime_state(true, true, true).unwrap(),
ServerRuntimeState::PolicyEnabled
);
}
#[test]
fn normalize_bearer_token_trims_and_filters_blank_values() {
assert_eq!(normalize_bearer_token(None), None);

View file

@ -16,6 +16,12 @@ struct Cli {
config: Option<PathBuf>,
#[arg(long)]
bind: Option<String>,
/// Run without bearer tokens and without a policy file (MR-723).
/// Required when neither is configured — otherwise the server
/// refuses to start to prevent shipping the illusion of protection.
/// Equivalent to setting `OMNIGRAPH_UNAUTHENTICATED=1`.
#[arg(long)]
unauthenticated: bool,
}
#[tokio::main]
@ -24,7 +30,12 @@ async fn main() -> Result<()> {
init_tracing();
let cli = Cli::parse();
let settings: ServerConfig =
load_server_settings(cli.config.as_ref(), cli.uri, cli.target, cli.bind)?;
let settings: ServerConfig = load_server_settings(
cli.config.as_ref(),
cli.uri,
cli.target,
cli.bind,
cli.unauthenticated,
)?;
serve(settings).await
}

View file

@ -5,6 +5,7 @@ use std::sync::Arc;
use axum::Router;
use axum::body::{Body, to_bytes};
use axum::http::header::AUTHORIZATION;
use axum::http::{Method, Request, StatusCode};
use lance_index::traits::DatasetIndexExt;
use omnigraph::db::{Omnigraph, ReadTarget};
@ -176,15 +177,55 @@ async fn app_for_loaded_repo() -> (tempfile::TempDir, Router) {
(temp, build_app(state))
}
/// Build a permit-all policy YAML that grants every action used by the
/// HTTP-layer tests to the listed actor names. MR-723 default-deny
/// closed the "tokens but no policy" loophole; helpers that used to
/// represent "auth without policy" now install this permit-all policy
/// so test cases retain their pre-MR-723 semantics ("auth required,
/// every action permitted") without conflicting with the new state
/// matrix. Tests that specifically need the State-2 deny path use
/// `app_for_repo_with_auth_tokens_only` instead.
fn permit_all_policy_yaml(actors: &[&str]) -> String {
let members = actors
.iter()
.map(|a| format!("\"{a}\""))
.collect::<Vec<_>>()
.join(", ");
format!(
r#"
version: 1
groups:
permitted: [{members}]
protected_branches: [main]
rules:
- id: permit-data
allow:
actors: {{ group: permitted }}
actions: [read, change, export]
branch_scope: any
- id: permit-protected-target-actions
allow:
actors: {{ group: permitted }}
actions: [schema_apply, branch_create, branch_delete, branch_merge]
target_branch_scope: any
"#
)
}
async fn app_for_loaded_repo_with_auth(token: &str) -> (tempfile::TempDir, Router) {
// `AppState::new_with_bearer_token(token)` maps the token to actor "default";
// permit-all policy needs to include that actor.
let temp = init_loaded_repo().await;
let repo = repo_path(temp.path());
let db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap();
let state = AppState::new_with_bearer_token(
let policy_path = temp.path().join("policy.yaml");
fs::write(&policy_path, permit_all_policy_yaml(&["default"])).unwrap();
let state = AppState::open_with_bearer_tokens_and_policy(
repo.to_string_lossy().to_string(),
db,
Some(token.to_string()),
);
vec![("default".to_string(), token.to_string())],
Some(&policy_path),
)
.await
.unwrap();
(temp, build_app(state))
}
@ -193,15 +234,19 @@ async fn app_for_loaded_repo_with_auth_tokens(
) -> (tempfile::TempDir, Router) {
let temp = init_loaded_repo().await;
let repo = repo_path(temp.path());
let db = Omnigraph::open(repo.to_str().unwrap()).await.unwrap();
let state = AppState::new_with_bearer_tokens(
let policy_path = temp.path().join("policy.yaml");
let actors: Vec<&str> = tokens.iter().map(|(actor, _)| *actor).collect();
fs::write(&policy_path, permit_all_policy_yaml(&actors)).unwrap();
let state = AppState::open_with_bearer_tokens_and_policy(
repo.to_string_lossy().to_string(),
db,
tokens
.iter()
.map(|(actor, token)| ((*actor).to_string(), (*token).to_string()))
.collect(),
);
Some(&policy_path),
)
.await
.unwrap();
(temp, build_app(state))
}
@ -248,6 +293,29 @@ async fn app_for_repo_with_auth_tokens_and_policy(
(temp, build_app(state))
}
/// MR-723 default-deny mode: bearer tokens configured, no policy file.
/// Exercises ServerRuntimeState::DefaultDeny — authenticated requests
/// for Read succeed, every other action is rejected with 403 from
/// `authorize_request`'s state-2 branch.
async fn app_for_repo_with_auth_tokens_only(
schema: &str,
tokens: &[(&str, &str)],
) -> (tempfile::TempDir, Router) {
let temp = init_repo_with_schema(schema).await;
let repo = repo_path(temp.path());
let state = AppState::open_with_bearer_tokens_and_policy(
repo.to_string_lossy().to_string(),
tokens
.iter()
.map(|(actor, token)| ((*actor).to_string(), (*token).to_string()))
.collect(),
None,
)
.await
.unwrap();
(temp, build_app(state))
}
fn additive_schema_with_nickname() -> String {
fs::read_to_string(fixture("test.pg")).unwrap().replace(
" age: I32?\n}",
@ -831,11 +899,20 @@ async fn export_route_returns_jsonl_for_branch_snapshot() {
.unwrap();
drop(db);
let state = AppState::new_with_bearer_token(
// MR-723: tokens-without-policy is now default-deny. Install a
// permit-all policy alongside the bearer token so /export
// (action=Export) passes Cedar evaluation. The test is exercising
// export semantics, not policy — the policy is just enough to clear
// the State 3 path.
let policy_path = temp.path().join("policy.yaml");
fs::write(&policy_path, permit_all_policy_yaml(&["default"])).unwrap();
let state = AppState::open_with_bearer_tokens_and_policy(
repo.to_string_lossy().to_string(),
Omnigraph::open(repo.to_str().unwrap()).await.unwrap(),
Some(token.to_string()),
);
vec![("default".to_string(), token.to_string())],
Some(&policy_path),
)
.await
.unwrap();
let app = build_app(state);
let response = app
@ -3487,12 +3564,24 @@ async fn ingest_per_actor_admission_cap_returns_429() {
1, // per-actor in-flight cap (the fixture under test)
1_000_000_000, // per-actor byte budget — large so it never bottlenecks
);
// MR-723: install a permit-all policy alongside the bearer token so
// /ingest (action=Change) passes Cedar evaluation. The test is
// exercising the admission cap, not policy — the policy is just
// enough to clear the State 3 path so the test reaches workload.
let policy_path = temp.path().join("policy.yaml");
fs::write(&policy_path, permit_all_policy_yaml(&["act-flooder"])).unwrap();
let policy_engine = omnigraph_server::PolicyEngine::load(
&policy_path,
repo.to_string_lossy().as_ref(),
)
.unwrap();
let state = AppState::new_with_workload(
repo.to_string_lossy().to_string(),
db,
vec![("act-flooder".to_string(), "flooder-token".to_string())],
workload,
);
)
.with_policy_engine(policy_engine);
let app = build_app(state);
let _temp = temp;
@ -3604,3 +3693,97 @@ async fn oversized_request_body_returns_payload_too_large() {
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
// ─── MR-723 default-deny mode (State 2: tokens without policy) ──────────
//
// `authorize_request` returns 403 for every action except `Read` when a
// PolicyEngine is not installed but bearer tokens are configured. Pinned
// by the three tests below — Read allowed, Change/SchemaApply denied —
// to prevent regressing back to the pre-MR-723 "tokens configured but
// no policy = fully open" trap.
#[tokio::test(flavor = "multi_thread")]
async fn default_deny_mode_allows_read_for_authenticated_actor() {
let (_temp, app) = app_for_repo_with_auth_tokens_only(
&fs::read_to_string(fixture("test.pg")).unwrap(),
&[("act-andrew", "demo-token")],
)
.await;
let (status, _body) = json_response(
&app,
Request::builder()
.uri("/snapshot")
.method(Method::GET)
.header(AUTHORIZATION, "Bearer demo-token")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test(flavor = "multi_thread")]
async fn default_deny_mode_rejects_change_with_forbidden() {
let (_temp, app) = app_for_repo_with_auth_tokens_only(
&fs::read_to_string(fixture("test.pg")).unwrap(),
&[("act-andrew", "demo-token")],
)
.await;
let change = ChangeRequest {
query_source: MUTATION_QUERIES.to_string(),
query_name: Some("insert_person".to_string()),
params: Some(json!({ "name": "DefaultDeny", "age": 1 })),
branch: Some("main".to_string()),
};
let (status, body) = json_response(
&app,
Request::builder()
.uri("/change")
.method(Method::POST)
.header(AUTHORIZATION, "Bearer demo-token")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&change).unwrap()))
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
let error: ErrorOutput = serde_json::from_value(body).unwrap();
assert!(
error.error.contains("default-deny"),
"expected default-deny in error message, got: {}",
error.error
);
}
#[tokio::test(flavor = "multi_thread")]
async fn default_deny_mode_rejects_schema_apply_with_forbidden() {
let (_temp, app) = app_for_repo_with_auth_tokens_only(
&fs::read_to_string(fixture("test.pg")).unwrap(),
&[("act-andrew", "demo-token")],
)
.await;
let req = SchemaApplyRequest {
schema_source: additive_schema_with_nickname(),
};
let (status, body) = json_response(
&app,
Request::builder()
.uri("/schema/apply")
.method(Method::POST)
.header(AUTHORIZATION, "Bearer demo-token")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&req).unwrap()))
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
let error: ErrorOutput = serde_json::from_value(body).unwrap();
assert!(
error.error.contains("default-deny"),
"expected default-deny in error message, got: {}",
error.error
);
}

View file

@ -63,6 +63,25 @@ is a strict no-op; when one is installed and the call site forgets to
thread an actor through, the gate fails closed rather than silently
bypassing.
## Server runtime states (MR-723)
The HTTP server classifies its startup configuration into one of three
states based on whether bearer tokens are configured and whether a
policy file is set. The state determines what happens to a request that
reaches `authorize_request()` without a matching policy permit.
| State | Tokens | Policy file | Behavior |
|---|---|---|---|
| **Open** | no | no | Every request is permitted. Refuses to start unless `--unauthenticated` or `OMNIGRAPH_UNAUTHENTICATED=1` is set — the operator must explicitly opt in. |
| **DefaultDeny** | yes | no | Every authenticated request for an action other than `read` is rejected with HTTP 403. Closes the "tokens but forgot the policy file" trap — an operator who sets up auth and forgot to point at a policy file used to ship the illusion of protection. |
| **PolicyEnabled** | any | yes | Every request is evaluated by Cedar against the configured policy. |
The classifier is `classify_server_runtime_state` in
`crates/omnigraph-server/src/lib.rs`; it returns `Err` for the "no
tokens, no policy, no flag" cell so the server refuses to start instead
of silently shipping an open instance. Tests pin every cell of the
matrix and the State-2 deny path.
Server-side, `authorize_request()` still runs at the HTTP boundary —
that's where actor identity is resolved from the bearer token and where
admission control / per-actor rate limits live. Engine-layer enforcement