Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 39 additions & 4 deletions apps/hash-graph/src/subcommand/admin_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ fn parse_jwt_algorithm(name: &str) -> Result<Algorithm, String> {
/// 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -175,6 +193,13 @@ pub(crate) async fn run_admin_server(
) -> Result<(), Report<GraphError>> {
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,
Expand All @@ -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(
Expand Down
112 changes: 87 additions & 25 deletions libs/@local/graph/api/src/rest/admin.rs
Original file line number Diff line number Diff line change
@@ -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 <token>`
//!
//! 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;

Expand Down Expand Up @@ -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<Arc<JwtValidator>>) -> 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
Expand All @@ -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<S: Sync> FromRequestParts<S> for AdminActorId {
Expand All @@ -87,7 +125,7 @@ impl<S: Sync> FromRequestParts<S> 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
Expand Down Expand Up @@ -128,6 +166,10 @@ impl<S: Sync> FromRequestParts<S> 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<Arc<PostgresStorePool>>,
Expand All @@ -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)
Expand All @@ -160,16 +201,20 @@ 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<Arc<PostgresStorePool>>,
) -> Result<BoxedResponse, BoxedResponse> {
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)?
Expand All @@ -184,16 +229,20 @@ 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<Arc<PostgresStorePool>>,
) -> Result<BoxedResponse, BoxedResponse> {
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)?
Expand All @@ -208,16 +257,20 @@ 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<Arc<PostgresStorePool>>,
) -> Result<BoxedResponse, BoxedResponse> {
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)?
Expand All @@ -232,16 +285,20 @@ 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<Arc<PostgresStorePool>>,
) -> Result<BoxedResponse, BoxedResponse> {
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)?
Expand All @@ -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<Arc<PostgresStorePool>>,
Expand Down
45 changes: 45 additions & 0 deletions libs/@local/graph/store/src/entity/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading