199 lines
7 KiB
Markdown
199 lines
7 KiB
Markdown
# Mercury — Dynamic CORS Design Spec
|
|
**Date:** 2026-06-18
|
|
|
|
## Overview
|
|
|
|
Replace the static startup-time `CorsLayer` with a DB-backed, in-memory CORS cache that can be edited at runtime through the standard CRUD API, mirroring the existing route blacklist pattern. Add a Vue admin view for managing origins and add loading state indicators across all admin views.
|
|
|
|
---
|
|
|
|
## Motivation
|
|
|
|
CORS origins are currently loaded from the `CORS_ORIGINS` environment variable at server startup and baked into a static `tower_http::CorsLayer`. Changing origins requires a server restart. This feature makes CORS origins a first-class runtime configuration: stored in PostgreSQL, held in an in-memory cache, and editable by super-admins through the standard CRUD API without downtime.
|
|
|
|
---
|
|
|
|
## Data Layer
|
|
|
|
### Migration `007_cors_origins.sql`
|
|
|
|
Create a `cors_origins` table:
|
|
|
|
```sql
|
|
CREATE TABLE cors_origins (
|
|
id SERIAL PRIMARY KEY,
|
|
origin TEXT NOT NULL UNIQUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
```
|
|
|
|
Seed blacklist entries to restrict CRUD access to super-admins only (SUPER_ADMIN bit = 32), matching the existing pattern for `blacklist`, `queries`, `users`, and `permissions`:
|
|
|
|
```sql
|
|
INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES
|
|
('/api/cors_origins', NULL, 'admin-only table', true, '32'),
|
|
('/api/cors_origins/**', NULL, 'admin-only table', true, '32');
|
|
```
|
|
|
|
### Startup Seeding
|
|
|
|
On server startup, if the `cors_origins` table is empty and the `CORS_ORIGINS` environment variable is set, seed the table from the env var (splitting on `,`, trimming whitespace). This provides a smooth migration path from the env-var approach. Once the DB has entries, `CORS_ORIGINS` is ignored.
|
|
|
|
---
|
|
|
|
## In-Memory Cache
|
|
|
|
### `CorsCache` in `state.rs`
|
|
|
|
```rust
|
|
pub struct CorsCache {
|
|
pub inner: Arc<RwLock<CorsState>>,
|
|
}
|
|
|
|
pub struct CorsState {
|
|
pub wildcard: bool,
|
|
pub origins: Vec<HeaderValue>,
|
|
}
|
|
```
|
|
|
|
`CorsCache::load(origins: Vec<String>)`:
|
|
- Sets `wildcard = true` if any origin is `"*"`
|
|
- Parses each string into a `HeaderValue`, skipping malformed entries
|
|
- Acquires a write lock and replaces the inner state
|
|
|
|
`AppState` gains a `cors_cache: CorsCache` field alongside `blacklist_cache`.
|
|
|
|
---
|
|
|
|
## Middleware
|
|
|
|
### `cors_layer` in `auth/middleware.rs`
|
|
|
|
An async Axum middleware (`from_fn_with_state`) that runs on every request. Behavior:
|
|
|
|
| Cache state | Request type | Action |
|
|
|---|---|---|
|
|
| Empty (no origins, no wildcard) | Any | Pass through — no CORS headers added |
|
|
| Wildcard (`*`) | Non-OPTIONS | Add `Access-Control-Allow-Origin: *` to response |
|
|
| Wildcard (`*`) | OPTIONS preflight | Return `204` with `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers` |
|
|
| Specific origins | Non-OPTIONS, Origin matches | Echo origin back as `Access-Control-Allow-Origin`, add `Vary: Origin` |
|
|
| Specific origins | Non-OPTIONS, Origin no match | Pass through — no CORS headers |
|
|
| Specific origins | OPTIONS preflight, Origin matches | Return `204` with echoed origin, `Vary: Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers` |
|
|
| Specific origins | OPTIONS preflight, Origin no match | Return `204` with no CORS headers |
|
|
|
|
Allowed methods: `GET, POST, PUT, DELETE, OPTIONS`.
|
|
Allowed headers: `Content-Type, Authorization`.
|
|
|
|
The middleware is applied globally via `.layer(middleware::from_fn_with_state(state.clone(), cors_layer))` in `main.rs`, replacing the removed `build_cors()` call and static `CorsLayer`.
|
|
|
|
### `reload_cors` in `routes/crud.rs`
|
|
|
|
Called after any non-GET mutation to the `cors_origins` table, exactly mirroring `reload_blacklist`:
|
|
|
|
```rust
|
|
async fn reload_cors(state: &AppState) -> Result<(), StatusCode> {
|
|
// SELECT origin FROM cors_origins ORDER BY id
|
|
// state.cors_cache.load(origins).await
|
|
}
|
|
```
|
|
|
|
The existing `handle_crud` hook block gains a `cors_origins` branch alongside the `blacklist` branch.
|
|
|
|
---
|
|
|
|
## Admin Interface
|
|
|
|
### `Cors.vue`
|
|
|
|
New Vue view at `ui/src/views/admin/Cors.vue`:
|
|
|
|
- **Header:** "CORS Origins" with subtitle "Allowed cross-origin request sources — matched against the request Origin header"
|
|
- **Table card:** lists all origins with `id`, `origin` (monospace), `created_at`, and a `Delete` button per row
|
|
- **Create dialog:** single `Origin` text input with placeholder `https://app.example.com` or `*`, and an `Add Origin` submit button
|
|
- **Empty state:** icon + "No origins configured — cross-origin requests will be rejected" label + hint
|
|
- **API calls:** `GET /api/cors_origins` (list), `POST /api/cors_origins` (create), `DELETE /api/cors_origins/:id` (remove)
|
|
|
|
### Router & Navigation
|
|
|
|
- Route added to `ui/src/router/index.ts`: `{ path: 'cors', component: () => import('../views/admin/Cors.vue') }`
|
|
- Nav item added to `Layout.vue` `navItems`: `{ to: '/admin/cors', label: 'CORS', icon: '✦' }`
|
|
|
|
---
|
|
|
|
## Loading State (All Admin Views)
|
|
|
|
A consistent loading indicator is added to every admin view (`Queries.vue`, `Tables.vue`, `Users.vue`, `Permissions.vue`, `Blacklist.vue`, `ApiKeys.vue`, `Cache.vue`, and the new `Cors.vue`).
|
|
|
|
### Pattern
|
|
|
|
Each view gains a `loading` ref:
|
|
|
|
```ts
|
|
const loading = ref(false)
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
// existing fetch logic
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
```
|
|
|
|
Mutating operations (`submitCreate`, `submitEdit`, `deleteEntry`, etc.) also set `loading = true` for their duration.
|
|
|
|
### Shared CSS Spinner
|
|
|
|
A `.spinner` keyframe animation added once to `main.css`:
|
|
|
|
```css
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.loading-spinner {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--primary);
|
|
border-radius: 50%;
|
|
animation: spin 0.7s linear infinite;
|
|
}
|
|
|
|
.loading-overlay {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.6rem;
|
|
padding: 3rem 2rem;
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
font-family: var(--font-sans);
|
|
}
|
|
```
|
|
|
|
Each view shows a `<div class="loading-overlay"><div class="loading-spinner"></div><span>Loading…</span></div>` in place of the table/content when `loading` is true.
|
|
|
|
---
|
|
|
|
## Files Changed
|
|
|
|
| File | Change |
|
|
|---|---|
|
|
| `src/db/migrations/007_cors_origins.sql` | New migration |
|
|
| `src/state.rs` | Add `CorsCache`, `CorsState`; add `cors_cache` to `AppState` |
|
|
| `src/auth/middleware.rs` | Add `cors_layer` middleware |
|
|
| `src/routes/crud.rs` | Add `reload_cors`, hook into `handle_crud` |
|
|
| `src/main.rs` | Remove `build_cors`, remove static `CorsLayer`, seed `cors_origins` from env on startup, apply `cors_layer` |
|
|
| `ui/src/views/admin/Cors.vue` | New view |
|
|
| `ui/src/router/index.ts` | Add `/admin/cors` route |
|
|
| `ui/src/views/admin/Layout.vue` | Add CORS nav item |
|
|
| `ui/src/assets/main.css` | Add spinner CSS |
|
|
| `ui/src/views/admin/*.vue` (all 7 existing) | Add `loading` ref and spinner display |
|
|
|
|
---
|
|
|
|
## Out of Scope
|
|
|
|
- Credentials support (`Access-Control-Allow-Credentials`) — not currently used
|
|
- Per-origin method/header overrides — uniform allow-list for all origins
|
|
- Max-age preflight caching header — can be added later
|