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..9bf2f93d54a 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,24 @@ 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, + conflicts_with = "jwks_url" + )] + pub unsafe_allow_dev_authentication: bool, } /// CLI arguments for the standalone `admin-server` subcommand. @@ -175,6 +193,13 @@ pub(crate) async fn run_admin_server( ) -> Result<(), Report> { 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 { + // 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 { jwks_url, @@ -186,10 +211,20 @@ pub(crate) async fn run_admin_server( allowed_algorithms: config.jwt.allowed_algorithms, }))) } - (None, None, None) => { - tracing::warn!("JWT authentication disabled for admin API -- no JWKS URL configured"); + (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 \ + 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. return Err(Report::new(GraphError).attach( diff --git a/libs/@local/graph/api/src/rest/admin.rs b/libs/@local/graph/api/src/rest/admin.rs index ef0ab3fd0c0..d2c9fafe706 100644 --- a/libs/@local/graph/api/src/rest/admin.rs +++ b/libs/@local/graph/api/src/rest/admin.rs @@ -1,7 +1,39 @@ //! 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` | -- | `--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 +//! +//! 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 (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. +//! +//! # 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; @@ -39,22 +71,28 @@ 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). +/// JWT and dev mode are mutually exclusive (enforced by the caller). +/// +/// - **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 { - // 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 { + 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 @@ -76,8 +114,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 disabled (dev mode), 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 { @@ -87,7 +125,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 @@ -128,6 +166,10 @@ impl FromRequestParts for AdminActorId { } } +/// Restores a snapshot from a JSON Lines stream, replacing all existing data. +/// +/// Only available with `--unsafe-allow-dev-authentication`. See [`SnapshotStore::restore_snapshot`] +/// for details. async fn restore_snapshot( jwt: OptionalJwtAuthentication, store_pool: Extension>, @@ -136,9 +178,8 @@ async fn restore_snapshot( 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" + "restoring snapshot" ); - let store = store_pool.acquire(None).await.map_err(report_to_response)?; SnapshotStore::new(store) @@ -160,6 +201,11 @@ async fn restore_snapshot( ))) } +/// Deletes **all** accounts. Only available with `--unsafe-allow-dev-authentication`. +/// +/// See [`PostgresStore::delete_principals`] for details. +/// +/// [`PostgresStore::delete_principals`]: hash_graph_postgres_store::store::PostgresStore::delete_principals async fn delete_accounts( jwt: OptionalJwtAuthentication, pool: Extension>, @@ -167,9 +213,8 @@ async fn delete_accounts( 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" + "deleting all accounts" ); - pool.acquire(None) .await .map_err(report_to_response)? @@ -184,6 +229,11 @@ async fn delete_accounts( ))) } +/// Deletes **all** data types. Only available with `--unsafe-allow-dev-authentication`. +/// +/// 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( jwt: OptionalJwtAuthentication, pool: Extension>, @@ -191,9 +241,8 @@ async fn delete_data_types( 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" + "deleting all data types" ); - pool.acquire(None) .await .map_err(report_to_response)? @@ -208,6 +257,11 @@ async fn delete_data_types( ))) } +/// Deletes **all** property types. Only available with `--unsafe-allow-dev-authentication`. +/// +/// 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( jwt: OptionalJwtAuthentication, pool: Extension>, @@ -215,9 +269,8 @@ async fn delete_property_types( 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" + "deleting all property types" ); - pool.acquire(None) .await .map_err(report_to_response)? @@ -232,6 +285,11 @@ async fn delete_property_types( ))) } +/// Deletes **all** entity types. Only available with `--unsafe-allow-dev-authentication`. +/// +/// 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( jwt: OptionalJwtAuthentication, pool: Extension>, @@ -239,9 +297,8 @@ async fn delete_entity_types( 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" + "deleting all entity types" ); - pool.acquire(None) .await .map_err(report_to_response)? @@ -257,6 +314,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")]