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

620 lines
21 KiB
Markdown

# 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<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`:
```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<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:
```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<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:
```rust
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:
```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<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:
```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<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:
```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<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:
```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).