Mercury/docs/superpowers/plans/2026-08-09-disable-auth-env-var.md
Matthew L McPeak 2d95265e7d
Some checks failed
ci / build-ui (pull_request) Successful in 21s
ci / test (pull_request) Failing after 34s
ci / publish (pull_request) Has been skipped
fix: cleanup
2026-08-09 14:34:28 -04:00

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_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:

    #[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 (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<Claims> (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:

    #[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_bypass and rewire the require_* functions and blacklist_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 (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/config200 { "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 (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:

      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 /login is required again (existing admin/admin-style credentials per README, or MERCURY_ADMIN_USER/MERCURY_ADMIN_PASSWORD from compose).