From 870afde910994c24be0eea0921358b6ad8d8ce2b Mon Sep 17 00:00:00 2001 From: Zyrakq Date: Wed, 24 Jun 2026 23:21:50 +0000 Subject: [PATCH] Add WASM admin panel extensibility for modules Made loaded WASM modules visible to administrators Allowed modules to present a branded identity in the dashboard Enabled modules to surface their own configuration UI in admin Equipped module authors with built-in admin access control Turn WASM into a first-class extension model for the admin Remove the need for a separate admin interface per extension Spare module authors from re-implementing admin access control --- crates/assets/js/admin/src/App.tsx | 4 + .../js/admin/src/components/IndexPage.tsx | 6 + .../assets/js/admin/src/components/Navbar.tsx | 2 + .../wasm/WasmModuleSettingsPage.tsx | 147 ++++++++++++++++++ .../src/components/wasm/WasmModulesPage.tsx | 120 ++++++++++++++ .../js/admin/src/lib/api/wasm-modules.ts | 8 + .../js/bindings/ListWasmModulesResponse.ts | 4 + crates/assets/js/bindings/WasmModuleEntry.ts | 9 ++ .../core/bindings/ListWasmModulesResponse.ts | 4 + crates/core/bindings/WasmModuleEntry.ts | 9 ++ crates/core/src/admin/mod.rs | 2 + .../admin/wasm_modules/list_wasm_modules.rs | 113 ++++++++++++++ crates/core/src/admin/wasm_modules/mod.rs | 3 + crates/core/src/app_state.rs | 19 ++- crates/core/src/wasm/mod.rs | 69 +++++++- crates/wasm-runtime-guest/src/auth.rs | 61 ++++++++ crates/wasm-runtime-guest/src/http.rs | 55 +++++++ crates/wasm-runtime-guest/src/lib.rs | 1 + 18 files changed, 633 insertions(+), 3 deletions(-) create mode 100644 crates/assets/js/admin/src/components/wasm/WasmModuleSettingsPage.tsx create mode 100644 crates/assets/js/admin/src/components/wasm/WasmModulesPage.tsx create mode 100644 crates/assets/js/admin/src/lib/api/wasm-modules.ts create mode 100644 crates/assets/js/bindings/ListWasmModulesResponse.ts create mode 100644 crates/assets/js/bindings/WasmModuleEntry.ts create mode 100644 crates/core/bindings/ListWasmModulesResponse.ts create mode 100644 crates/core/bindings/WasmModuleEntry.ts create mode 100644 crates/core/src/admin/wasm_modules/list_wasm_modules.rs create mode 100644 crates/core/src/admin/wasm_modules/mod.rs create mode 100644 crates/wasm-runtime-guest/src/auth.rs diff --git a/crates/assets/js/admin/src/App.tsx b/crates/assets/js/admin/src/App.tsx index 334de320c..54b8a034d 100644 --- a/crates/assets/js/admin/src/App.tsx +++ b/crates/assets/js/admin/src/App.tsx @@ -6,6 +6,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; import { TablePage } from "@/components/tables/TablesPage"; import { AccountsPage } from "@/components/accounts/AccountsPage"; +import { WasmModulesPage } from "@/components/wasm/WasmModulesPage"; +import { WasmModuleSettingsPage } from "@/components/wasm/WasmModuleSettingsPage"; import { LoginPage } from "@/components/auth/LoginPage"; import { SettingsPage } from "@/components/settings/SettingsPage"; import { IndexPage } from "@/components/IndexPage"; @@ -100,6 +102,8 @@ const App: Component = () => { + + diff --git a/crates/assets/js/admin/src/components/IndexPage.tsx b/crates/assets/js/admin/src/components/IndexPage.tsx index 1428d87f6..81a15b675 100644 --- a/crates/assets/js/admin/src/components/IndexPage.tsx +++ b/crates/assets/js/admin/src/components/IndexPage.tsx @@ -6,6 +6,7 @@ import { TbOutlineEdit, TbOutlineChartDots3, TbOutlineUsers, + TbOutlinePackage, TbOutlineTimeline, TbOutlineSettings, } from "solid-icons/tb"; @@ -88,6 +89,11 @@ const elements = [ content: "Browse and manage your application's user registry.", href: `${BASE}/auth`, }, + { + icon: TbOutlinePackage, + content: "Loaded WASM modules", + href: `${BASE}/wasm-modules`, + }, { icon: TbOutlineTimeline, content: "Access logs for your application", diff --git a/crates/assets/js/admin/src/components/Navbar.tsx b/crates/assets/js/admin/src/components/Navbar.tsx index 3ef653c9d..1d9cfeeee 100644 --- a/crates/assets/js/admin/src/components/Navbar.tsx +++ b/crates/assets/js/admin/src/components/Navbar.tsx @@ -8,6 +8,7 @@ import { TbOutlineChartDots3, TbOutlineTimeline, TbOutlineSettings, + TbOutlinePackage, } from "solid-icons/tb"; import { AuthButton } from "@/components/auth/AuthButton"; @@ -36,6 +37,7 @@ const options = [ [`${BASE}/editor`, TbOutlineEdit, "SQL Editor"], [`${BASE}/erd`, TbOutlineChartDots3, "Entity Relationship Diagram"], [`${BASE}/auth`, TbOutlineUsers, "User Accounts"], + [`${BASE}/wasm-modules`, TbOutlinePackage, "WASM Modules"], [`${BASE}/logs`, TbOutlineTimeline, "Logs & Metrics"], [`${BASE}/settings/`, TbOutlineSettings, "Settings"], ] as const; diff --git a/crates/assets/js/admin/src/components/wasm/WasmModuleSettingsPage.tsx b/crates/assets/js/admin/src/components/wasm/WasmModuleSettingsPage.tsx new file mode 100644 index 000000000..1d0f7b2b8 --- /dev/null +++ b/crates/assets/js/admin/src/components/wasm/WasmModuleSettingsPage.tsx @@ -0,0 +1,147 @@ +import { createEffect, createSignal, Match, Show, Switch } from "solid-js"; +import { A, useParams } from "@solidjs/router"; +import { useQuery } from "@tanstack/solid-query"; +import { TbOutlineArrowLeft } from "solid-icons/tb"; + +import { Header } from "@/components/Header"; +import { Spinner } from "@/components/Spinner"; + +import { fetchWasmModules } from "@/lib/api/wasm-modules"; + +// Injects an HTML fragment string into a container element. +// Script tags in the fragment do not execute when set via innerHTML; this +// function clones each script element so the browser treats it as new and +// executes it. Non-script nodes are inserted as-is. +function injectFragment(container: HTMLDivElement, html: string): void { + container.innerHTML = html; + container.querySelectorAll("script").forEach((old) => { + const next = document.createElement("script"); + Array.from(old.attributes).forEach((attr) => { + next.setAttribute(attr.name, attr.value); + }); + next.textContent = old.textContent; + old.parentNode?.replaceChild(next, old); + }); +} + +export function WasmModuleSettingsPage() { + const params = useParams<{ name: string }>(); + + const wasmModules = useQuery(() => ({ + queryKey: ["wasm-modules"], + queryFn: fetchWasmModules, + // The module list doesn't change on its own during an admin session; + // refetching on window focus would re-inject the settings fragment and + // reset any unsaved state the user may have entered. + refetchOnWindowFocus: false, + })); + + const module = () => + wasmModules.data?.modules.find((m) => m.name === params.name); + + const [fragmentState, setFragmentState] = createSignal< + | { status: "idle" } + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ready" } + >({ status: "idle" }); + + let containerRef: HTMLDivElement | undefined; + // Tracks the config_path that was last successfully injected so that + // spurious re-runs of the effect (e.g. from an unrelated query refetch that + // produces a new object reference) don't wipe and re-inject the fragment. + let loadedConfigPath: string | undefined; + + createEffect(() => { + const mod = module(); + if (!mod) return; + const configPath = mod.config_path; + if (!configPath) return; + if (configPath === loadedConfigPath) return; + + setFragmentState({ status: "loading" }); + + fetch(configPath, { credentials: "include" }) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return res.text(); + }) + .then((html) => { + if (containerRef) { + injectFragment(containerRef, html); + loadedConfigPath = configPath; + setFragmentState({ status: "ready" }); + } + }) + .catch((err: unknown) => { + const message = + err instanceof Error ? err.message : "Failed to load settings"; + setFragmentState({ status: "error", message }); + }); + }); + + const backLink = () => ( + + + + ); + + return ( +
+ + +
+ } + > + + +
+
+ No module named "{params.name}" is installed. +
+ + + +
+
+ This module has no settings page. +
+ + + +
+ +
+ +
+ +
+
+ + +
+ Failed to load settings:{" "} + {(fragmentState() as { status: "error"; message: string }).message} +
+
+ +
+
+ + + +
+ ); +} diff --git a/crates/assets/js/admin/src/components/wasm/WasmModulesPage.tsx b/crates/assets/js/admin/src/components/wasm/WasmModulesPage.tsx new file mode 100644 index 000000000..ce3aec2cc --- /dev/null +++ b/crates/assets/js/admin/src/components/wasm/WasmModulesPage.tsx @@ -0,0 +1,120 @@ +import { createMemo, For, Show } from "solid-js"; +import { useQuery } from "@tanstack/solid-query"; +import { A } from "@solidjs/router"; +import { + TbOutlinePackage, + TbOutlinePuzzle, + TbOutlineSettings, +} from "solid-icons/tb"; + +import { Header } from "@/components/Header"; +import { Spinner } from "@/components/Spinner"; + +import { fetchWasmModules } from "@/lib/api/wasm-modules"; + +import type { WasmModuleEntry } from "@bindings/WasmModuleEntry"; + +function ModuleIcon(props: { icon?: string }) { + const src = (): string | undefined => { + const icon = props.icon; + if (icon === undefined) { + return undefined; + } + if (icon.trimStart().startsWith("} + > + + + ); +} + +function ModuleCard(props: { module: WasmModuleEntry }) { + const hasManifest = createMemo( + () => + props.module.display_name !== props.module.name || + props.module.icon !== null, + ); + + return ( +
+
+ +
+ +
+
+

{props.module.display_name}

+ + + {props.module.name} + + +
+ +

+ {props.module.description} +

+
+
+ + + + + + +
+ ); +} + +export function WasmModulesPage() { + const wasmModules = useQuery(() => ({ + queryKey: ["wasm-modules"], + queryFn: fetchWasmModules, + })); + + const modules = createMemo(() => wasmModules.data?.modules ?? []); + + return ( +
+
+ +
+ + +
+ } + > + 0} + fallback={ +
+ +
+ } + > + + {(module) => } + +
+ +
+ + ); +} diff --git a/crates/assets/js/admin/src/lib/api/wasm-modules.ts b/crates/assets/js/admin/src/lib/api/wasm-modules.ts new file mode 100644 index 000000000..4bdb280dd --- /dev/null +++ b/crates/assets/js/admin/src/lib/api/wasm-modules.ts @@ -0,0 +1,8 @@ +import { adminFetch } from "@/lib/fetch"; + +import type { ListWasmModulesResponse } from "@bindings/ListWasmModulesResponse"; + +export async function fetchWasmModules(): Promise { + const response = await adminFetch("/wasm-modules"); + return await response.json(); +} diff --git a/crates/assets/js/bindings/ListWasmModulesResponse.ts b/crates/assets/js/bindings/ListWasmModulesResponse.ts new file mode 100644 index 000000000..5c02392db --- /dev/null +++ b/crates/assets/js/bindings/ListWasmModulesResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WasmModuleEntry } from "./WasmModuleEntry"; + +export type ListWasmModulesResponse = { modules: Array, }; diff --git a/crates/assets/js/bindings/WasmModuleEntry.ts b/crates/assets/js/bindings/WasmModuleEntry.ts new file mode 100644 index 000000000..0d509142a --- /dev/null +++ b/crates/assets/js/bindings/WasmModuleEntry.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WasmModuleEntry = { +name: string, +display_name: string, +icon: string | null, +config_path: string | null, +description: string | null, +}; diff --git a/crates/core/bindings/ListWasmModulesResponse.ts b/crates/core/bindings/ListWasmModulesResponse.ts new file mode 100644 index 000000000..5c02392db --- /dev/null +++ b/crates/core/bindings/ListWasmModulesResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WasmModuleEntry } from "./WasmModuleEntry"; + +export type ListWasmModulesResponse = { modules: Array, }; diff --git a/crates/core/bindings/WasmModuleEntry.ts b/crates/core/bindings/WasmModuleEntry.ts new file mode 100644 index 000000000..0d509142a --- /dev/null +++ b/crates/core/bindings/WasmModuleEntry.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WasmModuleEntry = { +name: string, +display_name: string, +icon: string | null, +config_path: string | null, +description: string | null, +}; diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs index 2d587f0c8..957e805cb 100644 --- a/crates/core/src/admin/mod.rs +++ b/crates/core/src/admin/mod.rs @@ -1,3 +1,4 @@ +mod wasm_modules; mod config; mod email; mod error; @@ -73,6 +74,7 @@ pub fn router() -> Router { ) .route("/public_key", get(jwt::get_public_key)) .route("/info", get(info::info_handler)) + .route("/wasm-modules", get(wasm_modules::list_wasm_modules_handler)) .route("/jobs", get(jobs::list_jobs_handler)) .route("/job/run", post(jobs::run_job_handler)) .route("/email/test", post(email::test_email_handler)) diff --git a/crates/core/src/admin/wasm_modules/list_wasm_modules.rs b/crates/core/src/admin/wasm_modules/list_wasm_modules.rs new file mode 100644 index 000000000..69d8419e9 --- /dev/null +++ b/crates/core/src/admin/wasm_modules/list_wasm_modules.rs @@ -0,0 +1,113 @@ +use axum::{Json, extract::State}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::admin::AdminError as Error; +use crate::app_state::AppState; + +#[derive(Debug, Deserialize, Serialize, TS)] +#[ts(export)] +pub struct WasmModuleEntry { + pub name: String, + pub display_name: String, + pub icon: Option, + pub config_path: Option, + pub description: Option, +} + +#[derive(Debug, Deserialize, Serialize, TS)] +#[ts(export)] +pub struct ListWasmModulesResponse { + pub modules: Vec, +} + +fn build_entry( + name: String, + manifest: Option<&crate::app_state::WasmManifest>, +) -> WasmModuleEntry { + return WasmModuleEntry { + display_name: manifest + .map(|m| m.display_name.clone()) + .unwrap_or_else(|| name.clone()), + icon: manifest.and_then(|m| m.icon.clone()), + config_path: manifest.and_then(|m| m.config_path.clone()), + description: manifest.and_then(|m| m.description.clone()), + name, + }; +} + +pub async fn list_wasm_modules_handler( + State(state): State, +) -> Result, Error> { + let mut names: Vec = Vec::new(); + for rt in state.wasm_runtimes() { + let path = rt.read().await.component_path().clone(); + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + names.push(name); + } + + let manifests = state.wasm_manifests().read().await; + let modules = names + .into_iter() + .map(|name| build_entry(name.clone(), manifests.get(&name))) + .collect(); + + return Ok(Json(ListWasmModulesResponse { modules })); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_state::{WasmManifest, test_state}; + + #[test] + fn build_entry_without_manifest_uses_name_as_display_name() { + let entry = build_entry("my_component".to_string(), None); + assert_eq!(entry.name, "my_component"); + assert_eq!(entry.display_name, "my_component"); + assert!(entry.icon.is_none()); + assert!(entry.config_path.is_none()); + } + + #[test] + fn build_entry_with_manifest_propagates_fields() { + let manifest = WasmManifest { + display_name: "My Component".to_string(), + icon: Some("".to_string()), + config_path: Some("/_/admin/my/config".to_string()), + description: Some("A test component".to_string()), + }; + let entry = build_entry("my_component".to_string(), Some(&manifest)); + assert_eq!(entry.name, "my_component"); + assert_eq!(entry.display_name, "My Component"); + assert_eq!(entry.icon.as_deref(), Some("")); + assert_eq!(entry.config_path.as_deref(), Some("/_/admin/my/config")); + assert_eq!(entry.description.as_deref(), Some("A test component")); + } + + #[tokio::test] + async fn list_wasm_modules_handler_returns_empty_when_no_runtimes() { + let state = test_state(None).await.unwrap(); + + state + .wasm_manifests() + .write() + .await + .insert( + "phantom".to_string(), + WasmManifest { + display_name: "Phantom".to_string(), + icon: Some("".to_string()), + config_path: Some("/_/admin/phantom".to_string()), + description: None, + }, + ); + + let response = list_wasm_modules_handler(State(state)).await.unwrap(); + assert!(response.modules.is_empty()); + } +} diff --git a/crates/core/src/admin/wasm_modules/mod.rs b/crates/core/src/admin/wasm_modules/mod.rs new file mode 100644 index 000000000..a54f1afc9 --- /dev/null +++ b/crates/core/src/admin/wasm_modules/mod.rs @@ -0,0 +1,3 @@ +mod list_wasm_modules; + +pub use list_wasm_modules::list_wasm_modules_handler; diff --git a/crates/core/src/app_state.rs b/crates/core/src/app_state.rs index f8ed2bb01..b0effe407 100644 --- a/crates/core/src/app_state.rs +++ b/crates/core/src/app_state.rs @@ -22,6 +22,14 @@ use crate::records::subscribe::manager::SubscriptionManager; use crate::scheduler::{JobRegistry, build_job_registry_from_config}; use crate::wasm::Runtime; +#[derive(Clone, Debug, serde::Deserialize)] +pub struct WasmManifest { + pub display_name: String, + pub icon: Option, + pub config_path: Option, + pub description: Option, +} + /// The app's internal state. AppState needs to be clonable which puts unnecessary constraints on /// the internals. Thus rather arc once than many times. struct InternalState { @@ -57,6 +65,7 @@ struct InternalState { wasm_runtimes: Vec>>, /// WASM runtime builders needed to rebuild above runtimes, e.g. when hot-reloading. wasm_runtimes_builder: crate::wasm::WasmRuntimeBuilder, + wasm_manifests: Arc>>, #[cfg(test)] #[allow(unused)] @@ -203,8 +212,9 @@ impl AppState { .map(|rt| Arc::new(RwLock::new(rt))) .collect(), wasm_runtimes_builder, - #[cfg(test)] - pg_uri: None, + wasm_manifests: Arc::new(RwLock::new(HashMap::new())), + #[cfg(test)] + pg_uri: None, #[cfg(test)] test_cleanup: vec![], }), @@ -401,6 +411,10 @@ impl AppState { return &self.state.wasm_runtimes; } + pub(crate) fn wasm_manifests(&self) -> &Arc>> { + return &self.state.wasm_manifests; + } + pub(crate) async fn reload_wasm_runtimes(&self) -> Result<(), crate::wasm::AnyError> { let mut new_runtimes = (self.state.wasm_runtimes_builder)()?; if new_runtimes.is_empty() { @@ -867,6 +881,7 @@ mod test_utils { object_store, wasm_runtimes: vec![], wasm_runtimes_builder: Box::new(|| Ok(vec![])), + wasm_manifests: Arc::new(RwLock::new(HashMap::new())), pg_uri, // NOTE: We gotta make sure `pg_db` is destroyed before the temp dir, otherwise it will // write new artifacts to the already deleted dir. diff --git a/crates/core/src/wasm/mod.rs b/crates/core/src/wasm/mod.rs index d4d897b8e..81f61dada 100644 --- a/crates/core/src/wasm/mod.rs +++ b/crates/core/src/wasm/mod.rs @@ -115,6 +115,49 @@ pub(crate) fn wasm_runtimes_builder( })); } +/// Probe a WASM component's manifest endpoint by calling it directly +/// through the runtime's HTTP store. Returns the parsed manifest or None +/// if the component doesn't expose one or the response is invalid. +pub(crate) async fn probe_manifest( + runtime: &Runtime, + manifest_path: &str, +) -> Option { + let manifest_uri = format!("http://localhost{manifest_path}"); + + let manifest_store = HttpStore::new(runtime).await.ok()?; + let context_header = to_header_value(&HttpContext { + kind: HttpContextKind::Http, + registered_path: manifest_path.to_string(), + path_params: vec![], + user: None, + }) + .ok()?; + + let probe_req = hyper::Request::builder() + .method(hyper::Method::GET) + .uri(manifest_uri) + .header("__context", context_header) + .body(empty()) + .ok()?; + + let resp = manifest_store.call_incoming_http_handler(probe_req).await.ok()?; + + if resp.status() != hyper::StatusCode::OK { + return None; + } + + let (_, body) = resp.into_parts(); + let collected = body.collect().await.ok()?; + + match serde_json::from_slice::(&collected.to_bytes()) { + Ok(manifest) => Some(manifest), + Err(err) => { + warn!("Manifest at '{manifest_path}' returned invalid JSON: {err}"); + None + } + } +} + pub(crate) async fn install_routes_and_jobs( state: &AppState, runtime: Arc>, @@ -130,6 +173,27 @@ pub(crate) async fn install_routes_and_jobs( .await? }; + let component_name = runtime + .read() + .await + .component_path() + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + // Convention: manifest is always at /_/wasm//manifest. + // Components must register their routes under the same prefix as their file name. + let manifest_path = format!("/_/wasm/{component_name}/manifest"); + if let Some(manifest) = probe_manifest(&*runtime.read().await, &manifest_path).await { + info!("Registering manifest for WASM component '{component_name}'"); + state + .wasm_manifests() + .write() + .await + .insert(component_name, manifest); + } + for (name, spec) in init_result.job_handlers { let schedule = cron::Schedule::from_str(&spec)?; let store = HttpStore::new(&*runtime.read().await).await?; @@ -199,7 +263,10 @@ pub(crate) async fn install_routes_and_jobs( .map(|(name, value)| (name.to_string(), value.to_string())) .collect(), user: user.map(|u| HttpContextUser { - id: u.id, + // The host encodes user IDs with BASE64_URL_SAFE (with padding), but the + // wasm-runtime-guest's is_admin() decodes with URL_SAFE_NO_PAD. Strip the + // padding here so the guest receives the format it expects. + id: u.id.trim_end_matches('=').to_string(), email: u.email, username: u.username, csrf_token: u.csrf_token, diff --git a/crates/wasm-runtime-guest/src/auth.rs b/crates/wasm-runtime-guest/src/auth.rs new file mode 100644 index 000000000..41f9c9b1e --- /dev/null +++ b/crates/wasm-runtime-guest/src/auth.rs @@ -0,0 +1,61 @@ +use base64::Engine; +use http::StatusCode; + +use crate::db::{self, Value}; +use crate::http::{HttpError, Request}; + +const CSRF_HEADER: &str = "CSRF-Token"; + +pub async fn require_admin(req: &Request) -> Result<(), HttpError> { + let Some(user) = req.user() else { + return Err(HttpError::status(StatusCode::UNAUTHORIZED)); + }; + + if !is_admin(&user.id).await? { + return Err(HttpError::status(StatusCode::FORBIDDEN)); + } + + if *req.method() != http::Method::GET { + let received = req.header(CSRF_HEADER).and_then(|v| v.to_str().ok()); + if received != Some(user.csrf_token.as_str()) { + return Err(HttpError::status(StatusCode::FORBIDDEN)); + } + } + + Ok(()) +} + +pub async fn is_admin(user_id: &str) -> Result { + let id_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(user_id.as_bytes()) + .map_err(|err| { + log::warn!("require_admin: invalid user id encoding: {err}"); + HttpError::status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + let rows = db::query( + r#"SELECT admin FROM "_user" WHERE id = ?"#, + vec![Value::Blob(id_bytes)], + ) + .await + .map_err(|err| { + log::warn!("require_admin: db query failed: {err}"); + HttpError::status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + + match rows.first().and_then(|row| row.first()) { + Some(Value::Integer(1)) => Ok(true), + Some(Value::Integer(0)) => Ok(false), + _ => Err(HttpError::status(StatusCode::FORBIDDEN)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn csrf_header_constant_matches_host() { + assert_eq!(CSRF_HEADER, "CSRF-Token"); + } +} diff --git a/crates/wasm-runtime-guest/src/http.rs b/crates/wasm-runtime-guest/src/http.rs index 49710a4b7..57623c8c4 100644 --- a/crates/wasm-runtime-guest/src/http.rs +++ b/crates/wasm-runtime-guest/src/http.rs @@ -96,6 +96,61 @@ impl HttpRoute { ), }; } + + pub fn require_admin(self) -> HttpRoute { + let Self { + method, + path, + handler: original, + } = self; + + let wrapped: HttpHandler = Box::new( + move |context: HttpContext, + req: http::Request, + responder: Responder| { + Box::pin(async move { + let Some(user) = context.user.as_ref() else { + return responder + .respond(empty_error_response(StatusCode::UNAUTHORIZED)) + .await; + }; + + if req.method() != Method::GET { + let received = req + .headers() + .get("CSRF-Token") + .and_then(|v| v.to_str().ok()); + if received != Some(user.csrf_token.as_str()) { + return responder + .respond(empty_error_response(StatusCode::FORBIDDEN)) + .await; + } + } + + let user_id = user.id.clone(); + match crate::auth::is_admin(&user_id).await { + Ok(true) => original(context, req, responder).await, + Ok(false) => { + responder + .respond(empty_error_response(StatusCode::FORBIDDEN)) + .await + } + Err(_err) => { + responder + .respond(empty_error_response(StatusCode::INTERNAL_SERVER_ERROR)) + .await + } + } + }) + }, + ); + + return HttpRoute { + method, + path, + handler: wrapped, + }; + } } pub mod routing { diff --git a/crates/wasm-runtime-guest/src/lib.rs b/crates/wasm-runtime-guest/src/lib.rs index b82bd853f..a3f484b17 100644 --- a/crates/wasm-runtime-guest/src/lib.rs +++ b/crates/wasm-runtime-guest/src/lib.rs @@ -26,6 +26,7 @@ pub mod wit { }); } +pub mod auth; pub mod db; pub mod fetch; pub mod fs;