diff --git a/Cargo.lock b/Cargo.lock index 9e63dfb..fc99526 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzod" -version = "0.7.0" +version = "0.8.0" dependencies = [ "argon2", "askama", diff --git a/Cargo.toml b/Cargo.toml index c49ceae..b3493e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bzod" description = "Self-hosted multi-user URL management, landing page and QR analytics platform" -version = "0.7.0" +version = "0.8.0" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/thakares/nx9-url-shortener" diff --git a/README.md b/README.md index 37e0086..80b9ca9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue) ![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey) ![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green) -![Version](https://img.shields.io/badge/Version-v0.7.0-purple) +![Version](https://img.shields.io/badge/Version-v0.8.0-purple) [![GitHub](https://img.shields.io/badge/GitHub-thakares%2Fbzod-181717?logo=github)](https://github.com/thakares/bzod) [![Codeberg](https://img.shields.io/badge/Codeberg-thakares%2Fbzod-2185D0?logo=codeberg)](https://codeberg.org/thakares/bzod) @@ -93,7 +93,7 @@ No recurring subscription fees. --- -## Runtime Efficiency (v0.7.0) +## Runtime Efficiency (v0.8.0) | Metric | Value | |---------|------:| @@ -1337,7 +1337,7 @@ Community feedback helps guide future development. **Current Version** -**v0.7.0** +**v0.8.0** Production Ready diff --git a/deploy.sh b/deploy.sh index bef4824..55bee07 100755 --- a/deploy.sh +++ b/deploy.sh @@ -11,7 +11,7 @@ # sudo bash deploy.sh # # Environment overrides: -# BZOD_VERSION=0.7.1 +# BZOD_VERSION=0.8.0 # BZOD_IMAGE=nx9-url-shortener # BZOD_ROOT=/DATA/AppData/nx9-url-shortener # BZOD_PORT=8654 @@ -23,7 +23,7 @@ set -euo pipefail # Configuration # ============================================================ -BZOD_VERSION="${BZOD_VERSION:-0.7.1}" +BZOD_VERSION="${BZOD_VERSION:-0.8.0}" BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}" BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}" BZOD_PORT="${BZOD_PORT:-8654}" diff --git a/docker-compose.yml b/docker-compose.yml index 7fd3b5c..ae5e159 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: hostname: bzod - image: nx9-url-shortener:v0.7.0 + image: nx9-url-shortener:v0.8.0 ports: - mode: ingress diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md index 1c5c1ab..c3be919 100644 --- a/docs/ADMIN_GUIDE.md +++ b/docs/ADMIN_GUIDE.md @@ -1,6 +1,6 @@ # BZOD Administrator Guide -Version: v0.7.0 +Version: v0.8.0 --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d090032..050e360 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # BZOD Architecture Guide -Version: v0.7.0 +Version: v0.8.0 --- diff --git a/docs/BACKUP_RESTORE.md b/docs/BACKUP_RESTORE.md index 060df4b..ef57f90 100644 --- a/docs/BACKUP_RESTORE.md +++ b/docs/BACKUP_RESTORE.md @@ -1,6 +1,6 @@ # Backup & Restore Guide -Version: v0.7.0 +Version: v0.8.0 Applies To: BZOD Multi-User Platform --- diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d528c19..b93457e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog and this project follows Semantic Versioning. +# v0.8.0 — Core Admin Separation, Tenant Boundary & Authentication Hardening + +## Added + +* Core Admin is strictly platform-operator-only and has no tenant application storage. +* Inspection-only global URL and landing-page registries for Admin. +* Strict Admin/tenant route boundary with HTTP 403 enforcement. +* TenantId-based active ownership and tenant filesystem topology. +* Tenant-aware analytics worker grouping. +* Deterministic cross-role session invalidation and cookie clearing. + +## Changed + +* Removed active production dependencies on the legacy `system.db.global_slugs` registry. +* Removed request-time TenantId generation and integer tenant filesystem fallbacks from active tenant operations. +* Admin resource creation endpoints reject Core Admin actors with `403 Forbidden`. +* User login and Admin login now establish role-specific sessions and clear the opposite-role session. + +## Compatibility + +* Historical v0.7.x migration and legacy restore compatibility remains preserved. +* Legacy database/schema identifiers are retained only where required for migration and historical restore support. + +--- + --- # v0.7.0 — Responsive UI, Theme Support & Build Metadata diff --git a/docs/CLI.md b/docs/CLI.md index 23ee1c5..66c5a40 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -2,7 +2,7 @@ BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management. -The current command list for BZOD v0.7.0 is: +The current command list for BZOD v0.8.0 is: ```text $ bzod --help diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 957bd15..f9e1d48 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -1,4 +1,4 @@ -# BZOD v0.7.0 vs Self-Hosted URL Management Platforms +# BZOD v0.8.0 vs Self-Hosted URL Management Platforms BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform. diff --git a/docs/DATABASES.md b/docs/DATABASES.md index 81fcde6..3c56bc9 100644 --- a/docs/DATABASES.md +++ b/docs/DATABASES.md @@ -2,7 +2,7 @@ # BZOD Database Architecture -BZOD v0.7.0 uses SQLite exclusively. +BZOD v0.8.0 uses SQLite exclusively. Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index bf05ab6..87a5b60 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,6 +1,6 @@ # BZOD Installation Guide -Version: v0.7.0 +Version: v0.8.0 --- @@ -183,13 +183,13 @@ sudo pacman -S \ Example: ```bash -wget https://example.com/bzod-v0.7.0-linux-amd64.tar.gz +wget https://example.com/bzod-v0.8.0-linux-amd64.tar.gz ``` Extract: ```bash -tar -xzf bzod-v0.7.0-linux-amd64.tar.gz +tar -xzf bzod-v0.8.0-linux-amd64.tar.gz ``` Install: diff --git a/docs/MULTI_USER.md b/docs/MULTI_USER.md index d2692c2..9f59d77 100644 --- a/docs/MULTI_USER.md +++ b/docs/MULTI_USER.md @@ -1,6 +1,6 @@ # BZOD Multi-User Architecture Guide -Version: v0.7.0 +Version: v0.8.0 --- diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index f298799..0a22b35 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -1,3 +1,30 @@ +# BZOD v0.8.0 — Multi-Tenant Core Separation & Authorization Hardening + +Release Date: 2026-08-21 + +## Highlights + +- **Core Admin separation**: Admin is a platform operator, not a tenant and not an application resource owner. +- **Global slug registries**: Active URL and landing-page ownership uses `slugs/global_urls.db` and `slugs/global_landing_pages.db`. +- **Strict route boundary**: Core Admin is forbidden from `/user/*`; normal tenant users are forbidden from `/admin/*`. +- **Capability enforcement**: Admin resource creation through UI and REST/bulk endpoints returns `403 Forbidden`. +- **Tenant identity hardening**: Active tenant operations require an immutable `TenantId`; no request-time fallback generation or `users/1` application fallback. +- **Session hygiene**: Admin/user session cookies and server-side sessions are invalidated when switching principals or logging out. +- **Tenant-aware analytics**: Analytics events are grouped and persisted by `TenantId`. +- **Legacy compatibility preserved**: Legacy migration and restore paths remain available without being active production paths. + +## Verification + +- Phase 5 Core Separation tests: **4/4 passed** +- Admin capability boundary tests: **11/11 passed** +- Admin/user route and session boundary tests: **27/27 passed** +- Phase 6A elimination tests: **6/6 passed** +- Workspace regression suite: **all tests passed** +- `cargo fmt --all -- --check`: **PASS** +- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: **PASS** + +--- + # BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata Release Date: 2026-08-11 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 936e470..ac93e5e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,6 +1,6 @@ # BZOD Security Guide -Version: v0.7.0 +Version: v0.8.0 --- @@ -16,7 +16,7 @@ BZOD is designed as a self-hosted URL shortener and landing page platform with a * Disaster recovery * Operational simplicity -This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.7.0. +This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.8.0. --- @@ -620,7 +620,7 @@ If compromise is suspected: # Security Testing -BZOD v0.7.0 includes tests covering: +BZOD v0.8.0 includes tests covering: * Authentication * Authorization @@ -665,7 +665,7 @@ These may be addressed in future releases. # Summary -BZOD v0.7.0 provides: +BZOD v0.8.0 provides: * Centralized authentication * Secure session management diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index 3cd1a02..7fbc590 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -1,11 +1,23 @@ # Upgrade Guide -Version: v0.7.0 +Version: v0.8.0 -This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.7.0. +This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.8.0. --- +# BZOD v0.8.0 Upgrade Overview + +BZOD v0.8.0 completes the TenantId-based multi-tenant topology and separates Core Admin from tenant application resources. The active runtime uses the Core databases under `admin/`, global slug registries under `slugs/`, and tenant databases under `users//`. + +Key upgrade characteristics: + +* Core Admin has no tenant directory, `content.db`, or `analytics.db`. +* Active production operations no longer use `system.db.global_slugs`. +* Active tenant ownership is represented by immutable `TenantId`. +* Legacy integer IDs and legacy slug data remain available only to migration/restore compatibility paths. +* Existing legacy deployments should use the repository's migration and restore commands rather than manually copying legacy tenant directories into the v0.8 topology. + # Overview BZOD v0.5.1 is a platform hardening release focused on: diff --git a/src/auth/session.rs b/src/auth/session.rs index bbc958f..214e177 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -198,9 +198,9 @@ pub fn authenticate_user_session( return Ok(None); } - // Get tenant user (status must be 'active') + // Get tenant user (status must be 'active', account_type != 'admin', and tenant_id is Some) let user_opt = crate::db::users::get_user_by_id(users_conn, session.user_id)? - .filter(|u| u.status == "active"); + .filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some()); if let Some(user) = user_opt { Ok(Some((user, session.id))) @@ -234,8 +234,8 @@ pub fn authenticate_api_key( let user_id_opt: Option = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?; if let Some(user_id) = user_id_opt { - let user_opt = - crate::db::users::get_user_by_id(users_conn, user_id)?.filter(|u| u.status == "active"); + let user_opt = crate::db::users::get_user_by_id(users_conn, user_id)? + .filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some()); if let Some(user) = user_opt { return Ok(Some(ApiActor::User(user))); diff --git a/src/templates/pages.rs b/src/templates/pages.rs index 9df89b3..33dfaf5 100644 --- a/src/templates/pages.rs +++ b/src/templates/pages.rs @@ -5,11 +5,17 @@ use axum::{ response::{Html, IntoResponse, Response}, }; +pub struct AdminPageRow { + pub page: LandingPage, + pub owner_tenant_id: String, + pub owner_username: Option, +} + #[derive(Template)] #[template(path = "pages.html")] pub struct PagesTemplate { pub admin_username: String, - pub pages: Vec, + pub pages: Vec, pub csrf_token: String, pub error: Option, pub current_page: usize, diff --git a/src/templates/urls.rs b/src/templates/urls.rs index 9a6ef00..be6893c 100644 --- a/src/templates/urls.rs +++ b/src/templates/urls.rs @@ -5,11 +5,17 @@ use axum::{ response::{Html, IntoResponse, Response}, }; +pub struct AdminUrlRow { + pub url: Url, + pub owner_tenant_id: String, + pub owner_username: Option, +} + #[derive(Template)] #[template(path = "urls.html")] pub struct UrlsTemplate { pub admin_username: String, - pub urls: Vec, + pub urls: Vec, pub csrf_token: String, pub error: Option, pub tag_filter: Option, diff --git a/src/web/admin/auth.rs b/src/web/admin/auth.rs index 21208ae..a1ca1b2 100644 --- a/src/web/admin/auth.rs +++ b/src/web/admin/auth.rs @@ -116,6 +116,31 @@ fn audit_meta(ip: &str, headers: &HeaderMap) -> String { ) } +pub(crate) fn clear_admin_cookie(jar: CookieJar) -> CookieJar { + let cookie = Cookie::build("bzod_session") + .path("/") + .max_age(time::Duration::ZERO) + .build(); + jar.add(cookie) +} + +pub(crate) fn clear_user_cookie(jar: CookieJar) -> CookieJar { + let cookie = Cookie::build("bzod_user_session") + .path("/") + .max_age(time::Duration::ZERO) + .build(); + jar.add(cookie) +} + +pub(crate) fn invalidate_session_in_db(state: &AppState, session_id: &str) { + if session_id.trim().is_empty() { + return; + } + if let Ok(conn) = state.users_db.lock() { + let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [session_id]); + } +} + // POST /admin/login pub async fn login_post( State(state): State, @@ -233,6 +258,14 @@ pub async fn login_post( let session_token = generate_token(32); let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); + // Invalidate any existing sessions from the jar + if let Some(old_admin_cookie) = jar.get("bzod_session") { + invalidate_session_in_db(&state, old_admin_cookie.value()); + } + if let Some(old_user_cookie) = jar.get("bzod_user_session") { + invalidate_session_in_db(&state, old_user_cookie.value()); + } + { let conn = match state.users_db.lock() { Ok(c) => c, @@ -279,6 +312,7 @@ pub async fn login_post( .build(); let mut response_jar = jar.clone(); + response_jar = clear_user_cookie(response_jar); response_jar = response_jar.add(cookie).add(clear_temp); (response_jar, Redirect::to("/admin/dashboard")).into_response() @@ -301,14 +335,17 @@ pub async fn login_post( } // GET /logout -pub async fn public_logout(State(_state): State, jar: CookieJar) -> Response { - let cookie = Cookie::build("bzod_user_session") - .path("/") - .max_age(time::Duration::ZERO) - .build(); +pub async fn public_logout(State(state): State, jar: CookieJar) -> Response { + if let Some(user_cookie) = jar.get("bzod_user_session") { + invalidate_session_in_db(&state, user_cookie.value()); + } + if let Some(admin_cookie) = jar.get("bzod_session") { + invalidate_session_in_db(&state, admin_cookie.value()); + } let mut response_jar = jar.clone(); - response_jar = response_jar.add(cookie); + response_jar = clear_user_cookie(response_jar); + response_jar = clear_admin_cookie(response_jar); (response_jar, Redirect::to("/login")).into_response() } @@ -383,6 +420,14 @@ pub async fn public_login_post( let session_token = generate_token(32); let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); + // Invalidate any existing sessions from the jar + if let Some(old_admin_cookie) = jar.get("bzod_session") { + invalidate_session_in_db(&state, old_admin_cookie.value()); + } + if let Some(old_user_cookie) = jar.get("bzod_user_session") { + invalidate_session_in_db(&state, old_user_cookie.value()); + } + { let conn = state.users_db.lock().unwrap(); let _ = @@ -406,23 +451,51 @@ pub async fn public_login_post( let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers); - let cookie = Cookie::build(("bzod_user_session", session_token)) - .path("/") - .secure(secure_flag) - .http_only(true) - .same_site(axum_extra::extract::cookie::SameSite::Strict) - .max_age(time::Duration::days(30)) - .build(); - let clear_temp = Cookie::build("bzod_temp_csrf") - .path("/login") - .max_age(time::Duration::ZERO) - .build(); + if user.account_type == "admin" { + let cookie = Cookie::build(("bzod_session", session_token)) + .path("/") + .secure(secure_flag) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .max_age(time::Duration::days(30)) + .build(); - let mut response_jar = jar.clone(); - response_jar = response_jar.add(cookie).add(clear_temp); + let clear_temp = Cookie::build("bzod_temp_csrf") + .path("/login") + .max_age(time::Duration::ZERO) + .build(); - (response_jar, Redirect::to("/user/dashboard")).into_response() + let mut response_jar = jar.clone(); + response_jar = clear_user_cookie(response_jar); + response_jar = response_jar.add(cookie).add(clear_temp); + + (response_jar, Redirect::to("/admin/dashboard")).into_response() + } else { + if user.tenant_id.is_none() { + return Redirect::to("/login?error=Invalid tenant configuration") + .into_response(); + } + + let cookie = Cookie::build(("bzod_user_session", session_token)) + .path("/") + .secure(secure_flag) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .max_age(time::Duration::days(30)) + .build(); + + let clear_temp = Cookie::build("bzod_temp_csrf") + .path("/login") + .max_age(time::Duration::ZERO) + .build(); + + let mut response_jar = jar.clone(); + response_jar = clear_admin_cookie(response_jar); + response_jar = response_jar.add(cookie).add(clear_temp); + + (response_jar, Redirect::to("/user/dashboard")).into_response() + } } None => { let system_conn = state.system_db.lock().unwrap(); @@ -446,18 +519,16 @@ pub async fn public_login_post( // GET /admin/logout pub async fn logout(State(state): State, jar: CookieJar) -> Response { - if let Ok((_, session_id)) = require_auth(&state, &jar).await { - let conn = state.users_db.lock().unwrap(); - let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]); + if let Some(admin_cookie) = jar.get("bzod_session") { + invalidate_session_in_db(&state, admin_cookie.value()); + } + if let Some(user_cookie) = jar.get("bzod_user_session") { + invalidate_session_in_db(&state, user_cookie.value()); } - let cookie = Cookie::build("bzod_session") - .path("/") - .max_age(time::Duration::ZERO) - .build(); - let mut response_jar = jar.clone(); - response_jar = response_jar.add(cookie); + response_jar = clear_admin_cookie(response_jar); + response_jar = clear_user_cookie(response_jar); (response_jar, Redirect::to("/admin/login")).into_response() } diff --git a/src/web/admin/mod.rs b/src/web/admin/mod.rs index 85e31d0..31cbd27 100644 --- a/src/web/admin/mod.rs +++ b/src/web/admin/mod.rs @@ -86,32 +86,74 @@ pub(crate) fn write_audit_log( pub(crate) const PAGE_SIZE: usize = 25; pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50; pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000; -// Helper: Verify admin session and return user or redirect to login +// Helper: Verify admin session and return user or error response (403 Forbidden / login redirect) pub(crate) async fn require_auth( state: &AppState, jar: &CookieJar, -) -> Result<(User, String), Redirect> { +) -> Result<(User, String), Response> { let conn = match state.users_db.lock() { Ok(c) => c, - Err(_) => return Err(Redirect::to("/admin/login")), + Err(_) => return Err(Redirect::to("/admin/login").into_response()), }; + match authenticate_admin_session(&conn, jar) { Ok(Some((user, session_id))) => Ok((user, session_id)), - _ => Err(Redirect::to("/admin/login")), + Ok(None) => { + // Check if user is logged in as a normal tenant user trying to access admin route + if let Ok(Some((tenant_user, _))) = authenticate_user_session(&conn, jar) { + if tenant_user.account_type != "admin" { + return Err(( + StatusCode::FORBIDDEN, + "Forbidden: Standard users cannot access Admin routes", + ) + .into_response()); + } + } + Err(Redirect::to("/admin/login").into_response()) + } + Err(_) => Err(Redirect::to("/admin/login").into_response()), } } -// Helper: Verify tenant user session and return user or redirect to login + +// Helper: Verify tenant user session and return tenant user or error response (403 Forbidden / login redirect) pub(crate) async fn require_user_auth( state: &AppState, jar: &CookieJar, -) -> Result<(crate::models::TenantUser, String), Redirect> { +) -> Result<(crate::models::TenantUser, String), Response> { let conn = match state.users_db.lock() { Ok(c) => c, - Err(_) => return Err(Redirect::to("/login")), + Err(_) => return Err(Redirect::to("/login").into_response()), }; + + // If an Admin session is present, Core Admin is trying to access /user/* routes -> Reject with 403 Forbidden + if let Ok(Some((_admin_user, _))) = authenticate_admin_session(&conn, jar) { + return Err(( + StatusCode::FORBIDDEN, + "Forbidden: Core Admin cannot access tenant application routes; use /admin/...", + ) + .into_response()); + } + + // Now check tenant user session match authenticate_user_session(&conn, jar) { - Ok(Some((user, session_id))) => Ok((user, session_id)), - _ => Err(Redirect::to("/login")), + Ok(Some((user, session_id))) => { + if user.account_type == "admin" { + return Err(( + StatusCode::FORBIDDEN, + "Forbidden: Core Admin cannot access tenant application routes; use /admin/...", + ) + .into_response()); + } + if user.tenant_id.is_none() { + return Err(( + StatusCode::FORBIDDEN, + "Forbidden: User has no assigned TenantId", + ) + .into_response()); + } + Ok((user, session_id)) + } + _ => Err(Redirect::to("/login").into_response()), } } #[derive(Deserialize)] diff --git a/src/web/admin/pages.rs b/src/web/admin/pages.rs index 831cbd3..c89ddc5 100644 --- a/src/web/admin/pages.rs +++ b/src/web/admin/pages.rs @@ -118,9 +118,10 @@ pub async fn user_pages_create( } } - let owner_tid = user - .tenant_id - .unwrap_or_else(crate::identity::TenantId::generate); + let owner_tid = match user.tenant_id { + Some(tid) => tid, + None => return (StatusCode::FORBIDDEN, "Missing tenant identity").into_response(), + }; { let reserved_conn = state.db.reserved.lock().unwrap(); @@ -319,10 +320,10 @@ pub async fn pages_get( } } } - if let Some(p) = resolved_page { - pages.push(p); + let page = if let Some(p) = resolved_page { + p } else { - pages.push(crate::models::LandingPage { + crate::models::LandingPage { id: target_id, code: slug.clone(), slug, @@ -331,8 +332,23 @@ pub async fn pages_get( state: status, created_at, updated_at, - }); - } + } + }; + let owner_username = { + let u_conn = state.users_db.lock().unwrap(); + u_conn + .query_row( + "SELECT username FROM users WHERE tenant_id = ?1;", + [&owner_tid_str], + |r| r.get(0), + ) + .ok() + }; + pages.push(crate::templates::pages::AdminPageRow { + page, + owner_tenant_id: owner_tid_str, + owner_username, + }); } let start_page = current_page.saturating_sub(3).max(1); @@ -382,7 +398,11 @@ pub async fn pages_create( Err(redir) => return redir.into_response(), }; - Redirect::to("/admin/pages?error=Admin is a platform operator and cannot create unowned application pages; create landing pages via a tenant user account").into_response() + ( + StatusCode::FORBIDDEN, + "Admin is a platform operator and cannot create unowned application pages; create landing pages via a tenant user account", + ) + .into_response() } // POST /admin/pages/delete/:id diff --git a/src/web/admin/settings.rs b/src/web/admin/settings.rs index cd9513d..6a1c100 100644 --- a/src/web/admin/settings.rs +++ b/src/web/admin/settings.rs @@ -253,9 +253,7 @@ pub async fn user_restore_backup_post( let mut archive = tar::Archive::new(tar_gz); archive.unpack(&temp_unpack_dir)?; - let target_tenant_id = user - .tenant_id - .unwrap_or_else(crate::identity::TenantId::generate); + let target_tenant_id = user.tenant_id.ok_or("User is missing assigned TenantId")?; let temp_content_db = temp_unpack_dir.join("content.db"); if !temp_content_db.exists() { return Err("Backup is missing content.db".into()); diff --git a/src/web/admin/urls.rs b/src/web/admin/urls.rs index 29f0c19..4714d61 100644 --- a/src/web/admin/urls.rs +++ b/src/web/admin/urls.rs @@ -153,9 +153,10 @@ pub async fn user_urls_create( } } - let owner_tid = user - .tenant_id - .unwrap_or_else(crate::identity::TenantId::generate); + let owner_tid = match user.tenant_id { + Some(tid) => tid, + None => return (StatusCode::FORBIDDEN, "Missing tenant identity").into_response(), + }; { let reserved_conn = state.db.reserved.lock().unwrap(); @@ -415,10 +416,10 @@ pub async fn urls_get( } } } - if let Some(u) = resolved_url { - urls.push(u); + let url = if let Some(u) = resolved_url { + u } else { - urls.push(crate::models::Url { + crate::models::Url { id: target_id, code: slug, destination: String::new(), @@ -435,8 +436,23 @@ pub async fn urls_get( expired: false, last_latency_ms: None, last_status: None, - }); - } + } + }; + let owner_username = { + let u_conn = state.users_db.lock().unwrap(); + u_conn + .query_row( + "SELECT username FROM users WHERE tenant_id = ?1;", + [&owner_tid_str], + |r| r.get(0), + ) + .ok() + }; + urls.push(crate::templates::urls::AdminUrlRow { + url, + owner_tenant_id: owner_tid_str, + owner_username, + }); } let start_page = current_page.saturating_sub(3).max(1); @@ -505,7 +521,11 @@ pub async fn urls_create( Err(redir) => return redir.into_response(), }; - Redirect::to("/admin/urls?error=Admin is a platform operator and cannot create unowned application URLs; create links via a tenant user account").into_response() + ( + StatusCode::FORBIDDEN, + "Admin is a platform operator and cannot create unowned application URLs; create links via a tenant user account", + ) + .into_response() } // POST /admin/urls/delete/:id diff --git a/src/web/api.rs b/src/web/api.rs index 89afe05..54aaf65 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -164,7 +164,7 @@ pub async fn api_create_url( let (target_user_id, target_tenant_id, content_db) = match user.0 { crate::models::ApiActor::Admin(_) => { return ( - StatusCode::BAD_REQUEST, + StatusCode::FORBIDDEN, Json(ApiError { error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(), }), @@ -184,9 +184,18 @@ pub async fn api_create_url( .into_response() } }; - let tid = u - .tenant_id - .unwrap_or_else(crate::identity::TenantId::generate); + let tid = match u.tenant_id { + Some(t) => t, + None => { + return ( + StatusCode::FORBIDDEN, + Json(ApiError { + error: "User has no assigned TenantId".to_string(), + }), + ) + .into_response(); + } + }; (u.id, tid, user_dbs.content.clone()) } }; @@ -560,7 +569,7 @@ pub async fn api_create_page( let (target_user_id, target_tenant_id, content_db) = match user.0 { crate::models::ApiActor::Admin(_) => { return ( - StatusCode::BAD_REQUEST, + StatusCode::FORBIDDEN, Json(ApiError { error: "Admin is a platform operator and cannot create application landing pages directly without tenant context".to_string(), }), @@ -580,9 +589,18 @@ pub async fn api_create_page( .into_response() } }; - let tid = u - .tenant_id - .unwrap_or_else(crate::identity::TenantId::generate); + let tid = match u.tenant_id { + Some(t) => t, + None => { + return ( + StatusCode::FORBIDDEN, + Json(ApiError { + error: "User has no assigned TenantId".to_string(), + }), + ) + .into_response(); + } + }; (u.id, tid, user_dbs.content.clone()) } }; diff --git a/src/web/bulk.rs b/src/web/bulk.rs index 194b1e4..a827478 100644 --- a/src/web/bulk.rs +++ b/src/web/bulk.rs @@ -217,7 +217,7 @@ pub async fn api_bulk_url( let (target_user_id, target_tenant_id, content_db) = match user.0 { crate::models::ApiActor::Admin(_) => { return ( - StatusCode::BAD_REQUEST, + StatusCode::FORBIDDEN, Json(BulkErrorResponse { error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(), }), diff --git a/templates/layout.html b/templates/layout.html index e65e806..9e88320 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -1476,13 +1476,13 @@
  • - Short URLs + URL Registry
  • - Landing Pages + Landing Page Registry
  • diff --git a/templates/pages.html b/templates/pages.html index ffee1ec..e24ce41 100644 --- a/templates/pages.html +++ b/templates/pages.html @@ -1,10 +1,10 @@ {% extends "layout.html" %} -{% block title %}Manage Landing Pages - BZOD{% endblock %} +{% block title %}Global Landing Page Registry - BZOD Admin{% endblock %} {% block active_pages %}active{% endblock %} -{% block header_title %}Landing Page Registry{% endblock %} +{% block header_title %}Global Landing Page Registry{% endblock %} {% block content %} {% if let Some(err) = error %} @@ -13,169 +13,134 @@ {% endif %} -
    -
    -

    - - Create a New Landing Page + {% endblock %} diff --git a/templates/urls.html b/templates/urls.html index 0a069f6..16b4095 100644 --- a/templates/urls.html +++ b/templates/urls.html @@ -1,18 +1,10 @@ {% extends "layout.html" %} -{% block title %}Manage Short URLs - BZOD{% endblock %} +{% block title %}Global URL Registry - BZOD Admin{% endblock %} {% block active_urls %}active{% endblock %} -{% block extra_css %} -@media (max-width: 720px) { - #utm_fields > div { - grid-template-columns: 1fr !important; - } -} -{% endblock %} - -{% block header_title %}Short URL Registry{% endblock %} +{% block header_title %}Global URL Registry{% endblock %} {% block content %} {% if let Some(err) = error %} @@ -21,258 +13,177 @@

    {% endif %} -
    -
    -

    - - Shorten a New URL +

    {% endblock %} diff --git a/tests/http_e2e_tests.rs b/tests/http_e2e_tests.rs index a492796..38f5f40 100644 --- a/tests/http_e2e_tests.rs +++ b/tests/http_e2e_tests.rs @@ -202,12 +202,10 @@ async fn test_full_http_e2e_flow() { let res = client.get(&user_dashboard_url).send().await.unwrap(); assert_eq!(res.status(), reqwest::StatusCode::OK); - // Try to access admin dashboard as standard user (RBAC check - should redirect to admin login) + // Try to access admin dashboard as standard user (RBAC check - should return 403 Forbidden) let admin_dashboard_url = format!("{}/admin/dashboard", base_url); let res = client.get(&admin_dashboard_url).send().await.unwrap(); - assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); - let redirect_url = res.headers().get("location").unwrap().to_str().unwrap(); - assert_eq!(redirect_url, "/admin/login"); + assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN); let _ = fs::remove_dir_all(&temp_dir); } diff --git a/tests/v08_admin_capability_boundary_tests.rs b/tests/v08_admin_capability_boundary_tests.rs new file mode 100644 index 0000000..70cb001 --- /dev/null +++ b/tests/v08_admin_capability_boundary_tests.rs @@ -0,0 +1,637 @@ +//! v0.8.0 Phase 5 Correction: Admin Core-Only Capability Boundary Tests +//! +//! Required Test Cases: +//! 1. Admin cannot create URL through POST /admin/urls/create (403 Forbidden). +//! 2. Admin cannot create landing page through POST /admin/pages/create (403 Forbidden). +//! 3. Admin cannot create URL through API (403 Forbidden). +//! 4. Admin cannot create landing page through API (403 Forbidden). +//! 5. Admin cannot bulk-create URLs through API (403 Forbidden). +//! 6. Admin GET /admin/urls renders inspection-only registry (no creation form). +//! 7. Admin GET /admin/pages renders inspection-only registry (no creation form). +//! 8. Admin resource inspection still works on global URL registry. +//! 9. Admin resource inspection still works on global page registry. +//! 10. Admin moderation still works. +//! 11. Admin transfer still works. +//! 12. Normal tenant can still create URLs (via UI and API). +//! 13. Normal tenant can still create landing pages (via UI and API). +//! 14. Normal tenant creation uses its own TenantId. +//! 15. No test or creation path uses users/1 as an application tenant. + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use bzod::analytics::AnalyticsQueue; +use bzod::config::Config; +use bzod::db::topology::Topology; +use bzod::db::Db; +use bzod::state::AppState; +use bzod::web::create_router; +use sha2::{Digest, Sha256}; + +#[allow(dead_code)] +struct TestHarness { + temp_dir: PathBuf, + config: Config, + db: Db, + base_url: String, + admin_client: reqwest::Client, + user_client: reqwest::Client, + admin_api_key: String, + bob_user_id: i64, + bob_tenant_id: bzod::identity::TenantId, + bob_token_secret: String, +} + +impl TestHarness { + async fn setup() -> Self { + let temp_dir = + std::env::temp_dir().join(format!("bzod_admin_boundary_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.join("backups"); + config.base_url = Some("http://localhost:8080".to_string()); + + let db = Db::init(&config).expect("Db::init failed"); + let (queue, _) = AnalyticsQueue::new(db.clone(), 10, tokio::sync::watch::channel(false).1); + let state = AppState { + admin_db: db.admin.clone(), + system_db: db.system.clone(), + users_db: db.users.clone(), + user_dbs: Arc::new(Mutex::new(HashMap::new())), + db: db.clone(), + config: config.clone(), + analytics_queue: queue, + start_time: Instant::now(), + }; + + let router = create_router(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}", addr); + + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + let admin_client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let user_client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + // 1. Create Admin + let _ = bzod::cli::create_admin::run( + Some("core_admin".to_string()), + Some("AdminPass123!".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + + let admin_user = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "core_admin") + .unwrap() + .expect("Admin must exist") + }; + + // 2. Create Normal Tenant + let _ = bzod::cli::create_user::run( + Some("tenant_bob".to_string()), + Some("UserPass123!".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + + let (bob_user_id, bob_tenant_id) = { + let conn = db.users.lock().unwrap(); + let u = bzod::db::users::get_user_by_username(&conn, "tenant_bob") + .unwrap() + .expect("Bob must exist"); + (u.id, u.tenant_id.expect("Bob must have a TenantId")) + }; + + // 3. Admin login + let admin_csrf = extract_csrf(&admin_client, &format!("{}/admin/login", base_url)).await; + let mut admin_login_params = HashMap::new(); + admin_login_params.insert("username", "core_admin"); + admin_login_params.insert("password", "AdminPass123!"); + admin_login_params.insert("csrf_token", admin_csrf.as_str()); + + let admin_login_res = admin_client + .post(format!("{}/admin/login", base_url)) + .form(&admin_login_params) + .send() + .await + .unwrap(); + assert_eq!(admin_login_res.status(), reqwest::StatusCode::SEE_OTHER); + + // 4. User login + let user_csrf = extract_csrf(&user_client, &format!("{}/login", base_url)).await; + let mut user_login_params = HashMap::new(); + user_login_params.insert("username", "tenant_bob"); + user_login_params.insert("password", "UserPass123!"); + user_login_params.insert("csrf_token", user_csrf.as_str()); + + let user_login_res = user_client + .post(format!("{}/login", base_url)) + .form(&user_login_params) + .send() + .await + .unwrap(); + assert_eq!(user_login_res.status(), reqwest::StatusCode::SEE_OTHER); + + // 5. Create API tokens + let admin_key_secret = format!("bzo_{}", bzod::utils::generate_token(16)); + { + let mut hasher = Sha256::new(); + hasher.update(admin_key_secret.as_bytes()); + let hashed_key = hex::encode(hasher.finalize()); + let conn = db.admin.lock().unwrap(); + let _ = bzod::db::admin::create_api_key( + &conn, + &admin_user.id.to_string(), + "Admin Test Key", + &hashed_key, + ) + .unwrap(); + } + + let bob_token_secret = format!("bzou_{}", bzod::utils::generate_token(16)); + { + let mut hasher = Sha256::new(); + hasher.update(bob_token_secret.as_bytes()); + let hashed_token = hex::encode(hasher.finalize()); + let conn = db.users.lock().unwrap(); + let _ = + bzod::db::users::create_user_api_token(&conn, bob_user_id, &hashed_token).unwrap(); + } + + TestHarness { + temp_dir, + config, + db, + base_url, + admin_client, + user_client, + admin_api_key: admin_key_secret, + bob_user_id, + bob_tenant_id, + bob_token_secret, + } + } +} + +impl Drop for TestHarness { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.temp_dir); + } +} + +async fn extract_csrf(client: &reqwest::Client, url: &str) -> String { + let html = client.get(url).send().await.unwrap().text().await.unwrap(); + html.split("name=\"csrf_token\" value=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .unwrap_or_default() + .to_string() +} + +#[tokio::test] +async fn test_01_admin_cannot_create_url_post() { + let h = TestHarness::setup().await; + let csrf = extract_csrf(&h.admin_client, &format!("{}/admin/urls", h.base_url)).await; + let mut form = HashMap::new(); + form.insert("destination", "https://fail.com"); + form.insert("code", "!fail"); + form.insert("csrf_token", csrf.as_str()); + + let res = h + .admin_client + .post(format!("{}/admin/urls/create", h.base_url)) + .form(&form) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin POST /admin/urls/create must return 403" + ); +} + +#[tokio::test] +async fn test_02_admin_cannot_create_landing_page_post() { + let h = TestHarness::setup().await; + let csrf = extract_csrf(&h.admin_client, &format!("{}/admin/pages", h.base_url)).await; + let mut form = HashMap::new(); + form.insert("title", "Admin Page"); + form.insert("slug", "admin-page"); + form.insert("code", "a1b2"); + form.insert("state", "published"); + form.insert("html_content", "

    Admin

    "); + form.insert("csrf_token", csrf.as_str()); + + let res = h + .admin_client + .post(format!("{}/admin/pages/create", h.base_url)) + .form(&form) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin POST /admin/pages/create must return 403" + ); +} + +#[tokio::test] +async fn test_03_admin_cannot_create_url_api() { + let h = TestHarness::setup().await; + let api_client = reqwest::Client::new(); + let res = api_client + .post(format!("{}/api/v1/urls", h.base_url)) + .header("Authorization", format!("Bearer {}", h.admin_api_key)) + .json(&serde_json::json!({ + "destination": "https://admin-api-fail.com", + "code": "!admin_api" + })) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin API POST /api/v1/urls must return 403" + ); +} + +#[tokio::test] +async fn test_04_admin_cannot_create_landing_page_api() { + let h = TestHarness::setup().await; + let api_client = reqwest::Client::new(); + let res = api_client + .post(format!("{}/api/v1/pages", h.base_url)) + .header("Authorization", format!("Bearer {}", h.admin_api_key)) + .json(&serde_json::json!({ + "title": "Admin Page API", + "slug": "admin-page-api", + "html_content": "

    Admin API

    ", + "state": "published" + })) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin API POST /api/v1/pages must return 403" + ); +} + +#[tokio::test] +async fn test_05_admin_cannot_bulk_create_urls_api() { + let h = TestHarness::setup().await; + let api_client = reqwest::Client::new(); + let res = api_client + .post(format!("{}/api/v1/bulk/url", h.base_url)) + .header("Authorization", format!("Bearer {}", h.admin_api_key)) + .json(&serde_json::json!([ + { "destination": "https://bulk1.com", "code": "!b1" }, + { "destination": "https://bulk2.com", "code": "!b2" } + ])) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin API POST /api/v1/bulk/url must return 403" + ); +} + +#[tokio::test] +async fn test_06_admin_urls_registry_renders_no_creation_form() { + let h = TestHarness::setup().await; + let html = h + .admin_client + .get(format!("{}/admin/urls", h.base_url)) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + !html.contains("Shorten a New URL"), + "Admin /admin/urls must NOT contain 'Shorten a New URL'" + ); + assert!( + !html.contains("action=\"/admin/urls/create\""), + "Admin /admin/urls must NOT contain creation form action" + ); + assert!( + html.contains("Global URL Registry"), + "Admin /admin/urls must render Global URL Registry" + ); +} + +#[tokio::test] +async fn test_07_admin_pages_registry_renders_no_creation_form() { + let h = TestHarness::setup().await; + let html = h + .admin_client + .get(format!("{}/admin/pages", h.base_url)) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + !html.contains("Create a New Landing Page"), + "Admin /admin/pages must NOT contain 'Create a New Landing Page'" + ); + assert!( + !html.contains("action=\"/admin/pages/create\""), + "Admin /admin/pages must NOT contain creation form action" + ); + assert!( + html.contains("Global Landing Page Registry"), + "Admin /admin/pages must render Global Landing Page Registry" + ); +} + +#[tokio::test] +async fn test_08_and_09_admin_inspection_of_global_registries() { + let h = TestHarness::setup().await; + let api_client = reqwest::Client::new(); + + // Bob creates URL + let res = api_client + .post(format!("{}/api/v1/urls", h.base_url)) + .header("Authorization", format!("Bearer {}", h.bob_token_secret)) + .json(&serde_json::json!({ + "destination": "https://bob-portfolio.org", + "code": "b0b001" + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::CREATED); + + // Bob creates Landing Page via UI + let bob_pages_csrf = extract_csrf(&h.user_client, &format!("{}/user/pages", h.base_url)).await; + let mut bob_page_form = HashMap::new(); + bob_page_form.insert("title", "Bob Landing Page"); + bob_page_form.insert("slug", "bob-page"); + bob_page_form.insert("code", "a1b2"); + bob_page_form.insert("custom_slug", ""); + bob_page_form.insert("state", "published"); + bob_page_form.insert("html_content", "

    Welcome to Bob's Page

    "); + bob_page_form.insert("csrf_token", bob_pages_csrf.as_str()); + + let res = h + .user_client + .post(format!("{}/user/pages/create", h.base_url)) + .form(&bob_page_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + + // Test 8: Admin can inspect URL registry + let admin_urls_inspected = h + .admin_client + .get(format!("{}/admin/urls", h.base_url)) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + admin_urls_inspected.contains("b0b001"), + "Admin URL registry must display tenant URLs" + ); + assert!( + admin_urls_inspected.contains("tenant_bob"), + "Admin URL registry must display owner username" + ); + assert!( + admin_urls_inspected.contains(h.bob_tenant_id.as_str()), + "Admin URL registry must display owner TenantId" + ); + + // Test 9: Admin can inspect Landing Page registry + let admin_pages_inspected = h + .admin_client + .get(format!("{}/admin/pages", h.base_url)) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + admin_pages_inspected.contains("Bob Landing Page"), + "Admin Page registry must display tenant landing pages" + ); + assert!( + admin_pages_inspected.contains("tenant_bob"), + "Admin Page registry must display owner username" + ); + assert!( + admin_pages_inspected.contains(h.bob_tenant_id.as_str()), + "Admin Page registry must display owner TenantId" + ); +} + +#[tokio::test] +async fn test_10_admin_moderation_functional() { + let h = TestHarness::setup().await; + let api_client = reqwest::Client::new(); + + let _ = api_client + .post(format!("{}/api/v1/urls", h.base_url)) + .header("Authorization", format!("Bearer {}", h.bob_token_secret)) + .json(&serde_json::json!({ + "destination": "https://spam.org", + "code": "!spam-link" + })) + .send() + .await + .unwrap(); + + let urls_conn = h.db.global_urls.lock().unwrap(); + let pages_conn = h.db.global_landing_pages.lock().unwrap(); + let retired = bzod::db::slugs::retire_slug(&urls_conn, &pages_conn, "!spam-link").unwrap(); + assert!(retired, "Admin moderation (retire slug) must succeed"); +} + +#[tokio::test] +async fn test_11_admin_transfer_functional() { + let h = TestHarness::setup().await; + let api_client = reqwest::Client::new(); + + let _ = api_client + .post(format!("{}/api/v1/urls", h.base_url)) + .header("Authorization", format!("Bearer {}", h.bob_token_secret)) + .json(&serde_json::json!({ + "destination": "https://transfer-target.org", + "code": "!transfer-link" + })) + .send() + .await + .unwrap(); + + let _ = bzod::cli::create_user::run( + Some("tenant_alice".to_string()), + Some("AlicePass123!".to_string()), + None, + h.config.clone(), + ) + .await + .unwrap(); + + let alice_tenant_id = { + let conn = h.db.users.lock().unwrap(); + let u = bzod::db::users::get_user_by_username(&conn, "tenant_alice") + .unwrap() + .expect("Alice must exist"); + u.tenant_id.expect("Alice must have a TenantId") + }; + + let urls_conn = h.db.global_urls.lock().unwrap(); + let pages_conn = h.db.global_landing_pages.lock().unwrap(); + let transferred = bzod::db::slugs::transfer_slug_owner( + &urls_conn, + &pages_conn, + "!transfer-link", + &alice_tenant_id, + "new_target_id", + ) + .unwrap(); + assert!(transferred, "Admin slug transfer to Alice must succeed"); + + let lookup = bzod::db::slugs::lookup_slug(&urls_conn, &pages_conn, "!transfer-link") + .unwrap() + .unwrap(); + assert_eq!( + lookup.owner_tenant_id, + alice_tenant_id.as_str(), + "Slug owner must now be Alice's TenantId" + ); +} + +#[tokio::test] +async fn test_12_13_14_15_normal_tenant_creation_and_topology() { + let h = TestHarness::setup().await; + + // Verify User UI contains creation form + let user_urls_html = h + .user_client + .get(format!("{}/user/urls", h.base_url)) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + user_urls_html.contains("Create a New URL"), + "User /user/urls MUST contain creation form" + ); + assert!( + user_urls_html.contains("action=\"/user/urls/create\""), + "User /user/urls MUST post to /user/urls/create" + ); + + // UI URL creation + let bob_urls_csrf = extract_csrf(&h.user_client, &format!("{}/user/urls", h.base_url)).await; + let mut bob_url_form = HashMap::new(); + bob_url_form.insert("destination", "https://bob-portfolio.org"); + bob_url_form.insert("code", "b0b001"); + bob_url_form.insert("title", "Bob's Portfolio"); + bob_url_form.insert("csrf_token", bob_urls_csrf.as_str()); + + let res = h + .user_client + .post(format!("{}/user/urls/create", h.base_url)) + .form(&bob_url_form) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::SEE_OTHER, + "User URL creation must succeed and redirect" + ); + + // UI Landing page creation + let bob_pages_csrf = extract_csrf(&h.user_client, &format!("{}/user/pages", h.base_url)).await; + let mut bob_page_form = HashMap::new(); + bob_page_form.insert("title", "Bob Landing Page"); + bob_page_form.insert("slug", "bob-page"); + bob_page_form.insert("code", "a1b2"); + bob_page_form.insert("custom_slug", ""); + bob_page_form.insert("state", "published"); + bob_page_form.insert("html_content", "

    Welcome to Bob's Page

    "); + bob_page_form.insert("csrf_token", bob_pages_csrf.as_str()); + + let res = h + .user_client + .post(format!("{}/user/pages/create", h.base_url)) + .form(&bob_page_form) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::SEE_OTHER, + "User landing page creation must succeed and redirect" + ); + + // Test 14: Verify content is stored under users//content.db + let topology = Topology::new(&h.temp_dir); + let bob_tenant_dir = topology.user_dir(h.bob_tenant_id.as_str()).unwrap(); + assert!( + bob_tenant_dir.join("content.db").exists(), + "Bob's content.db must exist" + ); + + let bob_conn = rusqlite::Connection::open(bob_tenant_dir.join("content.db")).unwrap(); + let url_count: i64 = bob_conn + .query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + url_count, 1, + "Bob's content.db must hold exactly 1 URL created by Bob" + ); + + let page_count: i64 = bob_conn + .query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + page_count, 1, + "Bob's content.db must hold exactly 1 landing page created by Bob" + ); + + // Test 15: No creation path uses users/1 or integer paths + let legacy_int_dir = h.temp_dir.join("users").join("1"); + assert!(!legacy_int_dir.exists(), "users/1 must NOT exist anywhere"); +} diff --git a/tests/v08_admin_core_separation_tests.rs b/tests/v08_admin_core_separation_tests.rs index 7e3daec..f23573f 100644 --- a/tests/v08_admin_core_separation_tests.rs +++ b/tests/v08_admin_core_separation_tests.rs @@ -158,19 +158,9 @@ async fn test_admin_cannot_create_unowned_application_resources() { .send() .await .unwrap(); - assert_eq!(create_url_res.status(), reqwest::StatusCode::SEE_OTHER); - let location = create_url_res - .headers() - .get("location") - .unwrap() - .to_str() - .unwrap(); - assert!( - location.contains("error="), - "Admin URL creation must be rejected with error redirect" - ); + assert_eq!(create_url_res.status(), reqwest::StatusCode::FORBIDDEN); - // 4. Admin attempts to create Landing Page via POST /admin/pages/create -> Should redirect with error + // 4. Admin attempts to create Landing Page via POST /admin/pages/create -> Should return FORBIDDEN let mut page_form = HashMap::new(); page_form.insert("title", "Admin Page"); page_form.insert("slug", "!admin_page"); @@ -186,17 +176,7 @@ async fn test_admin_cannot_create_unowned_application_resources() { .send() .await .unwrap(); - assert_eq!(create_page_res.status(), reqwest::StatusCode::SEE_OTHER); - let location = create_page_res - .headers() - .get("location") - .unwrap() - .to_str() - .unwrap(); - assert!( - location.contains("error="), - "Admin Landing Page creation must be rejected with error redirect" - ); + assert_eq!(create_page_res.status(), reqwest::StatusCode::FORBIDDEN); let _ = fs::remove_dir_all(&temp_dir); } diff --git a/tests/v08_admin_user_route_boundary_tests.rs b/tests/v08_admin_user_route_boundary_tests.rs new file mode 100644 index 0000000..3efaf74 --- /dev/null +++ b/tests/v08_admin_user_route_boundary_tests.rs @@ -0,0 +1,1156 @@ +//! v0.8.0 Phase 5-Correction-2: Strict Admin/Core vs Normal Tenant Route Boundary Tests +//! +//! Required Test Cases: +//! 1. admin_login_redirects_to_admin_dashboard +//! 2. admin_cannot_access_user_dashboard +//! 3. admin_cannot_access_user_urls +//! 4. admin_cannot_access_user_pages +//! 5. admin_cannot_access_user_settings +//! 6. admin_cannot_access_user_audit +//! 7. admin_cannot_create_url_via_user_route +//! 8. admin_cannot_create_page_via_user_route +//! 9. normal_user_can_access_user_dashboard +//! 10. normal_user_can_access_user_urls +//! 11. normal_user_can_access_user_pages +//! 12. normal_user_can_access_user_settings +//! 13. normal_user_can_create_url +//! 14. normal_user_can_create_page +//! 15. normal_user_cannot_access_admin_routes +//! 16. admin_has_no_tenant_database +//! 17. admin_has_no_tenant_directory +//! 18. no_users_1_tenant_fallback +//! 19. tenant_routes_require_tenant_id + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use bzod::analytics::AnalyticsQueue; +use bzod::config::Config; +use bzod::db::topology::Topology; +use bzod::db::Db; +use bzod::identity::TenantId; +use bzod::state::AppState; +use bzod::web::create_router; + +#[allow(dead_code)] +struct TestHarness { + temp_dir: PathBuf, + config: Config, + db: Db, + base_url: String, + admin_client: reqwest::Client, + user_client: reqwest::Client, + admin_username: String, + admin_user_id: i64, + bob_username: String, + bob_user_id: i64, + bob_tenant_id: TenantId, +} + +impl TestHarness { + async fn setup() -> Self { + let temp_dir = + std::env::temp_dir().join(format!("bzod_user_boundary_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + config.backup_dir = temp_dir.join("backups"); + config.base_url = Some("http://localhost:8080".to_string()); + + let db = Db::init(&config).expect("Db::init failed"); + let (queue, _) = AnalyticsQueue::new(db.clone(), 10, tokio::sync::watch::channel(false).1); + let state = AppState { + admin_db: db.admin.clone(), + system_db: db.system.clone(), + users_db: db.users.clone(), + user_dbs: Arc::new(Mutex::new(HashMap::new())), + db: db.clone(), + config: config.clone(), + analytics_queue: queue, + start_time: Instant::now(), + }; + + let router = create_router(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}", addr); + + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + let admin_client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let user_client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + // 1. Create Admin + let _ = bzod::cli::create_admin::run( + Some("core_admin".to_string()), + Some("AdminPass123!".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + + let admin_user = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "core_admin") + .unwrap() + .expect("Admin must exist") + }; + + // 2. Create Normal Tenant User (Bob) + let _ = bzod::cli::create_user::run( + Some("tenant_bob".to_string()), + Some("UserPass123!".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + + let (bob_user_id, bob_tenant_id) = { + let conn = db.users.lock().unwrap(); + let u = bzod::db::users::get_user_by_username(&conn, "tenant_bob") + .unwrap() + .expect("Bob must exist"); + (u.id, u.tenant_id.expect("Bob must have a TenantId")) + }; + + // 3. Admin login via /admin/login + let admin_csrf = extract_csrf(&admin_client, &format!("{}/admin/login", base_url)).await; + let mut admin_login_params = HashMap::new(); + admin_login_params.insert("username", "core_admin"); + admin_login_params.insert("password", "AdminPass123!"); + admin_login_params.insert("csrf_token", admin_csrf.as_str()); + + let admin_login_res = admin_client + .post(format!("{}/admin/login", base_url)) + .form(&admin_login_params) + .send() + .await + .unwrap(); + assert_eq!(admin_login_res.status(), reqwest::StatusCode::SEE_OTHER); + + // 4. Normal user login via /login + let user_csrf = extract_csrf(&user_client, &format!("{}/login", base_url)).await; + let mut user_login_params = HashMap::new(); + user_login_params.insert("username", "tenant_bob"); + user_login_params.insert("password", "UserPass123!"); + user_login_params.insert("csrf_token", user_csrf.as_str()); + + let user_login_res = user_client + .post(format!("{}/login", base_url)) + .form(&user_login_params) + .send() + .await + .unwrap(); + assert_eq!(user_login_res.status(), reqwest::StatusCode::SEE_OTHER); + + TestHarness { + temp_dir, + config, + db, + base_url, + admin_client, + user_client, + admin_username: "core_admin".to_string(), + admin_user_id: admin_user.id, + bob_username: "tenant_bob".to_string(), + bob_user_id, + bob_tenant_id, + } + } +} + +impl Drop for TestHarness { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.temp_dir); + } +} + +async fn extract_csrf(client: &reqwest::Client, url: &str) -> String { + let html = client.get(url).send().await.unwrap().text().await.unwrap(); + html.split("name=\"csrf_token\" value=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .unwrap_or_default() + .to_string() +} + +// --------------------------------------------------------------------------- +// 1. Admin login always lands on /admin/dashboard (both via /admin/login and /login) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_01_admin_login_redirects_to_admin_dashboard() { + let h = TestHarness::setup().await; + let fresh_client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + // Login via /login as Admin + let csrf = extract_csrf(&fresh_client, &format!("{}/login", h.base_url)).await; + let mut form = HashMap::new(); + form.insert("username", "core_admin"); + form.insert("password", "AdminPass123!"); + form.insert("csrf_token", csrf.as_str()); + + let res = fresh_client + .post(format!("{}/login", h.base_url)) + .form(&form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + let location = res.headers().get("Location").unwrap().to_str().unwrap(); + assert_eq!( + location, "/admin/dashboard", + "Admin logging in via /login MUST redirect to /admin/dashboard" + ); +} + +// --------------------------------------------------------------------------- +// 2. Admin cannot access /user/dashboard (403 Forbidden) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_02_admin_cannot_access_user_dashboard() { + let h = TestHarness::setup().await; + let res = h + .admin_client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin accessing /user/dashboard MUST return 403 Forbidden" + ); +} + +// --------------------------------------------------------------------------- +// 3. Admin cannot access /user/urls (403 Forbidden) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_03_admin_cannot_access_user_urls() { + let h = TestHarness::setup().await; + let res = h + .admin_client + .get(format!("{}/user/urls", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin accessing /user/urls MUST return 403 Forbidden" + ); +} + +// --------------------------------------------------------------------------- +// 4. Admin cannot access /user/pages (403 Forbidden) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_04_admin_cannot_access_user_pages() { + let h = TestHarness::setup().await; + let res = h + .admin_client + .get(format!("{}/user/pages", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin accessing /user/pages MUST return 403 Forbidden" + ); +} + +// --------------------------------------------------------------------------- +// 5. Admin cannot access /user/settings (403 Forbidden) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_05_admin_cannot_access_user_settings() { + let h = TestHarness::setup().await; + let res = h + .admin_client + .get(format!("{}/user/settings", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin accessing /user/settings MUST return 403 Forbidden" + ); +} + +// --------------------------------------------------------------------------- +// 6. Admin cannot access /user/audit (403 Forbidden) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_06_admin_cannot_access_user_audit() { + let h = TestHarness::setup().await; + let res = h + .admin_client + .get(format!("{}/user/audit", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin accessing /user/audit MUST return 403 Forbidden" + ); +} + +// --------------------------------------------------------------------------- +// 7. Admin cannot create URL via user route POST /user/urls/create (403 Forbidden, 0 writes) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_07_admin_cannot_create_url_via_user_route() { + let h = TestHarness::setup().await; + let mut form = HashMap::new(); + form.insert("destination", "https://admin-attack.org"); + form.insert("code", "a1b2c3"); + form.insert("csrf_token", "invalid_or_any"); + + let res = h + .admin_client + .post(format!("{}/user/urls/create", h.base_url)) + .form(&form) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin POST /user/urls/create MUST return 403 Forbidden" + ); + + // Verify 0 writes in global slug registry + let urls_conn = h.db.global_urls.lock().unwrap(); + let count: i64 = urls_conn + .query_row( + "SELECT COUNT(*) FROM global_urls WHERE slug = 'a1b2c3';", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count, 0, + "No global slug record may be created for rejected admin request" + ); +} + +// --------------------------------------------------------------------------- +// 8. Admin cannot create landing page via user route POST /user/pages/create (403 Forbidden, 0 writes) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_08_admin_cannot_create_page_via_user_route() { + let h = TestHarness::setup().await; + let mut form = HashMap::new(); + form.insert("title", "Admin Page"); + form.insert("slug", "admin-page-attack"); + form.insert("code", "c1d2"); + form.insert("custom_slug", ""); + form.insert("state", "published"); + form.insert("html_content", "

    Attack

    "); + form.insert("csrf_token", "invalid_or_any"); + + let res = h + .admin_client + .post(format!("{}/user/pages/create", h.base_url)) + .form(&form) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Admin POST /user/pages/create MUST return 403 Forbidden" + ); + + // Verify 0 writes in global slug registry + let pages_conn = h.db.global_landing_pages.lock().unwrap(); + let count: i64 = pages_conn + .query_row( + "SELECT COUNT(*) FROM global_landing_pages WHERE slug = 'c1d2';", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count, 0, + "No global landing page record may be created for rejected admin request" + ); +} + +// --------------------------------------------------------------------------- +// 9. Normal user can access /user/dashboard (200 OK) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_09_normal_user_can_access_user_dashboard() { + let h = TestHarness::setup().await; + let res = h + .user_client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::OK, + "Normal user MUST be able to access /user/dashboard" + ); +} + +// --------------------------------------------------------------------------- +// 10. Normal user can access /user/urls (200 OK) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_10_normal_user_can_access_user_urls() { + let h = TestHarness::setup().await; + let res = h + .user_client + .get(format!("{}/user/urls", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::OK, + "Normal user MUST be able to access /user/urls" + ); + let html = res.text().await.unwrap(); + assert!( + html.contains("Create a New URL"), + "Normal user /user/urls MUST contain creation form" + ); +} + +// --------------------------------------------------------------------------- +// 11. Normal user can access /user/pages (200 OK) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_11_normal_user_can_access_user_pages() { + let h = TestHarness::setup().await; + let res = h + .user_client + .get(format!("{}/user/pages", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::OK, + "Normal user MUST be able to access /user/pages" + ); + let html = res.text().await.unwrap(); + assert!( + html.contains("Create a New Landing Page"), + "Normal user /user/pages MUST contain creation form" + ); +} + +// --------------------------------------------------------------------------- +// 12. Normal user can access /user/settings (200 OK) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_12_normal_user_can_access_user_settings() { + let h = TestHarness::setup().await; + let res = h + .user_client + .get(format!("{}/user/settings", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::OK, + "Normal user MUST be able to access /user/settings" + ); +} + +// --------------------------------------------------------------------------- +// 13. Normal user can create URL via POST /user/urls/create (303 Redirect) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_13_normal_user_can_create_url() { + let h = TestHarness::setup().await; + let csrf = extract_csrf(&h.user_client, &format!("{}/user/urls", h.base_url)).await; + let mut form = HashMap::new(); + form.insert("destination", "https://bob-portfolio.org"); + form.insert("code", "b0b001"); + form.insert("title", "Bob Portfolio"); + form.insert("csrf_token", csrf.as_str()); + + let res = h + .user_client + .post(format!("{}/user/urls/create", h.base_url)) + .form(&form) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::SEE_OTHER, + "User URL creation must succeed and redirect" + ); + + // Verify written to tenant content.db + let topology = Topology::new(&h.temp_dir); + let bob_dir = topology.user_dir(h.bob_tenant_id.as_str()).unwrap(); + let conn = rusqlite::Connection::open(bob_dir.join("content.db")).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM urls WHERE code = 'b0b001';", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "URL must be stored in Bob's content.db"); + + // Verify written to global_urls.db + let urls_conn = h.db.global_urls.lock().unwrap(); + let owner_tid: String = urls_conn + .query_row( + "SELECT owner_tenant_id FROM global_urls WHERE slug = 'b0b001';", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + owner_tid, + h.bob_tenant_id.as_str(), + "Global slug owner must be Bob's TenantId" + ); +} + +// --------------------------------------------------------------------------- +// 14. Normal user can create landing page via POST /user/pages/create (303 Redirect) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_14_normal_user_can_create_page() { + let h = TestHarness::setup().await; + let csrf = extract_csrf(&h.user_client, &format!("{}/user/pages", h.base_url)).await; + let mut form = HashMap::new(); + form.insert("title", "Bob Landing Page"); + form.insert("slug", "bob-landing"); + form.insert("code", "a1b2"); + form.insert("custom_slug", ""); + form.insert("state", "published"); + form.insert("html_content", "

    Welcome

    "); + form.insert("csrf_token", csrf.as_str()); + + let res = h + .user_client + .post(format!("{}/user/pages/create", h.base_url)) + .form(&form) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::SEE_OTHER, + "User landing page creation must succeed and redirect" + ); + + // Verify written to tenant content.db + let topology = Topology::new(&h.temp_dir); + let bob_dir = topology.user_dir(h.bob_tenant_id.as_str()).unwrap(); + let conn = rusqlite::Connection::open(bob_dir.join("content.db")).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM landing_pages WHERE slug = 'bob-landing';", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "Page must be stored in Bob's content.db"); +} + +// --------------------------------------------------------------------------- +// 15. Normal user cannot access /admin/* routes (403 Forbidden) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_15_normal_user_cannot_access_admin_routes() { + let h = TestHarness::setup().await; + + let admin_paths = vec![ + "/admin/dashboard", + "/admin/urls", + "/admin/pages", + "/admin/users", + "/admin/settings", + "/admin/audit", + "/admin/status", + ]; + + for path in admin_paths { + let res = h + .user_client + .get(format!("{}{}", h.base_url, path)) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::FORBIDDEN, + "Normal user accessing {} MUST be rejected with 403 Forbidden", + path + ); + } +} + +// --------------------------------------------------------------------------- +// 16. Admin has no tenant database +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_16_admin_has_no_tenant_database() { + let h = TestHarness::setup().await; + // Core Admin has no TenantId in users.db + let conn = h.db.users.lock().unwrap(); + let admin_u = bzod::db::users::get_user_by_username(&conn, "core_admin") + .unwrap() + .unwrap(); + assert!( + admin_u.tenant_id.is_none(), + "Core Admin must have tenant_id == None" + ); +} + +// --------------------------------------------------------------------------- +// 17. Admin has no tenant directory +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_17_admin_has_no_tenant_directory() { + let h = TestHarness::setup().await; + let users_dir = h.temp_dir.join("users"); + if users_dir.exists() { + for entry in fs::read_dir(users_dir).unwrap().flatten() { + let file_name = entry.file_name().to_string_lossy().to_string(); + assert_ne!(file_name, "admin", "No tenant directory 'admin' may exist"); + assert_ne!( + file_name, "core_admin", + "No tenant directory 'core_admin' may exist" + ); + } + } +} + +// --------------------------------------------------------------------------- +// 18. No users/1 fallback +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_18_no_users_1_tenant_fallback() { + let h = TestHarness::setup().await; + let legacy_dir = h.temp_dir.join("users").join("1"); + assert!( + !legacy_dir.exists(), + "users/1 directory must NEVER be created or used as fallback" + ); +} + +// --------------------------------------------------------------------------- +// 19. Tenant routes require TenantId (unprovisioned user without TenantId is rejected) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_19_tenant_routes_require_tenant_id() { + let h = TestHarness::setup().await; + + // Insert user with status active, account_type user, but tenant_id NULL + { + let conn = h.db.users.lock().unwrap(); + let hash = bzod::auth::hash_password("Pass123!").unwrap(); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO users (username, password_hash, status, created_at, account_type, tenant_id, uuid) + VALUES ('unprovisioned_user', ?1, 'active', ?2, 'user', NULL, NULL);", + rusqlite::params![hash, now], + ) + .unwrap(); + } + + let unpriv_client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let csrf = extract_csrf(&unpriv_client, &format!("{}/login", h.base_url)).await; + let mut form = HashMap::new(); + form.insert("username", "unprovisioned_user"); + form.insert("password", "Pass123!"); + form.insert("csrf_token", csrf.as_str()); + + let res = unpriv_client + .post(format!("{}/login", h.base_url)) + .form(&form) + .send() + .await + .unwrap(); + + // Login must fail or redirect with error because tenant_id is missing + let location = res + .headers() + .get("Location") + .map(|v| v.to_str().unwrap()) + .unwrap_or_default(); + assert!( + location.contains("error=Invalid+tenant+configuration") || location.contains("error="), + "Unprovisioned user without TenantId must NOT be logged in as a valid tenant" + ); +} + +// --------------------------------------------------------------------------- +// 20. Admin login clears existing user session & cookie +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_20_admin_login_clears_existing_user_session() { + let h = TestHarness::setup().await; + let client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + // 1. Login as user first + let user_csrf = extract_csrf(&client, &format!("{}/login", h.base_url)).await; + let mut user_form = HashMap::new(); + user_form.insert("username", "tenant_bob"); + user_form.insert("password", "UserPass123!"); + user_form.insert("csrf_token", user_csrf.as_str()); + + let res = client + .post(format!("{}/login", h.base_url)) + .form(&user_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + + // Verify user can access dashboard + let res = client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::OK); + + // 2. Now login as Admin on /admin/login + let admin_csrf = extract_csrf(&client, &format!("{}/admin/login", h.base_url)).await; + let mut admin_form = HashMap::new(); + admin_form.insert("username", "core_admin"); + admin_form.insert("password", "AdminPass123!"); + admin_form.insert("csrf_token", admin_csrf.as_str()); + + let res = client + .post(format!("{}/admin/login", h.base_url)) + .form(&admin_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!(res.headers().get("location").unwrap(), "/admin/dashboard"); + + // Verify Admin can access admin dashboard + let res = client + .get(format!("{}/admin/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::OK); + + // Verify Admin is now blocked from user dashboard (403 Forbidden) and has no active user session + let res = client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// 21. User login clears existing admin session & cookie +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_21_user_login_clears_existing_admin_session() { + let h = TestHarness::setup().await; + let client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + // 1. Login as Admin first + let admin_csrf = extract_csrf(&client, &format!("{}/admin/login", h.base_url)).await; + let mut admin_form = HashMap::new(); + admin_form.insert("username", "core_admin"); + admin_form.insert("password", "AdminPass123!"); + admin_form.insert("csrf_token", admin_csrf.as_str()); + + let res = client + .post(format!("{}/admin/login", h.base_url)) + .form(&admin_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + + // Verify admin can access admin dashboard + let res = client + .get(format!("{}/admin/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::OK); + + // 2. Now login as user on /login + let user_csrf = extract_csrf(&client, &format!("{}/login", h.base_url)).await; + let mut user_form = HashMap::new(); + user_form.insert("username", "tenant_bob"); + user_form.insert("password", "UserPass123!"); + user_form.insert("csrf_token", user_csrf.as_str()); + + let res = client + .post(format!("{}/login", h.base_url)) + .form(&user_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!(res.headers().get("location").unwrap(), "/user/dashboard"); + + // Verify user can access user dashboard + let res = client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::OK); + + // Verify user is blocked from admin dashboard (403 Forbidden) and has no active admin session + let res = client + .get(format!("{}/admin/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// 22. Admin logout invalidates admin session in DB and browser +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_22_admin_logout_invalidates_admin_session() { + let h = TestHarness::setup().await; + let client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + // Login as Admin + let admin_csrf = extract_csrf(&client, &format!("{}/admin/login", h.base_url)).await; + let mut admin_form = HashMap::new(); + admin_form.insert("username", "core_admin"); + admin_form.insert("password", "AdminPass123!"); + admin_form.insert("csrf_token", admin_csrf.as_str()); + + let res = client + .post(format!("{}/admin/login", h.base_url)) + .form(&admin_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + + // Logout via /admin/logout + let res = client + .get(format!("{}/admin/logout", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!(res.headers().get("location").unwrap(), "/admin/login"); + + // Revisit /admin/dashboard -> Must redirect to /admin/login + let res = client + .get(format!("{}/admin/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!(res.headers().get("location").unwrap(), "/admin/login"); +} + +// --------------------------------------------------------------------------- +// 23. User logout invalidates user session in DB and browser +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_23_user_logout_invalidates_user_session() { + let h = TestHarness::setup().await; + let client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + // Login as user + let user_csrf = extract_csrf(&client, &format!("{}/login", h.base_url)).await; + let mut user_form = HashMap::new(); + user_form.insert("username", "tenant_bob"); + user_form.insert("password", "UserPass123!"); + user_form.insert("csrf_token", user_csrf.as_str()); + + let res = client + .post(format!("{}/login", h.base_url)) + .form(&user_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + + // Logout via /logout + let res = client + .get(format!("{}/logout", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!(res.headers().get("location").unwrap(), "/login"); + + // Revisit /user/dashboard -> Must redirect to /login + let res = client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!(res.headers().get("location").unwrap(), "/login"); +} + +// --------------------------------------------------------------------------- +// 24. Fresh browser requires admin credentials +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_24_fresh_browser_requires_admin_credentials() { + let h = TestHarness::setup().await; + let fresh_client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let res = fresh_client + .get(format!("{}/admin/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!(res.headers().get("location").unwrap(), "/admin/login"); +} + +// --------------------------------------------------------------------------- +// 25. Fresh browser requires user credentials +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_25_fresh_browser_requires_user_credentials() { + let h = TestHarness::setup().await; + let fresh_client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let res = fresh_client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!(res.headers().get("location").unwrap(), "/login"); +} + +// --------------------------------------------------------------------------- +// 26. Admin to User login switch +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_26_admin_to_user_login_switch() { + let h = TestHarness::setup().await; + let client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + // 1. Admin login + let admin_csrf = extract_csrf(&client, &format!("{}/admin/login", h.base_url)).await; + let mut admin_form = HashMap::new(); + admin_form.insert("username", "core_admin"); + admin_form.insert("password", "AdminPass123!"); + admin_form.insert("csrf_token", admin_csrf.as_str()); + + let res = client + .post(format!("{}/admin/login", h.base_url)) + .form(&admin_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + + // Verify /admin/dashboard accessible + let res = client + .get(format!("{}/admin/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::OK); + + // Verify /user/dashboard forbidden + let res = client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN); + + // 2. Switch to User by logging in at /login + let user_csrf = extract_csrf(&client, &format!("{}/login", h.base_url)).await; + let mut user_form = HashMap::new(); + user_form.insert("username", "tenant_bob"); + user_form.insert("password", "UserPass123!"); + user_form.insert("csrf_token", user_csrf.as_str()); + + let res = client + .post(format!("{}/login", h.base_url)) + .form(&user_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!(res.headers().get("location").unwrap(), "/user/dashboard"); + + // Verify /user/dashboard now accessible (200 OK) + let res = client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::OK); + + // Verify User can create URL + let url_csrf = extract_csrf(&client, &format!("{}/user/urls", h.base_url)).await; + let mut url_form = HashMap::new(); + url_form.insert("destination", "https://bob-switch.org"); + url_form.insert("code", "b0bsw1"); + url_form.insert("title", "Bob Switch"); + url_form.insert("csrf_token", url_csrf.as_str()); + + let res = client + .post(format!("{}/user/urls/create", h.base_url)) + .form(&url_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + + // Verify /admin/dashboard is now forbidden (403) + let res = client + .get(format!("{}/admin/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// 27. User to Admin login switch +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_27_user_to_admin_login_switch() { + let h = TestHarness::setup().await; + let client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + // 1. User login + let user_csrf = extract_csrf(&client, &format!("{}/login", h.base_url)).await; + let mut user_form = HashMap::new(); + user_form.insert("username", "tenant_bob"); + user_form.insert("password", "UserPass123!"); + user_form.insert("csrf_token", user_csrf.as_str()); + + let res = client + .post(format!("{}/login", h.base_url)) + .form(&user_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + + // Verify /user/dashboard accessible + let res = client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::OK); + + // Verify /admin/dashboard forbidden + let res = client + .get(format!("{}/admin/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN); + + // 2. Switch to Admin by logging in at /admin/login + let admin_csrf = extract_csrf(&client, &format!("{}/admin/login", h.base_url)).await; + let mut admin_form = HashMap::new(); + admin_form.insert("username", "core_admin"); + admin_form.insert("password", "AdminPass123!"); + admin_form.insert("csrf_token", admin_csrf.as_str()); + + let res = client + .post(format!("{}/admin/login", h.base_url)) + .form(&admin_form) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); + assert_eq!(res.headers().get("location").unwrap(), "/admin/dashboard"); + + // Verify /admin/dashboard now accessible (200 OK) + let res = client + .get(format!("{}/admin/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::OK); + + // Verify /user/dashboard is now forbidden (403) + let res = client + .get(format!("{}/user/dashboard", h.base_url)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN); +} diff --git a/www/index.html b/www/index.html index 8433a67..594c51f 100644 --- a/www/index.html +++ b/www/index.html @@ -297,7 +297,7 @@ footer{background:var(--bg);border-top:1px solid var(--border);padding:4rem 2rem Cloning into 'bzod'... $ cargo build --release - Compiling bzod v0.5.0 + Compiling bzod v0.8.0 Finished release [optimized] in 12.58s $ ./target/release/bzod serve @@ -537,7 +537,7 @@ Authorization: Bearer bzo_xxxxxxxxxxxx Feature - BZOD v0.5.0 + BZOD v0.8.0 Shlink YOURLS Chhoto URL