21 KiB
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_AUTHto off/false — the app must behave exactly as today when it's unset. - Truthy values are
1andtrue(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 realPgPool-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_envbody ~lines 20-43,testsmod ~line 50+)
Interfaces:
-
Produces:
Config.disable_auth: bool— read by Task 2's middleware and Task 3's/auth/confighandler. -
Step 1: Write the failing tests
Add to the tests mod at the bottom of src/config.rs:
#[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):
/// 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:
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(addbypass_claims, ~afterpermissionsmod, beforeClaimsstruct) - Modify:
src/auth/middleware.rs(addauthenticate_or_bypass; rewriterequire_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<Claims>(src/auth/middleware.rs, crate-private) — used by allrequire_*functions andblacklist_layer. -
Step 1: Write the failing test for
bypass_claims
Add to the tests mod at the bottom of src/auth/mod.rs:
#[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):
/// 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:
#[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_bypassand rewire therequire_*functions andblacklist_layer
In src/auth/middleware.rs, add this function directly after the existing authenticate function:
/// 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<Claims> {
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:
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:
let caller_mask = authenticate_or_bypass(&state, &mut req)
.await
.map(|c| c.permissions_mask())
.unwrap_or(0);
Replace require_auth's body:
pub async fn require_auth(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
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:
pub async fn require_auth(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
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:
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
let claims = authenticate(&token, &state, &mut req)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
with:
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(addauth_confighandler) - 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:
pub async fn auth_config(State(state): State<AppState>) -> Json<Value> {
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:
routes::{
admin::admin_router,
auth::login,
to:
routes::{
admin::admin_router,
auth::{auth_config, login},
Change the router construction:
let app = Router::new()
.route("/auth/login", post(login))
to:
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:
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<void>,useAuthStore().authDisabled: Ref<boolean>— consumed by the router guard. -
Step 1: Update the auth store
Replace the full contents of ui/src/stores/auth.ts with:
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<string | null>(localStorage.getItem("mercury_token"));
const claims = computed<Claims | null>(() =>
token.value ? parseJwt(token.value) : null,
);
// DISABLE_AUTH dev mode: fetched once from the backend at boot.
const authDisabled = ref(false);
let configCheckPromise: Promise<void> | null = null;
function checkAuthDisabled(): Promise<void> {
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<void> {
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<string, string> {
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:
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(apiserviceenvironmentblock, ~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:
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:
| `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/loginis required again (existingadmin/admin-style credentials per README, orMERCURY_ADMIN_USER/MERCURY_ADMIN_PASSWORDfrom compose).