From ec532c615e25fd01360ca8861019ab6ef4349651 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Thu, 5 Mar 2026 12:40:22 +0100 Subject: [PATCH 1/9] BE-445: Disable bulk destructive endpoints when JWT is configured Bulk destructive endpoints (/snapshot, /accounts, /data-types, /property-types, /entity-types) are now only registered when JWT authentication is not configured. In production/staging where JWT is enabled, only /health and /entities/delete are available. This removes the risk of accidental bulk data loss through the admin API in deployed environments. The bulk endpoints remain available for local development and testing. Removes now-unnecessary OptionalJwtAuthentication and audit logging from bulk handlers, since they are never reachable with JWT enabled. --- libs/@local/graph/api/src/rest/admin.rs | 58 ++++++------------------- 1 file changed, 14 insertions(+), 44 deletions(-) diff --git a/libs/@local/graph/api/src/rest/admin.rs b/libs/@local/graph/api/src/rest/admin.rs index ef0ab3fd0c0..afc780d167a 100644 --- a/libs/@local/graph/api/src/rest/admin.rs +++ b/libs/@local/graph/api/src/rest/admin.rs @@ -39,22 +39,27 @@ use crate::rest::status::report_to_response; /// Creates the admin API router. /// -/// When `jwt_validator` is `Some`, all endpoints except `/health` require a valid -/// JWT token. When `None`, JWT authentication is disabled (development mode). +/// When `jwt_validator` is `Some`, only `/health` and `/entities/delete` are available. +/// Bulk destructive endpoints (`/snapshot`, `/accounts`, `/data-types`, `/property-types`, +/// `/entity-types`) are only registered when JWT is **not** configured. pub fn routes(store_pool: PostgresStorePool, jwt_validator: Option>) -> Router { // Health endpoint is always public (used by load balancers and healthchecks) let public = Router::new().route("/health", get(async || "Healthy")); - let mut protected = Router::new() - .route("/snapshot", post(restore_snapshot)) - .route("/accounts", delete(delete_accounts)) - .route("/data-types", delete(delete_data_types)) - .route("/property-types", delete(delete_property_types)) - .route("/entity-types", delete(delete_entity_types)) - .route("/entities/delete", post(delete_entities)); + let mut protected = Router::new().route("/entities/delete", post(delete_entities)); if let Some(validator) = jwt_validator { protected = protected.layer(Extension(validator)); + } else { + // Bulk destructive endpoints are only available when JWT is not configured. + // In production/staging (JWT enabled), these are disabled to prevent accidental + // data loss — use snapshots or targeted entity deletion instead. + protected = protected + .route("/snapshot", post(restore_snapshot)) + .route("/accounts", delete(delete_accounts)) + .route("/data-types", delete(delete_data_types)) + .route("/property-types", delete(delete_property_types)) + .route("/entity-types", delete(delete_entity_types)); } public @@ -129,16 +134,9 @@ impl FromRequestParts for AdminActorId { } async fn restore_snapshot( - jwt: OptionalJwtAuthentication, store_pool: Extension>, snapshot: Body, ) -> Result { - tracing::info!( - sub = jwt.0.as_ref().map(|claims| claims.sub.as_str()), - email = jwt.0.as_ref().and_then(|claims| claims.email.as_deref()), - "Admin: restoring snapshot" - ); - let store = store_pool.acquire(None).await.map_err(report_to_response)?; SnapshotStore::new(store) @@ -161,15 +159,8 @@ async fn restore_snapshot( } async fn delete_accounts( - jwt: OptionalJwtAuthentication, pool: Extension>, ) -> Result { - tracing::info!( - sub = jwt.0.as_ref().map(|claims| claims.sub.as_str()), - email = jwt.0.as_ref().and_then(|claims| claims.email.as_deref()), - "Admin: deleting all accounts" - ); - pool.acquire(None) .await .map_err(report_to_response)? @@ -185,15 +176,8 @@ async fn delete_accounts( } async fn delete_data_types( - jwt: OptionalJwtAuthentication, pool: Extension>, ) -> Result { - tracing::info!( - sub = jwt.0.as_ref().map(|claims| claims.sub.as_str()), - email = jwt.0.as_ref().and_then(|claims| claims.email.as_deref()), - "Admin: deleting all data types" - ); - pool.acquire(None) .await .map_err(report_to_response)? @@ -209,15 +193,8 @@ async fn delete_data_types( } async fn delete_property_types( - jwt: OptionalJwtAuthentication, pool: Extension>, ) -> Result { - tracing::info!( - sub = jwt.0.as_ref().map(|claims| claims.sub.as_str()), - email = jwt.0.as_ref().and_then(|claims| claims.email.as_deref()), - "Admin: deleting all property types" - ); - pool.acquire(None) .await .map_err(report_to_response)? @@ -233,15 +210,8 @@ async fn delete_property_types( } async fn delete_entity_types( - jwt: OptionalJwtAuthentication, pool: Extension>, ) -> Result { - tracing::info!( - sub = jwt.0.as_ref().map(|claims| claims.sub.as_str()), - email = jwt.0.as_ref().and_then(|claims| claims.email.as_deref()), - "Admin: deleting all entity types" - ); - pool.acquire(None) .await .map_err(report_to_response)? From dedfcb4342df48e8bcb2809a96d07c8cf19ab916 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Thu, 5 Mar 2026 13:49:21 +0100 Subject: [PATCH 2/9] BE-445: Document admin API module and link to operational runbook - Add module-level rustdoc with endpoint table, auth flow, and availability - Add doc comments for all admin endpoint handlers - Link to Graph Admin API Notion runbook for operational instructions - Fix "dev mode" terminology to "JWT not configured" --- libs/@local/graph/api/src/rest/admin.rs | 46 ++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/libs/@local/graph/api/src/rest/admin.rs b/libs/@local/graph/api/src/rest/admin.rs index afc780d167a..a1deb3a0080 100644 --- a/libs/@local/graph/api/src/rest/admin.rs +++ b/libs/@local/graph/api/src/rest/admin.rs @@ -1,7 +1,38 @@ //! Admin API routes for database management operations. //! -//! These routes are served on a separate admin port and provide operations like -//! entity deletion, snapshot restoration, and bulk cleanup of ontology types. +//! Served on a dedicated port (default: 4001, configured via `HASH_GRAPH_ADMIN_PORT`), separate +//! from the main Graph API. +//! +//! # Endpoints +//! +//! | Method | Path | Auth | Availability | +//! |----------|--------------------|------|--------------------------| +//! | `GET` | `/health` | — | Always | +//! | `POST` | `/entities/delete` | JWT | Always | +//! | `POST` | `/snapshot` | — | Only without JWT | +//! | `DELETE` | `/accounts` | — | Only without JWT | +//! | `DELETE` | `/data-types` | — | Only without JWT | +//! | `DELETE` | `/property-types` | — | Only without JWT | +//! | `DELETE` | `/entity-types` | — | Only without JWT | +//! +//! # Authentication +//! +//! JWT tokens are extracted from headers in order: +//! 1. `Cf-Access-Jwt-Assertion` (Cloudflare Access) +//! 2. `Authorization: Bearer ` +//! +//! When JWT is configured (`--jwt-jwks-url`), the token's `email` claim is resolved to a HASH +//! user actor for provenance tracking on `/entities/delete`. When JWT is not configured, the +//! `X-Authenticated-User-Actor-Id` header is used instead. +//! +//! See [`super::jwt`] for validation details. +//! +//! # Operational runbook +//! +//! See the [Graph Admin API] Notion page for access instructions and troubleshooting. +//! **Update that page when endpoints or authentication behaviour change.** +//! +//! [Graph Admin API]: https://www.notion.so/hashintel/Graph-Admin-API-31a3c81fe02480f792c9d7bedfdc49db use alloc::sync::Arc; @@ -81,7 +112,7 @@ enum AdminActorError { /// Resolves the authenticated admin actor from JWT claims. /// /// When JWT authentication is configured, resolves the actor ID by looking up the email from the -/// token claims. When JWT is disabled (dev mode), falls back to the `X-Authenticated-User-Actor-Id` +/// token claims. When JWT is not configured, falls back to the `X-Authenticated-User-Actor-Id` /// header. struct AdminActorId(AuthenticatedActor); @@ -92,7 +123,7 @@ impl FromRequestParts for AdminActorId { let jwt = OptionalJwtAuthentication::from_request_parts(parts, state).await?; let Some(claims) = jwt.0 else { - // No JWT configured (dev mode) — fall back to header + // No JWT configured — fall back to header let AuthenticatedUserHeader(actor_id) = AuthenticatedUserHeader::from_request_parts(parts, state) .await @@ -133,6 +164,9 @@ impl FromRequestParts for AdminActorId { } } +/// Restores a snapshot from a JSON Lines stream, replacing all existing data. +/// +/// Only available when JWT is not configured. async fn restore_snapshot( store_pool: Extension>, snapshot: Body, @@ -158,6 +192,7 @@ async fn restore_snapshot( ))) } +/// Deletes **all** accounts. Only available when JWT is not configured. async fn delete_accounts( pool: Extension>, ) -> Result { @@ -175,6 +210,7 @@ async fn delete_accounts( ))) } +/// Deletes **all** data types. Only available when JWT is not configured. async fn delete_data_types( pool: Extension>, ) -> Result { @@ -192,6 +228,7 @@ async fn delete_data_types( ))) } +/// Deletes **all** property types. Only available when JWT is not configured. async fn delete_property_types( pool: Extension>, ) -> Result { @@ -209,6 +246,7 @@ async fn delete_property_types( ))) } +/// Deletes **all** entity types. Only available when JWT is not configured. async fn delete_entity_types( pool: Extension>, ) -> Result { From ff8040cc5051837cb3fb3453279a64dcbb9b3880 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Thu, 5 Mar 2026 14:02:46 +0100 Subject: [PATCH 3/9] BE-445: Add doc-tested examples and cross-references for admin endpoints - Add doc-tested JSON examples on `DeleteEntitiesParams` (purge by type, erase all) validated via `cargo test --doc` - Link each admin handler to its underlying store method for detailed behavioral docs (EntityStore, PostgresStore, SnapshotStore) --- libs/@local/graph/api/src/rest/admin.rs | 23 ++++++++++- libs/@local/graph/store/src/entity/store.rs | 45 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/libs/@local/graph/api/src/rest/admin.rs b/libs/@local/graph/api/src/rest/admin.rs index a1deb3a0080..b9e8aafbd9a 100644 --- a/libs/@local/graph/api/src/rest/admin.rs +++ b/libs/@local/graph/api/src/rest/admin.rs @@ -166,7 +166,7 @@ impl FromRequestParts for AdminActorId { /// Restores a snapshot from a JSON Lines stream, replacing all existing data. /// -/// Only available when JWT is not configured. +/// Only available when JWT is not configured. See [`SnapshotStore::restore_snapshot`] for details. async fn restore_snapshot( store_pool: Extension>, snapshot: Body, @@ -193,6 +193,10 @@ async fn restore_snapshot( } /// Deletes **all** accounts. Only available when JWT is not configured. +/// +/// See [`PostgresStore::delete_principals`] for details. +/// +/// [`PostgresStore::delete_principals`]: hash_graph_postgres_store::store::PostgresStore::delete_principals async fn delete_accounts( pool: Extension>, ) -> Result { @@ -211,6 +215,10 @@ async fn delete_accounts( } /// Deletes **all** data types. Only available when JWT is not configured. +/// +/// See [`PostgresStore::delete_data_types`] for details. +/// +/// [`PostgresStore::delete_data_types`]: hash_graph_postgres_store::store::PostgresStore::delete_data_types async fn delete_data_types( pool: Extension>, ) -> Result { @@ -229,6 +237,10 @@ async fn delete_data_types( } /// Deletes **all** property types. Only available when JWT is not configured. +/// +/// See [`PostgresStore::delete_property_types`] for details. +/// +/// [`PostgresStore::delete_property_types`]: hash_graph_postgres_store::store::PostgresStore::delete_property_types async fn delete_property_types( pool: Extension>, ) -> Result { @@ -247,6 +259,10 @@ async fn delete_property_types( } /// Deletes **all** entity types. Only available when JWT is not configured. +/// +/// See [`PostgresStore::delete_entity_types`] for details. +/// +/// [`PostgresStore::delete_entity_types`]: hash_graph_postgres_store::store::PostgresStore::delete_entity_types async fn delete_entity_types( pool: Extension>, ) -> Result { @@ -265,6 +281,11 @@ async fn delete_entity_types( } /// Deletes entities matching the given filter and scope with full provenance tracking. +/// +/// See [`EntityStore::delete_entities`] for behavioral details, scoping rules, and error +/// conditions. +/// +/// [`EntityStore::delete_entities`]: hash_graph_store::entity::EntityStore::delete_entities async fn delete_entities( AdminActorId(actor_id): AdminActorId, pool: Extension>, diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 2d6f85d2c69..eb31d35f7c9 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -553,6 +553,51 @@ pub enum LinkDeletionBehavior { // Cascade, } +/// Parameters for the `/entities/delete` admin API endpoint. +/// +/// # Examples +/// +/// Purge all entities of a specific type, erroring if any links remain: +/// +/// ``` +/// # use serde::Deserialize as _; +/// # use hash_graph_store::entity::DeleteEntitiesParams; +/// let json = serde_json::json!({ +/// "filter": { +/// "all": [ +/// { +/// "equal": [ +/// { "path": ["type(inheritanceDepth = 0)", "baseUrl"] }, +/// { "parameter": "https://hash.ai/@hash/types/entity-type/user/" } +/// ] +/// }, +/// { +/// "equal": [ +/// { "path": ["type(inheritanceDepth = 0)", "version"] }, +/// { "parameter": 1 } +/// ] +/// } +/// ] +/// }, +/// "includeDrafts": false, +/// "scope": "purge", +/// "linkBehavior": "error" +/// }); +/// let params = DeleteEntitiesParams::deserialize(&json).unwrap(); +/// ``` +/// +/// Erase all entities (including drafts) — typically used for database resets: +/// +/// ``` +/// # use serde::Deserialize as _; +/// # use hash_graph_store::entity::DeleteEntitiesParams; +/// let json = serde_json::json!({ +/// "filter": { "all": [] }, +/// "includeDrafts": true, +/// "scope": "erase" +/// }); +/// let params = DeleteEntitiesParams::deserialize(&json).unwrap(); +/// ``` #[derive(Debug, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] From 95cfeded222d167aa60ad7466ae8488742dd9140 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Thu, 12 Mar 2026 16:46:24 +0100 Subject: [PATCH 4/9] BE-445: Require --unsafe-allow-dev-authentication for unauthenticated admin access Without JWT configured, the admin server now refuses to start unless --unsafe-allow-dev-authentication (or HASH_GRAPH_UNSAFE_DEV_AUTH) is explicitly set. This prevents accidental exposure of destructive endpoints when authentication is misconfigured in production. The flag always emits a warning when set, even alongside JWT, to surface forgotten dev flags in staging/production logs. --- .env | 1 + .../hash-graph/src/subcommand/admin_server.rs | 32 ++++++++++++-- libs/@local/graph/api/src/rest/admin.rs | 43 +++++++++++-------- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/.env b/.env index ca84adb712e..a62bb999cfd 100644 --- a/.env +++ b/.env @@ -60,6 +60,7 @@ HASH_GRAPH_TYPE_FETCHER_PORT=4455 HASH_GRAPH_EMBED_ADMIN=true HASH_GRAPH_ADMIN_HOST=127.0.0.1 HASH_GRAPH_ADMIN_PORT=4001 +HASH_GRAPH_UNSAFE_DEV_AUTH=true HASH_GRAPH_PG_USER=graph HASH_GRAPH_PG_PASSWORD=graph diff --git a/apps/hash-graph/src/subcommand/admin_server.rs b/apps/hash-graph/src/subcommand/admin_server.rs index 236840b9695..a162884b4ca 100644 --- a/apps/hash-graph/src/subcommand/admin_server.rs +++ b/apps/hash-graph/src/subcommand/admin_server.rs @@ -52,8 +52,8 @@ fn parse_jwt_algorithm(name: &str) -> Result { /// JWT authentication configuration for the admin server. /// /// All three identity fields (`jwks_url`, `audience`, `issuer`) must be provided together or not at -/// all. When none are set, JWT authentication is disabled (development mode). Partial configuration -/// is rejected by clap's `requires`. +/// all. When none are set, `--unsafe-allow-dev-authentication` is required for the server to start. +/// Partial configuration is rejected by clap's `requires`. /// /// Operational parameters (cache TTL, refresh cooldown, HTTP timeout, algorithms) have sensible /// defaults and only take effect when JWT authentication is enabled. @@ -148,6 +148,19 @@ pub struct AdminConfig { #[clap(flatten)] pub jwt: JwtConfig, + + /// Allow header-based authentication and bulk destructive endpoints without JWT. + /// + /// When set, the admin server accepts the `X-Authenticated-User-Actor-Id` header for + /// authentication and registers bulk destructive endpoints (`/snapshot`, `/accounts`, + /// `/data-types`, `/property-types`, `/entity-types`). + /// + /// **This flag must never be used in production or staging.** Without JWT, any client that + /// can reach the admin port can execute destructive operations without authentication. + /// + /// If neither JWT nor this flag is configured, the server refuses to start. + #[clap(long, env = "HASH_GRAPH_UNSAFE_DEV_AUTH", default_value_t = false)] + pub unsafe_allow_dev_authentication: bool, } /// CLI arguments for the standalone `admin-server` subcommand. @@ -173,6 +186,13 @@ pub(crate) async fn run_admin_server( config: AdminConfig, shutdown: CancellationToken, ) -> Result<(), Report> { + if config.unsafe_allow_dev_authentication { + tracing::warn!( + "--unsafe-allow-dev-authentication is set — header-based authentication is enabled \ + without verification. DO NOT use this in production." + ); + } + let jwt_validator = match (config.jwt.jwks_url, config.jwt.audience, config.jwt.issuer) { (Some(jwks_url), Some(audience), Some(issuer)) => { tracing::info!(%jwks_url, "JWT authentication enabled for admin API"); @@ -186,9 +206,13 @@ pub(crate) async fn run_admin_server( allowed_algorithms: config.jwt.allowed_algorithms, }))) } + (None, None, None) if config.unsafe_allow_dev_authentication => None, (None, None, None) => { - tracing::warn!("JWT authentication disabled for admin API -- no JWKS URL configured"); - None + return Err(Report::new(GraphError).attach( + "no JWT authentication configured and --unsafe-allow-dev-authentication is not \ + set. Either configure JWT (--jwt-jwks-url, --jwt-audience, --jwt-issuer) or pass \ + --unsafe-allow-dev-authentication for local development.", + )); } _ => { // Clap `requires` should prevent this, but guard against it anyway. diff --git a/libs/@local/graph/api/src/rest/admin.rs b/libs/@local/graph/api/src/rest/admin.rs index b9e8aafbd9a..7fb67475c05 100644 --- a/libs/@local/graph/api/src/rest/admin.rs +++ b/libs/@local/graph/api/src/rest/admin.rs @@ -5,15 +5,15 @@ //! //! # Endpoints //! -//! | Method | Path | Auth | Availability | -//! |----------|--------------------|------|--------------------------| -//! | `GET` | `/health` | — | Always | -//! | `POST` | `/entities/delete` | JWT | Always | -//! | `POST` | `/snapshot` | — | Only without JWT | -//! | `DELETE` | `/accounts` | — | Only without JWT | -//! | `DELETE` | `/data-types` | — | Only without JWT | -//! | `DELETE` | `/property-types` | — | Only without JWT | -//! | `DELETE` | `/entity-types` | — | Only without JWT | +//! | Method | Path | Auth | Availability | +//! |----------|--------------------|--------|-------------------------------------| +//! | `GET` | `/health` | — | Always | +//! | `POST` | `/entities/delete` | JWT | Always | +//! | `POST` | `/snapshot` | Header | `--unsafe-allow-dev-authentication` | +//! | `DELETE` | `/accounts` | Header | `--unsafe-allow-dev-authentication` | +//! | `DELETE` | `/data-types` | Header | `--unsafe-allow-dev-authentication` | +//! | `DELETE` | `/property-types` | Header | `--unsafe-allow-dev-authentication` | +//! | `DELETE` | `/entity-types` | Header | `--unsafe-allow-dev-authentication` | //! //! # Authentication //! @@ -22,8 +22,9 @@ //! 2. `Authorization: Bearer ` //! //! When JWT is configured (`--jwt-jwks-url`), the token's `email` claim is resolved to a HASH -//! user actor for provenance tracking on `/entities/delete`. When JWT is not configured, the -//! `X-Authenticated-User-Actor-Id` header is used instead. +//! user actor for provenance tracking on `/entities/delete`. When JWT is not configured (requires +//! `--unsafe-allow-dev-authentication`), the `X-Authenticated-User-Actor-Id` header is used +//! instead. Without JWT and without the flag, the server refuses to start. //! //! See [`super::jwt`] for validation details. //! @@ -72,7 +73,10 @@ use crate::rest::status::report_to_response; /// /// When `jwt_validator` is `Some`, only `/health` and `/entities/delete` are available. /// Bulk destructive endpoints (`/snapshot`, `/accounts`, `/data-types`, `/property-types`, -/// `/entity-types`) are only registered when JWT is **not** configured. +/// `/entity-types`) are only registered when JWT is **not** configured, which requires the +/// `--unsafe-allow-dev-authentication` CLI flag — the server refuses to start without JWT +/// unless that flag is explicitly set. This prevents accidental exposure of destructive +/// endpoints when authentication is misconfigured in production. pub fn routes(store_pool: PostgresStorePool, jwt_validator: Option>) -> Router { // Health endpoint is always public (used by load balancers and healthchecks) let public = Router::new().route("/health", get(async || "Healthy")); @@ -112,8 +116,8 @@ enum AdminActorError { /// Resolves the authenticated admin actor from JWT claims. /// /// When JWT authentication is configured, resolves the actor ID by looking up the email from the -/// token claims. When JWT is not configured, falls back to the `X-Authenticated-User-Actor-Id` -/// header. +/// token claims. When JWT is not configured (requires `--unsafe-allow-dev-authentication`), falls +/// back to the `X-Authenticated-User-Actor-Id` header. struct AdminActorId(AuthenticatedActor); impl FromRequestParts for AdminActorId { @@ -166,7 +170,8 @@ impl FromRequestParts for AdminActorId { /// Restores a snapshot from a JSON Lines stream, replacing all existing data. /// -/// Only available when JWT is not configured. See [`SnapshotStore::restore_snapshot`] for details. +/// Only available with `--unsafe-allow-dev-authentication`. See [`SnapshotStore::restore_snapshot`] +/// for details. async fn restore_snapshot( store_pool: Extension>, snapshot: Body, @@ -192,7 +197,7 @@ async fn restore_snapshot( ))) } -/// Deletes **all** accounts. Only available when JWT is not configured. +/// Deletes **all** accounts. Only available with `--unsafe-allow-dev-authentication`. /// /// See [`PostgresStore::delete_principals`] for details. /// @@ -214,7 +219,7 @@ async fn delete_accounts( ))) } -/// Deletes **all** data types. Only available when JWT is not configured. +/// Deletes **all** data types. Only available with `--unsafe-allow-dev-authentication`. /// /// See [`PostgresStore::delete_data_types`] for details. /// @@ -236,7 +241,7 @@ async fn delete_data_types( ))) } -/// Deletes **all** property types. Only available when JWT is not configured. +/// Deletes **all** property types. Only available with `--unsafe-allow-dev-authentication`. /// /// See [`PostgresStore::delete_property_types`] for details. /// @@ -258,7 +263,7 @@ async fn delete_property_types( ))) } -/// Deletes **all** entity types. Only available when JWT is not configured. +/// Deletes **all** entity types. Only available with `--unsafe-allow-dev-authentication`. /// /// See [`PostgresStore::delete_entity_types`] for details. /// From 01a3261bc9be4744e253daf40f647ed1466db1a4 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Thu, 12 Mar 2026 17:14:40 +0100 Subject: [PATCH 5/9] BE-445: Decouple dev endpoints from JWT configuration Dev endpoints (snapshot, delete accounts/types) are now controlled by --unsafe-allow-dev-authentication independently of JWT. This allows running both JWT auth and dev endpoints simultaneously, and makes the warning message context-aware: when JWT is also configured, it warns that the flag has no auth effect rather than falsely claiming header auth is active. --- .../hash-graph/src/subcommand/admin_server.rs | 27 +++++++---- libs/@local/graph/api/src/rest/admin.rs | 48 ++++++++++--------- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/apps/hash-graph/src/subcommand/admin_server.rs b/apps/hash-graph/src/subcommand/admin_server.rs index a162884b4ca..e5a1fc44eea 100644 --- a/apps/hash-graph/src/subcommand/admin_server.rs +++ b/apps/hash-graph/src/subcommand/admin_server.rs @@ -186,15 +186,14 @@ pub(crate) async fn run_admin_server( config: AdminConfig, shutdown: CancellationToken, ) -> Result<(), Report> { - if config.unsafe_allow_dev_authentication { - tracing::warn!( - "--unsafe-allow-dev-authentication is set — header-based authentication is enabled \ - without verification. DO NOT use this in production." - ); - } - let jwt_validator = match (config.jwt.jwks_url, config.jwt.audience, config.jwt.issuer) { (Some(jwks_url), Some(audience), Some(issuer)) => { + if config.unsafe_allow_dev_authentication { + tracing::warn!( + "--unsafe-allow-dev-authentication is set but JWT is configured -- the flag \ + has no effect. Remove it to silence this warning." + ); + } tracing::info!(%jwks_url, "JWT authentication enabled for admin API"); Some(Arc::new(JwtValidator::new(JwtValidatorConfig { jwks_url, @@ -206,7 +205,13 @@ pub(crate) async fn run_admin_server( allowed_algorithms: config.jwt.allowed_algorithms, }))) } - (None, None, None) if config.unsafe_allow_dev_authentication => None, + (None, None, None) if config.unsafe_allow_dev_authentication => { + tracing::warn!( + "--unsafe-allow-dev-authentication is set -- header-based authentication is \ + enabled without verification. DO NOT use this in production." + ); + None + } (None, None, None) => { return Err(Report::new(GraphError).attach( "no JWT authentication configured and --unsafe-allow-dev-authentication is not \ @@ -223,7 +228,11 @@ pub(crate) async fn run_admin_server( } }; - let router = hash_graph_api::rest::admin::routes(pool, jwt_validator); + let router = hash_graph_api::rest::admin::routes( + pool, + jwt_validator, + config.unsafe_allow_dev_authentication, + ); let listener = TcpListener::bind((&*config.address.admin_host, config.address.admin_port)) .await diff --git a/libs/@local/graph/api/src/rest/admin.rs b/libs/@local/graph/api/src/rest/admin.rs index 7fb67475c05..b78479bb950 100644 --- a/libs/@local/graph/api/src/rest/admin.rs +++ b/libs/@local/graph/api/src/rest/admin.rs @@ -5,15 +5,15 @@ //! //! # Endpoints //! -//! | Method | Path | Auth | Availability | -//! |----------|--------------------|--------|-------------------------------------| -//! | `GET` | `/health` | — | Always | -//! | `POST` | `/entities/delete` | JWT | Always | -//! | `POST` | `/snapshot` | Header | `--unsafe-allow-dev-authentication` | -//! | `DELETE` | `/accounts` | Header | `--unsafe-allow-dev-authentication` | -//! | `DELETE` | `/data-types` | Header | `--unsafe-allow-dev-authentication` | -//! | `DELETE` | `/property-types` | Header | `--unsafe-allow-dev-authentication` | -//! | `DELETE` | `/entity-types` | Header | `--unsafe-allow-dev-authentication` | +//! | Method | Path | Auth | Availability | +//! |----------|--------------------|------|-------------------------------------| +//! | `GET` | `/health` | -- | Always | +//! | `POST` | `/entities/delete` | JWT | Always | +//! | `POST` | `/snapshot` | -- | `--unsafe-allow-dev-authentication` | +//! | `DELETE` | `/accounts` | -- | `--unsafe-allow-dev-authentication` | +//! | `DELETE` | `/data-types` | -- | `--unsafe-allow-dev-authentication` | +//! | `DELETE` | `/property-types` | -- | `--unsafe-allow-dev-authentication` | +//! | `DELETE` | `/entity-types` | -- | `--unsafe-allow-dev-authentication` | //! //! # Authentication //! @@ -71,24 +71,28 @@ use crate::rest::status::report_to_response; /// Creates the admin API router. /// -/// When `jwt_validator` is `Some`, only `/health` and `/entities/delete` are available. -/// Bulk destructive endpoints (`/snapshot`, `/accounts`, `/data-types`, `/property-types`, -/// `/entity-types`) are only registered when JWT is **not** configured, which requires the -/// `--unsafe-allow-dev-authentication` CLI flag — the server refuses to start without JWT -/// unless that flag is explicitly set. This prevents accidental exposure of destructive -/// endpoints when authentication is misconfigured in production. -pub fn routes(store_pool: PostgresStorePool, jwt_validator: Option>) -> Router { +/// `jwt_validator` enables JWT authentication for protected endpoints. When `Some`, the token's +/// `email` claim is resolved to a HASH user actor. When `None` (requires +/// `--unsafe-allow-dev-authentication`), the `X-Authenticated-User-Actor-Id` header is used. +/// +/// `dev_endpoints` enables bulk destructive endpoints (`/snapshot`, `/accounts`, `/data-types`, +/// `/property-types`, `/entity-types`). These require `--unsafe-allow-dev-authentication` and are +/// intended for local development and testing only. +pub fn routes( + store_pool: PostgresStorePool, + jwt_validator: Option>, + dev_endpoints: bool, +) -> Router { // Health endpoint is always public (used by load balancers and healthchecks) let public = Router::new().route("/health", get(async || "Healthy")); let mut protected = Router::new().route("/entities/delete", post(delete_entities)); - if let Some(validator) = jwt_validator { - protected = protected.layer(Extension(validator)); - } else { - // Bulk destructive endpoints are only available when JWT is not configured. - // In production/staging (JWT enabled), these are disabled to prevent accidental - // data loss — use snapshots or targeted entity deletion instead. + if let Some(validator) = &jwt_validator { + protected = protected.layer(Extension(Arc::clone(validator))); + } + + if dev_endpoints { protected = protected .route("/snapshot", post(restore_snapshot)) .route("/accounts", delete(delete_accounts)) From 1402942211b44c459ac695f8fea667cf5e6140b1 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Thu, 12 Mar 2026 17:24:53 +0100 Subject: [PATCH 6/9] BE-445: Apply JWT layer after dev endpoint registration Move JWT Extension layer after all routes are registered so dev endpoints are also covered by JWT when both are configured. Fix misleading warning that claimed the flag had no effect when JWT was active. --- apps/hash-graph/src/subcommand/admin_server.rs | 4 ++-- libs/@local/graph/api/src/rest/admin.rs | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/hash-graph/src/subcommand/admin_server.rs b/apps/hash-graph/src/subcommand/admin_server.rs index e5a1fc44eea..2a4f49aaa77 100644 --- a/apps/hash-graph/src/subcommand/admin_server.rs +++ b/apps/hash-graph/src/subcommand/admin_server.rs @@ -190,8 +190,8 @@ pub(crate) async fn run_admin_server( (Some(jwks_url), Some(audience), Some(issuer)) => { if config.unsafe_allow_dev_authentication { tracing::warn!( - "--unsafe-allow-dev-authentication is set but JWT is configured -- the flag \ - has no effect. Remove it to silence this warning." + "--unsafe-allow-dev-authentication is set -- dev endpoints are registered \ + alongside JWT. Remove the flag in production to disable them." ); } tracing::info!(%jwks_url, "JWT authentication enabled for admin API"); diff --git a/libs/@local/graph/api/src/rest/admin.rs b/libs/@local/graph/api/src/rest/admin.rs index b78479bb950..a186c084003 100644 --- a/libs/@local/graph/api/src/rest/admin.rs +++ b/libs/@local/graph/api/src/rest/admin.rs @@ -88,10 +88,6 @@ pub fn routes( let mut protected = Router::new().route("/entities/delete", post(delete_entities)); - if let Some(validator) = &jwt_validator { - protected = protected.layer(Extension(Arc::clone(validator))); - } - if dev_endpoints { protected = protected .route("/snapshot", post(restore_snapshot)) @@ -101,6 +97,11 @@ pub fn routes( .route("/entity-types", delete(delete_entity_types)); } + // Apply JWT layer after all routes so it covers dev endpoints too + if let Some(validator) = jwt_validator { + protected = protected.layer(Extension(validator)); + } + public .merge(protected) .layer(http_tracing_layer::HttpTracingLayer) From cb289f6d07137020f8973be239334d7ca6de6b1a Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Thu, 12 Mar 2026 17:33:25 +0100 Subject: [PATCH 7/9] BE-445: Clarify that dev endpoints are intentionally unauthenticated --- libs/@local/graph/api/src/rest/admin.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/@local/graph/api/src/rest/admin.rs b/libs/@local/graph/api/src/rest/admin.rs index a186c084003..b8bb5bd2bed 100644 --- a/libs/@local/graph/api/src/rest/admin.rs +++ b/libs/@local/graph/api/src/rest/admin.rs @@ -97,7 +97,9 @@ pub fn routes( .route("/entity-types", delete(delete_entity_types)); } - // Apply JWT layer after all routes so it covers dev endpoints too + // Makes JwtValidator available to handlers that extract OptionalJwtAuthentication + // (currently only /entities/delete). Dev endpoints are intentionally unauthenticated + // -- they require --unsafe-allow-dev-authentication which is an explicit opt-in. if let Some(validator) = jwt_validator { protected = protected.layer(Extension(validator)); } From a3b355730499db01ccf3fe6336bc0637fd3f20cc Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Thu, 12 Mar 2026 17:48:23 +0100 Subject: [PATCH 8/9] BE-445: Make JWT and dev auth mutually exclusive Add clap conflicts_with and runtime guard to reject both --unsafe-allow-dev-authentication and JWT being configured at the same time. Simplify routes() back to Option since the dev endpoints bool is now redundant. --- .../hash-graph/src/subcommand/admin_server.rs | 22 +++++++------ libs/@local/graph/api/src/rest/admin.rs | 31 +++++++------------ 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/apps/hash-graph/src/subcommand/admin_server.rs b/apps/hash-graph/src/subcommand/admin_server.rs index 2a4f49aaa77..9bf2f93d54a 100644 --- a/apps/hash-graph/src/subcommand/admin_server.rs +++ b/apps/hash-graph/src/subcommand/admin_server.rs @@ -159,7 +159,12 @@ pub struct AdminConfig { /// can reach the admin port can execute destructive operations without authentication. /// /// If neither JWT nor this flag is configured, the server refuses to start. - #[clap(long, env = "HASH_GRAPH_UNSAFE_DEV_AUTH", default_value_t = false)] + #[clap( + long, + env = "HASH_GRAPH_UNSAFE_DEV_AUTH", + default_value_t = false, + conflicts_with = "jwks_url" + )] pub unsafe_allow_dev_authentication: bool, } @@ -189,10 +194,11 @@ pub(crate) async fn run_admin_server( let jwt_validator = match (config.jwt.jwks_url, config.jwt.audience, config.jwt.issuer) { (Some(jwks_url), Some(audience), Some(issuer)) => { if config.unsafe_allow_dev_authentication { - tracing::warn!( - "--unsafe-allow-dev-authentication is set -- dev endpoints are registered \ - alongside JWT. Remove the flag in production to disable them." - ); + // Clap `conflicts_with` should prevent this, but guard against it anyway. + return Err(Report::new(GraphError).attach( + "--unsafe-allow-dev-authentication cannot be used with JWT authentication. \ + Remove the flag or the JWT configuration.", + )); } tracing::info!(%jwks_url, "JWT authentication enabled for admin API"); Some(Arc::new(JwtValidator::new(JwtValidatorConfig { @@ -228,11 +234,7 @@ pub(crate) async fn run_admin_server( } }; - let router = hash_graph_api::rest::admin::routes( - pool, - jwt_validator, - config.unsafe_allow_dev_authentication, - ); + let router = hash_graph_api::rest::admin::routes(pool, jwt_validator); let listener = TcpListener::bind((&*config.address.admin_host, config.address.admin_port)) .await diff --git a/libs/@local/graph/api/src/rest/admin.rs b/libs/@local/graph/api/src/rest/admin.rs index b8bb5bd2bed..547ccb38c1c 100644 --- a/libs/@local/graph/api/src/rest/admin.rs +++ b/libs/@local/graph/api/src/rest/admin.rs @@ -71,24 +71,22 @@ use crate::rest::status::report_to_response; /// Creates the admin API router. /// -/// `jwt_validator` enables JWT authentication for protected endpoints. When `Some`, the token's -/// `email` claim is resolved to a HASH user actor. When `None` (requires -/// `--unsafe-allow-dev-authentication`), the `X-Authenticated-User-Actor-Id` header is used. +/// JWT and dev mode are mutually exclusive (enforced by the caller). /// -/// `dev_endpoints` enables bulk destructive endpoints (`/snapshot`, `/accounts`, `/data-types`, -/// `/property-types`, `/entity-types`). These require `--unsafe-allow-dev-authentication` and are -/// intended for local development and testing only. -pub fn routes( - store_pool: PostgresStorePool, - jwt_validator: Option>, - dev_endpoints: bool, -) -> Router { - // Health endpoint is always public (used by load balancers and healthchecks) +/// - **JWT mode** (`Some`): Only `/health` and `/entities/delete` are available. The token's +/// `email` claim is resolved to a HASH user actor for provenance tracking. +/// - **Dev mode** (`None`, requires `--unsafe-allow-dev-authentication`): All endpoints are +/// available. The `X-Authenticated-User-Actor-Id` header is used for authentication. Bulk +/// destructive endpoints (`/snapshot`, `/accounts`, `/data-types`, `/property-types`, +/// `/entity-types`) are registered in this mode only. +pub fn routes(store_pool: PostgresStorePool, jwt_validator: Option>) -> Router { let public = Router::new().route("/health", get(async || "Healthy")); let mut protected = Router::new().route("/entities/delete", post(delete_entities)); - if dev_endpoints { + if let Some(validator) = jwt_validator { + protected = protected.layer(Extension(validator)); + } else { protected = protected .route("/snapshot", post(restore_snapshot)) .route("/accounts", delete(delete_accounts)) @@ -97,13 +95,6 @@ pub fn routes( .route("/entity-types", delete(delete_entity_types)); } - // Makes JwtValidator available to handlers that extract OptionalJwtAuthentication - // (currently only /entities/delete). Dev endpoints are intentionally unauthenticated - // -- they require --unsafe-allow-dev-authentication which is an explicit opt-in. - if let Some(validator) = jwt_validator { - protected = protected.layer(Extension(validator)); - } - public .merge(protected) .layer(http_tracing_layer::HttpTracingLayer) From 7f407e721e636cbdecdb1123791e782a95edc93f Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Thu, 12 Mar 2026 18:01:57 +0100 Subject: [PATCH 9/9] BE-445: Restore OptionalJwtAuthentication and audit logging on dev handlers --- libs/@local/graph/api/src/rest/admin.rs | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/libs/@local/graph/api/src/rest/admin.rs b/libs/@local/graph/api/src/rest/admin.rs index 547ccb38c1c..d2c9fafe706 100644 --- a/libs/@local/graph/api/src/rest/admin.rs +++ b/libs/@local/graph/api/src/rest/admin.rs @@ -171,9 +171,15 @@ impl FromRequestParts for AdminActorId { /// Only available with `--unsafe-allow-dev-authentication`. See [`SnapshotStore::restore_snapshot`] /// for details. async fn restore_snapshot( + jwt: OptionalJwtAuthentication, store_pool: Extension>, snapshot: Body, ) -> Result { + tracing::info!( + sub = jwt.0.as_ref().map(|claims| claims.sub.as_str()), + email = jwt.0.as_ref().and_then(|claims| claims.email.as_deref()), + "restoring snapshot" + ); let store = store_pool.acquire(None).await.map_err(report_to_response)?; SnapshotStore::new(store) @@ -201,8 +207,14 @@ async fn restore_snapshot( /// /// [`PostgresStore::delete_principals`]: hash_graph_postgres_store::store::PostgresStore::delete_principals async fn delete_accounts( + jwt: OptionalJwtAuthentication, pool: Extension>, ) -> Result { + tracing::info!( + sub = jwt.0.as_ref().map(|claims| claims.sub.as_str()), + email = jwt.0.as_ref().and_then(|claims| claims.email.as_deref()), + "deleting all accounts" + ); pool.acquire(None) .await .map_err(report_to_response)? @@ -223,8 +235,14 @@ async fn delete_accounts( /// /// [`PostgresStore::delete_data_types`]: hash_graph_postgres_store::store::PostgresStore::delete_data_types async fn delete_data_types( + jwt: OptionalJwtAuthentication, pool: Extension>, ) -> Result { + tracing::info!( + sub = jwt.0.as_ref().map(|claims| claims.sub.as_str()), + email = jwt.0.as_ref().and_then(|claims| claims.email.as_deref()), + "deleting all data types" + ); pool.acquire(None) .await .map_err(report_to_response)? @@ -245,8 +263,14 @@ async fn delete_data_types( /// /// [`PostgresStore::delete_property_types`]: hash_graph_postgres_store::store::PostgresStore::delete_property_types async fn delete_property_types( + jwt: OptionalJwtAuthentication, pool: Extension>, ) -> Result { + tracing::info!( + sub = jwt.0.as_ref().map(|claims| claims.sub.as_str()), + email = jwt.0.as_ref().and_then(|claims| claims.email.as_deref()), + "deleting all property types" + ); pool.acquire(None) .await .map_err(report_to_response)? @@ -267,8 +291,14 @@ async fn delete_property_types( /// /// [`PostgresStore::delete_entity_types`]: hash_graph_postgres_store::store::PostgresStore::delete_entity_types async fn delete_entity_types( + jwt: OptionalJwtAuthentication, pool: Extension>, ) -> Result { + tracing::info!( + sub = jwt.0.as_ref().map(|claims| claims.sub.as_str()), + email = jwt.0.as_ref().and_then(|claims| claims.email.as_deref()), + "deleting all entity types" + ); pool.acquire(None) .await .map_err(report_to_response)?