# DISABLE_AUTH Env Var Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a `DISABLE_AUTH` environment variable that, when set, makes the Mercury API treat every request as an authenticated super-admin (no token required) and makes the Vue admin UI skip the login screen entirely. **Architecture:** A new `Config.disable_auth` bool gates a single new helper, `authenticate_or_bypass`, that all auth-checking middleware (`require_auth`, `require_super_admin`, `require_admin_query`, `require_admin_cache`, `blacklist_layer`) call instead of manually extracting a bearer token. When bypassing, it injects a synthetic super-admin `Claims`. A new public `GET /auth/config` endpoint exposes the flag so the frontend's Pinia auth store can short-circuit `isAuthenticated`/`isSuperAdmin` and the router guard never redirects to `/login`. **Tech Stack:** Rust/Axum/SQLx backend, Vue 3 + Pinia + vue-router frontend. ## Global Constraints - Default `DISABLE_AUTH` to off/false — the app must behave exactly as today when it's unset. - Truthy values are `1` and `true` (case-insensitive); anything else (including unset) is false. - The bypass is all-or-nothing: no partial/route-scoped bypass. - Follow existing project test conventions: this codebase only unit-tests pure functions (no DB-backed router/handler tests exist anywhere in `src/`) — do not introduce a new DB-mocking pattern; where a change touches a real `PgPool`-holding handler with no pure logic to extract, verify manually instead of adding a test. - No commits during this implementation — leave changes in the working tree. --- ### Task 1: `Config.disable_auth` **Files:** - Modify: `src/config.rs` (struct fields ~lines 3-17, `from_env` body ~lines 20-43, `tests` mod ~line 50+) **Interfaces:** - Produces: `Config.disable_auth: bool` — read by Task 2's middleware and Task 3's `/auth/config` handler. - [ ] **Step 1: Write the failing tests** Add to the `tests` mod at the bottom of `src/config.rs`: ```rust #[test] fn test_config_disable_auth_default_false() { std::env::set_var("DATABASE_URL", "postgres://test"); std::env::set_var("JWT_SECRET", "secret"); std::env::remove_var("DISABLE_AUTH"); let cfg = Config::from_env().unwrap(); assert!(!cfg.disable_auth); } #[test] fn test_config_disable_auth_true() { std::env::set_var("DATABASE_URL", "postgres://test"); std::env::set_var("JWT_SECRET", "secret"); std::env::set_var("DISABLE_AUTH", "true"); let cfg = Config::from_env().unwrap(); assert!(cfg.disable_auth); std::env::remove_var("DISABLE_AUTH"); } #[test] fn test_config_disable_auth_numeric_true() { std::env::set_var("DATABASE_URL", "postgres://test"); std::env::set_var("JWT_SECRET", "secret"); std::env::set_var("DISABLE_AUTH", "1"); let cfg = Config::from_env().unwrap(); assert!(cfg.disable_auth); std::env::remove_var("DISABLE_AUTH"); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cargo test --lib config::tests` Expected: FAIL — `no field \`disable_auth\` on type \`Config\`` (compile error) - [ ] **Step 3: Implement the field and parsing** In `src/config.rs`, add the field to the struct (after `cdn_bucket`): ```rust /// Bucket name used for CDN object storage. pub cdn_bucket: String, /// Dev-only: when true, all auth/permission checks are bypassed and every /// request is treated as a super-admin. NEVER enable in production. pub disable_auth: bool, } ``` And in `from_env()`, add after the `cdn_bucket` line: ```rust cdn_bucket: std::env::var("CDN_BUCKET").unwrap_or_else(|_| "mercury".into()), disable_auth: std::env::var("DISABLE_AUTH") .map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true")) .unwrap_or(false), }) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cargo test --lib config::tests` Expected: PASS (5 tests: the existing `test_config_defaults` plus the 3 new ones) --- ### Task 2: Middleware bypass **Files:** - Modify: `src/auth/mod.rs` (add `bypass_claims`, ~after `permissions` mod, before `Claims` struct) - Modify: `src/auth/middleware.rs` (add `authenticate_or_bypass`; rewrite `require_auth`, `require_super_admin`, `require_admin_query`, `require_admin_cache`, `blacklist_layer`) **Interfaces:** - Consumes: `Config.disable_auth: bool` (Task 1), `AppState { pool, query_cache, blacklist_cache, cors_cache, http_client, cdn_base_url, config }` (`src/state.rs`). - Produces: `pub fn bypass_claims() -> Claims` (`src/auth/mod.rs`), `async fn authenticate_or_bypass(state: &AppState, req: &mut Request) -> Option` (`src/auth/middleware.rs`, crate-private) — used by all `require_*` functions and `blacklist_layer`. - [ ] **Step 1: Write the failing test for `bypass_claims`** Add to the `tests` mod at the bottom of `src/auth/mod.rs`: ```rust #[test] fn test_bypass_claims_has_full_permissions() { let claims = bypass_claims(); assert_eq!(claims.sub, "dev-bypass"); assert_eq!(claims.permissions_mask(), u128::MAX); assert!(claims.has_permission(permissions::SUPER_ADMIN)); assert!(claims.has_permission(permissions::ADMIN_QUERY)); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test --lib auth::tests::test_bypass_claims_has_full_permissions` Expected: FAIL — `cannot find function \`bypass_claims\`` (compile error) - [ ] **Step 3: Implement `bypass_claims`** In `src/auth/mod.rs`, add after the `permissions` mod block (before `Claims` struct): ```rust /// Synthetic super-admin claims used when `DISABLE_AUTH` is set. Grants every /// permission bit so all `require_*` and blacklist bypass checks pass. pub fn bypass_claims() -> Claims { Claims { sub: "dev-bypass".to_string(), permissions: u128::MAX.to_string(), exp: usize::MAX, } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cargo test --lib auth::tests::test_bypass_claims_has_full_permissions` Expected: PASS - [ ] **Step 5: Write the failing tests for `authenticate_or_bypass`** Add a `tests` mod at the bottom of `src/auth/middleware.rs`: ```rust #[cfg(test)] mod tests { use super::*; use crate::state::{AppState, BlacklistCache, CorsCache, QueryCache}; use axum::body::Body; use sqlx::postgres::PgPoolOptions; use std::sync::Arc; fn test_state(disable_auth: bool) -> AppState { // connect_lazy performs no I/O — safe to use without a running DB. let pool = PgPoolOptions::new() .connect_lazy("postgres://user:pass@localhost/db") .expect("lazy pool"); AppState { pool, query_cache: QueryCache::new(), blacklist_cache: BlacklistCache::new(), cors_cache: CorsCache::new(), http_client: reqwest::Client::new(), cdn_base_url: "http://localhost:9000/mercury".into(), config: Arc::new(crate::config::Config { database_url: "postgres://user:pass@localhost/db".into(), jwt_secret: "secret".into(), jwt_expiry_secs: 3600, cache_max_capacity: 10000, cache_idle_timeout_secs: 300, cache_sweep_interval_secs: 60, cors_origins: vec![], cdn_endpoint: "http://localhost:9000".into(), cdn_bucket: "mercury".into(), disable_auth, }), } } fn test_request() -> Request { Request::builder() .uri("/api/orders") .body(Body::empty()) .unwrap() } #[tokio::test] async fn test_bypass_when_disabled_and_no_token() { let state = test_state(true); let mut req = test_request(); let claims = authenticate_or_bypass(&state, &mut req).await; assert_eq!(claims.map(|c| c.permissions_mask()), Some(u128::MAX)); } #[tokio::test] async fn test_no_bypass_when_enabled_and_no_token() { let state = test_state(false); let mut req = test_request(); let claims = authenticate_or_bypass(&state, &mut req).await; assert!(claims.is_none()); } } ``` - [ ] **Step 6: Run tests to verify they fail** Run: `cargo test --lib auth::middleware::tests` Expected: FAIL — `cannot find function \`authenticate_or_bypass\`` (compile error) - [ ] **Step 7: Implement `authenticate_or_bypass` and rewire the `require_*` functions and `blacklist_layer`** In `src/auth/middleware.rs`, add this function directly after the existing `authenticate` function: ```rust /// Like `authenticate`, but short-circuits to a synthetic super-admin when /// `DISABLE_AUTH` is set — no `Authorization` header required in that case. async fn authenticate_or_bypass(state: &AppState, req: &mut Request) -> Option { if state.config.disable_auth { let claims = crate::auth::bypass_claims(); req.extensions_mut().insert(claims.clone()); return Some(claims); } let token = extract_bearer(req)?; authenticate(&token, state, req).await } ``` Replace the body of `blacklist_layer`'s `caller_mask` computation: ```rust let caller_mask = if let Some(token) = extract_bearer(&req) { authenticate(&token, &state, &mut req) .await .map(|c| c.permissions_mask()) .unwrap_or(0) } else { 0 }; ``` with: ```rust let caller_mask = authenticate_or_bypass(&state, &mut req) .await .map(|c| c.permissions_mask()) .unwrap_or(0); ``` Replace `require_auth`'s body: ```rust pub async fn require_auth( State(state): State, mut req: Request, next: Next, ) -> Result { let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; authenticate(&token, &state, &mut req) .await .ok_or(StatusCode::UNAUTHORIZED)?; Ok(next.run(req).await) } ``` with: ```rust pub async fn require_auth( State(state): State, mut req: Request, next: Next, ) -> Result { authenticate_or_bypass(&state, &mut req) .await .ok_or(StatusCode::UNAUTHORIZED)?; Ok(next.run(req).await) } ``` For each of `require_super_admin`, `require_admin_query`, `require_admin_cache`, replace: ```rust let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; let claims = authenticate(&token, &state, &mut req) .await .ok_or(StatusCode::UNAUTHORIZED)?; ``` with: ```rust let claims = authenticate_or_bypass(&state, &mut req) .await .ok_or(StatusCode::UNAUTHORIZED)?; ``` (leave the rest of each function — the `has_permission` check and `next.run(req).await` — unchanged). - [ ] **Step 8: Run tests to verify they pass** Run: `cargo test --lib auth::` Expected: PASS (all `auth::mod` and `auth::middleware` tests, including the 2 new ones) - [ ] **Step 9: Run the full test suite to check for regressions** Run: `cargo test --lib` Expected: PASS — all existing tests (`state::tests`, `routes::crud::tests`, `routes::admin::*::tests`) still pass unchanged. --- ### Task 3: `GET /auth/config` endpoint **Files:** - Modify: `src/routes/auth.rs` (add `auth_config` handler) - Modify: `src/main.rs` (import + route registration, ~line 25 imports, ~line 149 router) **Interfaces:** - Consumes: `Config.disable_auth: bool` (Task 1). - Produces: `GET /auth/config` → `200 { "disable_auth": bool }`, unauthenticated — consumed by Task 4's frontend auth store. - [ ] **Step 1: Implement the handler** In `src/routes/auth.rs`, add after `login`: ```rust pub async fn auth_config(State(state): State) -> Json { Json(json!({ "disable_auth": state.config.disable_auth })) } ``` (No new imports needed — `State`, `Json`, `json`, `Value`, and `AppState` are already imported in this file.) - [ ] **Step 2: Wire the route in `src/main.rs`** Change the import: ```rust routes::{ admin::admin_router, auth::login, ``` to: ```rust routes::{ admin::admin_router, auth::{auth_config, login}, ``` Change the router construction: ```rust let app = Router::new() .route("/auth/login", post(login)) ``` to: ```rust let app = Router::new() .route("/auth/login", post(login)) .route("/auth/config", get(auth_config)) ``` - [ ] **Step 3: Verify it builds** Run: `cargo build` Expected: builds with no errors. - [ ] **Step 4: Manual verification (no DB-backed router tests exist in this codebase — see Global Constraints)** Run: `docker compose up --build -d db cdn cdn-init api` (or `./dev.sh` for the full stack), then: ```bash curl -s http://localhost:3000/auth/config ``` Expected with `DISABLE_AUTH` unset: `{"disable_auth":false}` Then set `DISABLE_AUTH=true` in the environment (or `.env`), restart the `api` service, and re-run the curl — expected: `{"disable_auth":true}`. Also verify `curl -s http://localhost:3000/api/orders` (or any CRUD table) now returns `200` with no `Authorization` header, instead of `401`. --- ### Task 4: Frontend — skip login when auth is disabled **Files:** - Modify: `ui/src/stores/auth.ts` (full content shown below) - Modify: `ui/src/router/index.ts` (full content shown below) **Interfaces:** - Consumes: `GET /auth/config` (Task 3). - Produces: `useAuthStore().checkAuthDisabled(): Promise`, `useAuthStore().authDisabled: Ref` — consumed by the router guard. - [ ] **Step 1: Update the auth store** Replace the full contents of `ui/src/stores/auth.ts` with: ```ts import { defineStore } from "pinia"; import { ref, computed } from "vue"; interface Claims { sub: string; permissions: string; exp: number; } function parseJwt(token: string): Claims | null { try { const payload = token.split(".")[1]; return payload ? (JSON.parse(atob(payload)) as Claims) : null; } catch { return null; } } export const useAuthStore = defineStore("auth", () => { const token = ref(localStorage.getItem("mercury_token")); const claims = computed(() => token.value ? parseJwt(token.value) : null, ); // DISABLE_AUTH dev mode: fetched once from the backend at boot. const authDisabled = ref(false); let configCheckPromise: Promise | null = null; function checkAuthDisabled(): Promise { if (!configCheckPromise) { configCheckPromise = fetch("/auth/config") .then((res) => (res.ok ? res.json() : { disable_auth: false })) .then((data) => { authDisabled.value = Boolean(data.disable_auth); }) .catch(() => { authDisabled.value = false; }); } return configCheckPromise; } const isAuthenticated = computed(() => { if (authDisabled.value) return true; if (!claims.value) return false; return claims.value.exp * 1000 > Date.now(); }); const username = computed(() => authDisabled.value ? "dev-bypass" : (claims.value?.sub ?? ""), ); function hasPermission(bit: bigint): boolean { if (authDisabled.value) return true; if (!claims.value) return false; const mask = BigInt(claims.value.permissions); return (mask & bit) !== 0n; } const isSuperAdmin = computed(() => authDisabled.value || hasPermission(32n)); async function login(username: string, password: string): Promise { const res = await fetch("/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (!res.ok) throw new Error("Invalid credentials"); const data = await res.json(); token.value = data.token; localStorage.setItem("mercury_token", data.token); } function logout() { token.value = null; localStorage.removeItem("mercury_token"); } function authHeaders(): Record { return token.value ? { Authorization: `Bearer ${token.value}` } : {}; } return { token, claims, authDisabled, checkAuthDisabled, isAuthenticated, username, isSuperAdmin, hasPermission, login, logout, authHeaders, }; }); ``` - [ ] **Step 2: Update the router guard** Replace the full contents of `ui/src/router/index.ts` with: ```ts import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '../stores/auth' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', component: () => import('../views/Login.vue') }, { path: '/admin', component: () => import('../views/admin/Layout.vue'), children: [ { path: 'queries', component: () => import('../views/admin/Queries.vue') }, { path: 'tables', component: () => import('../views/admin/Tables.vue') }, { path: 'users', component: () => import('../views/admin/Users.vue') }, { path: 'permissions', component: () => import('../views/admin/Permissions.vue') }, { path: 'blacklist', component: () => import('../views/admin/Blacklist.vue') }, { path: 'api-keys', component: () => import('../views/admin/ApiKeys.vue') }, { path: 'cache', component: () => import('../views/admin/Cache.vue') }, { path: 'cors', component: () => import('../views/admin/Cors.vue') }, { path: 'cdn', component: () => import('../views/admin/Cdn.vue') }, ], meta: { requiresAuth: true }, }, { path: '/', redirect: '/admin/queries' }, ], }) router.beforeEach(async (to) => { const auth = useAuthStore() await auth.checkAuthDisabled() if (to.meta.requiresAuth && !auth.isAuthenticated) { return '/login' } if (to.path === '/login' && auth.isAuthenticated) { return '/admin/queries' } }) export default router ``` - [ ] **Step 3: Verify the FE builds** Run: `cd ui && npm run build` Expected: builds with no TypeScript errors. - [ ] **Step 4: Manual browser verification (no FE test framework configured in this project)** With `DISABLE_AUTH` unset, run `./dev.sh`, open `http://localhost:3000` — expect the normal `/login` redirect. With `DISABLE_AUTH=true` set (e.g. `DISABLE_AUTH=true docker compose up --build`), open `http://localhost:3000` — expect immediate landing on `/admin/queries` with no login prompt, and that every admin nav section (Users, Permissions, Blacklist, API Keys, Cache, CORS, CDN) loads data without a 401. --- ### Task 5: Docs and compose plumbing **Files:** - Modify: `.env.example` - Modify: `docker-compose.yml` (`api` service `environment` block, ~line 40) - Modify: `README.md` (Configuration table, ~line 146-153) **Interfaces:** - Consumes: nothing (documentation-only). - Produces: nothing consumed by other tasks — this is the last task. - [ ] **Step 1: Update `.env.example`** Add after `CORS_ORIGINS=`: ``` # Comma-separated allowed CORS origins, or * for permissive. Empty = no CORS headers. CORS_ORIGINS= # DEV ONLY: set to "true" to bypass all authentication/permission checks # (every request is treated as a super-admin, no login required). Never set # this in production. DISABLE_AUTH= ``` - [ ] **Step 2: Update `docker-compose.yml`** In the `api` service `environment` block, add after `CDN_BUCKET: mercury`: ```yaml CDN_BUCKET: mercury DISABLE_AUTH: ${DISABLE_AUTH:-false} ``` - [ ] **Step 3: Update `README.md`** In the Configuration table, add a row after `CACHE_SWEEP_INTERVAL_SECS`: ```markdown | `CACHE_SWEEP_INTERVAL_SECS` | 60 | Sweep interval for eviction task | | `DISABLE_AUTH` | `false` | **Dev only.** When `true`, bypasses all authentication/permission checks — every request is treated as a super-admin and the admin UI skips login. Never enable in production. | ``` - [ ] **Step 4: Verify docs render sanely** Run: `git diff --stat .env.example docker-compose.yml README.md` Expected: shows the 3 files with small additive diffs, no unrelated changes. --- ## Final Verification - [ ] `cargo test --lib` — all tests pass. - [ ] `cargo build` — builds clean. - [ ] `cd ui && npm run build` — builds clean. - [ ] Manual: `DISABLE_AUTH=true docker compose up --build`, confirm UI skips login and all admin sections load; then unset it and confirm `/login` is required again (existing `admin`/`admin`-style credentials per README, or `MERCURY_ADMIN_USER`/`MERCURY_ADMIN_PASSWORD` from compose).