diff --git a/.env.example b/.env.example index fe197b8..24e9e4b 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,14 @@ NEXT_PUBLIC_API_URL=http://localhost:8000 # this if you run your own self-hosted git server and want the button to # point at your fork instead of upstream. NEXT_PUBLIC_GIT_REPO_URL= +# "Open database viewer" link on the Settings page (local developers and +# server admins only). Access is gated by a short-lived token minted from a +# real admin login (see apps/api/app/api/v1/db_viewer.py), not a separate +# password. Blank by default - deliberately NOT defaulted the way +# NEXT_PUBLIC_GIT_REPO_URL is, since this points at your own deployment's +# private Adminer instance. Set to https://db.ciagent.org (or your own +# subdomain) once the db.ciagent.org Nginx block and DNS record exist. +NEXT_PUBLIC_DB_VIEWER_URL= # --- Reverse proxy (only relevant once deployed behind Cloudflare/Nginx) ----- # Empty = trust the direct connection for client-IP resolution (correct for diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4200190..75594f4 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -13,6 +13,7 @@ One Docker host runs everything via `docker-compose.prod.yml`: Postgres, Redis, | `ciagent.org` | `web:3000` | Next.js frontend | | `api.ciagent.org` | `api:8000` | FastAPI backend | | `git.ciagent.org` | `gitea:3000` | Self-hosted git (public read, admin-only write) | +| `db.ciagent.org` | `adminer:8080` | Database viewer (any admin account, no separate password - see §7) | Cloudflare's proxy (orange-cloud DNS) hides the origin's real IP and absorbs generic bot/volumetric traffic; the app's own IP-based throttle/ban system (`app/services/ip_throttle_service.py`) handles the business-logic-specific abuse cases Cloudflare can't know about. Both need `TRUSTED_PROXY_IP_HEADER=CF-Connecting-IP` set correctly or IP-based logic breaks — see `KNOWN_LIMITATIONS.md`. @@ -24,8 +25,8 @@ Cloudflare's proxy (orange-cloud DNS) hides the origin's real IP and absorbs gen ## 2. Cloudflare DNS + Origin CA certificate -1. Add three DNS **A records**, all proxied (orange cloud): `ciagent.org`, `api.ciagent.org`, `git.ciagent.org` → the server's public IP. -2. Cloudflare dashboard → SSL/TLS → **Origin Server** → Create Certificate. Cover `ciagent.org` and `*.ciagent.org` (one cert for all three subdomains), leave the default 15-year validity. Save the cert and private key. +1. Add four DNS **A records**, all proxied (orange cloud): `ciagent.org`, `api.ciagent.org`, `git.ciagent.org`, `db.ciagent.org` → the server's public IP. +2. Cloudflare dashboard → SSL/TLS → **Origin Server** → Create Certificate. Cover `ciagent.org` and `*.ciagent.org` (one cert for all four subdomains, including any added later), leave the default 15-year validity. Save the cert and private key. 3. On the server, create `/etc/ci-agent/certs/` (outside the repo, never committed) and place the two files there as `cloudflare-origin.pem` and `cloudflare-origin.key` — this is exactly what `docker-compose.prod.yml`'s `nginx` service mounts. 4. Cloudflare dashboard → SSL/TLS → set the encryption mode to **Full (strict)**. Anything less either skips origin verification or falls back to plaintext HTTP to the origin. @@ -59,6 +60,7 @@ FRONTEND_URL=https://ciagent.org BACKEND_URL=https://api.ciagent.org NEXT_PUBLIC_API_URL=https://api.ciagent.org NEXT_PUBLIC_GIT_REPO_URL=https://git.ciagent.org//ci-agent +NEXT_PUBLIC_DB_VIEWER_URL=https://db.ciagent.org TRUSTED_PROXY_IP_HEADER=CF-Connecting-IP RESEND_API_KEY= # no admin-UI equivalent for this one ``` @@ -97,7 +99,17 @@ Two ways to push, since Cloudflare's proxy only speaks HTTP(S) — raw SSH can't Read-only clone/browse works for anyone, no account: `https://git.ciagent.org//ci-agent.git` or the web UI directly. -## 7. Post-boot: configure provider API keys via the Settings UI, not `.env` +## 7. Database viewer (Adminer at db.ciagent.org) + +Unlike Gitea's admin account, there's no manual credential-handoff step for this one — any account with `is_admin=true` on the app itself can use it immediately, once `NEXT_PUBLIC_DB_VIEWER_URL` is set (§4) and the site is redeployed. Settings → Database → "Open database viewer" mints a short-lived token from the admin's real, live-checked login, which `db.ciagent.org`'s Nginx block (`infrastructure/nginx/nginx.conf`) exchanges for a signed session cookie via `apps/api/app/api/v1/db_viewer.py` — no separate password to generate, distribute, or rotate. + +A couple of things worth knowing: + +- Sessions last 60 minutes. Revoking someone's `is_admin` flag takes effect on their very next request through Nginx's `auth_request` check (it re-loads the user from the database each time), but an already-open Adminer tab isn't force-closed — it just stops being able to load anything new once that check runs again. +- Adminer's own Postgres login (username/password) is a second, independent layer past this gate — real DB credentials are still required to actually view or edit anything. +- An optional, commented-out IP-allowlist snippet is included in the `db.ciagent.org` Nginx block for admins with a static IP who want to require both the session *and* a matching source address — not enabled by default, since most admin connections don't have a stable IP to pin to. + +## 8. Post-boot: configure provider API keys via the Settings UI, not `.env` `scripts/bootstrap-env.sh` deliberately leaves these six blank. Sign in as the admin account and set them from the app itself: @@ -106,7 +118,7 @@ Read-only clone/browse works for anyone, no account: `https://git.ciagent.org/ Database viewer (Adminer, local-dev/admin only) + +- **This feature deliberately does not reuse the app's own JWT/admin session to gate access, because it can't.** The frontend's access token lives only in `window.localStorage` (`apps/web/lib/api-client.ts`), never a cookie, and is attached only as a JS-constructed `Authorization` header on this app's own `fetch()` calls - a plain browser navigation to a different subdomain (`db.ciagent.org`) carries none of that. Instead, `apps/api/app/api/v1/db_viewer.py` mints a short-lived bootstrap token from a live, `require_admin`-gated session, which Nginx's `db.ciagent.org` block exchanges for an independent, cookie-based session scoped to that subdomain only. Both token types reuse the same `JWT_SECRET`/`TokenType` machinery as real login tokens (`apps/api/app/core/security.py`) rather than introducing a second signing secret. +- **The `verify` endpoint re-loads the user and re-checks `is_admin` from the database on every request** (not just at token-mint time), so revoking someone's admin flag takes effect on their very next request through Nginx's `auth_request` - but a DB_VIEWER_SESSION cookie already issued is otherwise valid for its full 60-minute lifetime; there's no server-side session revocation list, only the live `is_admin` check and natural expiry. +- **Local dev's Adminer (`docker-compose.yml`, port `127.0.0.1:8081`) has no authentication of its own at all** - it relies entirely on the port being bound to loopback only, matching this app's existing "loopback is inherently trusted" philosophy elsewhere (e.g. the local-dev auth bypass itself). Anyone who can reach `localhost:8081` on that machine - including another local user account on a shared machine - has full Postgres access with no further gate. +- **Adminer itself has no read-only mode** - the feature was explicitly requested as "view and edit," so there's no additional restriction at the Adminer-config layer beyond Nginx's session gate (production) or loopback binding (local dev). The real Postgres username/password, required by Adminer's own login form, is the only remaining layer once past those. + Further limitations are appended per-phase below. diff --git a/apps/api/app/api/v1/db_viewer.py b/apps/api/app/api/v1/db_viewer.py new file mode 100644 index 0000000..4efb1c5 --- /dev/null +++ b/apps/api/app/api/v1/db_viewer.py @@ -0,0 +1,128 @@ +"""Settings -> Database viewer: lets any admin open a web-based Postgres +client (Adminer, at db.) without a separate, shared credential. + +The app's own JWT lives only in the browser's localStorage +(apps/web/lib/api-client.ts) - never a cookie, never sent on a plain +cross-subdomain navigation - so it can't gate a link to a different +subdomain the way an in-app API call is gated. Instead: an admin mints a +short-lived DB_VIEWER_BOOTSTRAP token from their real (live-checked) admin +session; Nginx's db. /_auth route exchanges that, one-time, for a +longer-lived DB_VIEWER_SESSION cookie scoped to that subdomain; Nginx's +`auth_request` then re-validates that cookie - including a fresh `is_admin` +check against the database - on every subsequent request. See +infrastructure/nginx/nginx.conf's db. server block and +DEPLOYMENT.md. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from fastapi.responses import RedirectResponse +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import require_admin +from app.core.config import Settings, get_settings +from app.core.security import ( + InvalidTokenError, + TokenType, + create_db_viewer_bootstrap_token, + create_db_viewer_session_token, + decode_token, + get_client_ip, +) +from app.db.session import get_db +from app.models.enums import SecurityEventType +from app.models.user import User +from app.repositories.user_repository import UserRepository +from app.repositories.user_security_event_repository import UserSecurityEventRepository + +router = APIRouter(prefix="/db-viewer", tags=["db-viewer"]) + +SESSION_COOKIE_NAME = "db_viewer_session" + + +class DbViewerSessionResponse(BaseModel): + token: str + + +@router.post("/session", response_model=DbViewerSessionResponse) +async def create_db_viewer_session( + request: Request, + settings: Settings = Depends(get_settings), + db: AsyncSession = Depends(get_db), + admin: User = Depends(require_admin), +) -> DbViewerSessionResponse: + """Mints a 2-minute bootstrap token the frontend embeds in a link to + https:///_auth?token=... - Nginx proxies that path + straight to `bootstrap` below, which exchanges it for a session cookie.""" + token = create_db_viewer_bootstrap_token(admin.id, settings) + await UserSecurityEventRepository(db).create( + user_id=admin.id, + event_type=SecurityEventType.DB_VIEWER_SESSION_CREATED, + ip_address=get_client_ip(request, settings), + ) + await db.commit() + return DbViewerSessionResponse(token=token) + + +@router.get("/bootstrap") +async def bootstrap_db_viewer_session( + token: str = Query(...), + settings: Settings = Depends(get_settings), +) -> RedirectResponse: + """Reached only via Nginx's db. /_auth location - never called + directly by the frontend. No `require_admin` dependency: the bootstrap + token itself, freshly minted by `create_db_viewer_session` above, is the + credential here.""" + try: + decoded = decode_token(token, settings, TokenType.DB_VIEWER_BOOTSTRAP) + except InvalidTokenError as exc: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired token" + ) from exc + + session_token = create_db_viewer_session_token(decoded.user_id, settings) + response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + response.set_cookie( + SESSION_COOKIE_NAME, + session_token, + max_age=60 * 60, + httponly=True, + secure=True, + samesite="lax", + # No `domain=` - defaults to the exact host (db.), not + # shared with ciagent.org/api.ciagent.org/git.ciagent.org. + ) + return response + + +@router.get("/verify") +async def verify_db_viewer_session( + request: Request, + settings: Settings = Depends(get_settings), + db: AsyncSession = Depends(get_db), +) -> Response: + """Reached only via Nginx's `auth_request` subrequest (internal `/_verify` + location) - carries just the forwarded Cookie header, no Authorization. + Re-checks `is_admin` fresh from the database on every call, not just at + token-mint time, so revoking an admin takes effect on their very next + request here - same live-check behavior as `require_admin` elsewhere.""" + token = request.cookies.get(SESSION_COOKIE_NAME) + if token is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No session") + + try: + decoded = decode_token(token, settings, TokenType.DB_VIEWER_SESSION) + except InvalidTokenError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session" + ) from exc + + user = await UserRepository(db).get_by_id(decoded.user_id) + if user is None or not user.is_admin: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin privileges required" + ) + + return Response(status_code=status.HTTP_200_OK) diff --git a/apps/api/app/api/v1/router.py b/apps/api/app/api/v1/router.py index 73930d3..be03270 100644 --- a/apps/api/app/api/v1/router.py +++ b/apps/api/app/api/v1/router.py @@ -11,6 +11,7 @@ from app.api.v1 import ( auth, companies, dashboard, + db_viewer, monitoring, notification_destinations, reports, @@ -24,6 +25,7 @@ api_v1_router = APIRouter(prefix="/api/v1") api_v1_router.include_router(system.router) api_v1_router.include_router(auth.router) api_v1_router.include_router(admin.router) +api_v1_router.include_router(db_viewer.router) api_v1_router.include_router(user_api_keys.router) api_v1_router.include_router(companies.router) api_v1_router.include_router(notification_destinations.router) diff --git a/apps/api/app/core/security.py b/apps/api/app/core/security.py index 2590114..e18ef2b 100644 --- a/apps/api/app/core/security.py +++ b/apps/api/app/core/security.py @@ -75,6 +75,11 @@ def verify_password(raw_password: str, password_hash: str) -> bool: class TokenType(StrEnum): ACCESS = "access" REFRESH = "refresh" + # Settings -> Database viewer: a short-lived token minted from a live + # admin session, exchanged (via Nginx's db.ciagent.org /_auth route) for + # a longer-lived DB_VIEWER_SESSION cookie. See app/api/v1/db_viewer.py. + DB_VIEWER_BOOTSTRAP = "db_viewer_bootstrap" + DB_VIEWER_SESSION = "db_viewer_session" @dataclass(frozen=True) @@ -105,6 +110,14 @@ def create_refresh_token(user_id: uuid.UUID, settings: Settings) -> tuple[str, s return token, jti, expires_at +def create_db_viewer_bootstrap_token(user_id: uuid.UUID, settings: Settings) -> str: + return _encode_token(user_id, TokenType.DB_VIEWER_BOOTSTRAP, timedelta(minutes=2), settings) + + +def create_db_viewer_session_token(user_id: uuid.UUID, settings: Settings) -> str: + return _encode_token(user_id, TokenType.DB_VIEWER_SESSION, timedelta(minutes=60), settings) + + def _encode_token( user_id: uuid.UUID, token_type: TokenType, diff --git a/apps/api/app/models/enums.py b/apps/api/app/models/enums.py index a2eed64..0140132 100644 --- a/apps/api/app/models/enums.py +++ b/apps/api/app/models/enums.py @@ -153,6 +153,7 @@ class SecurityEventType(StrEnum): EMAIL_VERIFIED = "email_verified" SERVER_SECRET_UPDATED = "server_secret_updated" API_KEY_UPDATED = "api_key_updated" + DB_VIEWER_SESSION_CREATED = "db_viewer_session_created" class ApiKeyProvider(StrEnum): diff --git a/apps/api/tests/unit/test_db_viewer.py b/apps/api/tests/unit/test_db_viewer.py new file mode 100644 index 0000000..56d9826 --- /dev/null +++ b/apps/api/tests/unit/test_db_viewer.py @@ -0,0 +1,207 @@ +"""Settings -> Database viewer: mint (admin-gated) -> bootstrap (token -> +session cookie) -> verify (cookie -> live is_admin check) round trip. + +Cookies are extracted from Set-Cookie headers and passed explicitly on +follow-up requests rather than relying on TestClient's cookie jar - the +session cookie is marked Secure, and TestClient's base_url is plain http, +so a real cookie jar wouldn't resend it anyway.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta + +import jwt +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.repositories.user_repository import UserRepository + + +def _unique_email() -> str: + return f"user-{uuid.uuid4().hex[:12]}@example.com" + + +async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]: + email = f"admin-{uuid.uuid4().hex[:12]}@example.com" + client.post( + "/api/v1/auth/register", + json={"email": email, "password": "correct-horse-1", "display_name": "Admin"}, + ) + user = await UserRepository(db_session).get_by_email(email) + user.is_admin = True + await db_session.commit() + tokens = client.post( + "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} + ).json() + return {"Authorization": f"Bearer {tokens['access_token']}"} + + +async def _register_non_admin_and_login(client: TestClient) -> dict[str, str]: + email = _unique_email() + client.post( + "/api/v1/auth/register", + json={"email": email, "password": "correct-horse-1", "display_name": "T"}, + ) + tokens = client.post( + "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} + ).json() + return {"Authorization": f"Bearer {tokens['access_token']}"} + + +def _extract_cookie(set_cookie_header: str) -> tuple[str, str]: + first_pair = set_cookie_header.split(";", 1)[0] + name, value = first_pair.split("=", 1) + return name, value + + +# --- /db-viewer/session (mint) -------------------------------------------- + + +def test_create_session_requires_auth(client: TestClient): + resp = client.post("/api/v1/db-viewer/session") + assert resp.status_code == 401 + + +async def test_create_session_non_admin_forbidden(client: TestClient): + headers = await _register_non_admin_and_login(client) + resp = client.post("/api/v1/db-viewer/session", headers=headers) + assert resp.status_code == 403 + + +async def test_admin_can_mint_a_bootstrap_token(client: TestClient, db_session: AsyncSession): + headers = await _register_admin_and_login(client, db_session) + resp = client.post("/api/v1/db-viewer/session", headers=headers) + assert resp.status_code == 200 + assert resp.json()["token"] + + +async def test_minting_a_session_is_logged_to_the_admins_account_activity( + client: TestClient, db_session: AsyncSession +): + headers = await _register_admin_and_login(client, db_session) + client.post("/api/v1/db-viewer/session", headers=headers) + + events = client.get("/api/v1/auth/security-events", headers=headers).json() + assert any(e["event_type"] == "db_viewer_session_created" for e in events) + + +# --- /db-viewer/bootstrap (token -> cookie) ------------------------------- + + +async def test_bootstrap_with_a_valid_token_sets_a_session_cookie_and_redirects( + client: TestClient, db_session: AsyncSession +): + headers = await _register_admin_and_login(client, db_session) + token = client.post("/api/v1/db-viewer/session", headers=headers).json()["token"] + + resp = client.get( + "/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False + ) + assert resp.status_code == 302 + assert resp.headers["location"] == "/" + assert "db_viewer_session" in resp.headers["set-cookie"] + assert "HttpOnly" in resp.headers["set-cookie"] + assert "Secure" in resp.headers["set-cookie"] + + +def test_bootstrap_rejects_a_garbage_token(client: TestClient): + resp = client.get( + "/api/v1/db-viewer/bootstrap", params={"token": "not-a-real-token"}, follow_redirects=False + ) + assert resp.status_code == 403 + + +def test_bootstrap_rejects_an_expired_token(client: TestClient): + settings = get_settings() + now = datetime.now(UTC) + expired_token = jwt.encode( + { + "sub": str(uuid.uuid4()), + "type": "db_viewer_bootstrap", + "iat": now - timedelta(minutes=10), + "exp": now - timedelta(minutes=5), + "jti": "x", + }, + settings.jwt_secret, + algorithm="HS256", + ) + resp = client.get( + "/api/v1/db-viewer/bootstrap", params={"token": expired_token}, follow_redirects=False + ) + assert resp.status_code == 403 + + +def test_bootstrap_rejects_a_session_type_token_used_as_a_bootstrap_token(client: TestClient): + """Type confusion guard: a DB_VIEWER_SESSION token must not work as a + DB_VIEWER_BOOTSTRAP token, even though both are signed with the same + jwt_secret.""" + settings = get_settings() + now = datetime.now(UTC) + session_typed_token = jwt.encode( + { + "sub": str(uuid.uuid4()), + "type": "db_viewer_session", + "iat": now, + "exp": now + timedelta(minutes=2), + "jti": "x", + }, + settings.jwt_secret, + algorithm="HS256", + ) + resp = client.get( + "/api/v1/db-viewer/bootstrap", + params={"token": session_typed_token}, + follow_redirects=False, + ) + assert resp.status_code == 403 + + +# --- /db-viewer/verify (cookie -> live is_admin check) -------------------- + + +async def test_verify_succeeds_for_a_live_admin_with_a_valid_session_cookie( + client: TestClient, db_session: AsyncSession +): + headers = await _register_admin_and_login(client, db_session) + token = client.post("/api/v1/db-viewer/session", headers=headers).json()["token"] + bootstrap_resp = client.get( + "/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False + ) + cookie_name, cookie_value = _extract_cookie(bootstrap_resp.headers["set-cookie"]) + + verify_resp = client.get("/api/v1/db-viewer/verify", cookies={cookie_name: cookie_value}) + assert verify_resp.status_code == 200 + + +def test_verify_rejects_a_missing_cookie(client: TestClient): + resp = client.get("/api/v1/db-viewer/verify") + assert resp.status_code == 401 + + +async def test_verify_rejects_a_session_cookie_once_admin_is_revoked( + client: TestClient, db_session: AsyncSession +): + headers = await _register_admin_and_login(client, db_session) + token = client.post("/api/v1/db-viewer/session", headers=headers).json()["token"] + bootstrap_resp = client.get( + "/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False + ) + cookie_name, cookie_value = _extract_cookie(bootstrap_resp.headers["set-cookie"]) + + # Sanity: still works before revocation. + assert ( + client.get("/api/v1/db-viewer/verify", cookies={cookie_name: cookie_value}).status_code + == 200 + ) + + admin_email_resp = client.get("/api/v1/auth/me", headers=headers) + admin = await UserRepository(db_session).get_by_email(admin_email_resp.json()["email"]) + admin.is_admin = False + await db_session.commit() + + assert ( + client.get("/api/v1/db-viewer/verify", cookies={cookie_name: cookie_value}).status_code + == 401 + ) diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 038a21b..a080e26 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { Bell, CheckCircle2, + Database, KeyRound, Loader2, Mail, @@ -24,6 +25,7 @@ import { import { authErrorMessage } from "@/hooks/use-auth"; import { useAcceptUnbanRequest, + useCreateDbViewerSession, useCreateIpBan, useCurrentUser, useDeleteIpBan, @@ -48,6 +50,7 @@ import { FormField } from "@/components/ui/form-field"; import { Select } from "@/components/ui/select"; import { SystemSecretRow } from "@/components/ui/system-secret-row"; import { UserApiKeyRow } from "@/components/ui/user-api-key-row"; +import { isLocalConvenience } from "@/lib/auth"; import { formatDateTime } from "@/lib/format"; import { SEVERITY_LABELS, @@ -663,6 +666,60 @@ function IpBansBox() { ); } +function DatabaseViewerBox() { + const { data: systemStatus } = useSystemStatus(); + const createSession = useCreateDbViewerSession(); + const [error, setError] = useState(null); + const isLocal = systemStatus ? isLocalConvenience(systemStatus) : false; + const dbViewerUrl = process.env.NEXT_PUBLIC_DB_VIEWER_URL; + + const handleOpen = async () => { + setError(null); + try { + const { token } = await createSession.mutateAsync(); + window.open(`${dbViewerUrl}/_auth?token=${encodeURIComponent(token)}`, "_blank", "noopener,noreferrer"); + } catch { + setError("Couldn't open the database viewer. Try again."); + } + }; + + return ( +
+
+ Database +
+

+ Open a web-based Postgres client (Adminer) in a new tab to view and edit rows directly. + Local developers and server admins only. +

+ +
+ {isLocal ? ( + + Open database viewer + + ) : dbViewerUrl ? ( + + ) : ( +

Not configured for this deployment.

+ )} + {error &&

{error}

} +
+
+ ); +} + export default function SettingsPage() { const { data: user } = useCurrentUser(); const { data: systemStatus } = useSystemStatus(); @@ -759,6 +816,7 @@ export default function SettingsPage() { <> + )} diff --git a/apps/web/hooks/use-auth.ts b/apps/web/hooks/use-auth.ts index fc77910..9b19d8c 100644 --- a/apps/web/hooks/use-auth.ts +++ b/apps/web/hooks/use-auth.ts @@ -47,6 +47,12 @@ export function useSetSystemSecret() { }); } +export function useCreateDbViewerSession() { + return useMutation({ + mutationFn: () => api.createDbViewerSession(), + }); +} + export function useSystemLogs() { return useQuery({ queryKey: ["system-logs"], diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts index 09d71ef..c5506d5 100644 --- a/apps/web/lib/api-client.ts +++ b/apps/web/lib/api-client.ts @@ -11,6 +11,7 @@ import type { CompanyUpdatePayload, ConfirmPasswordResetPayload, DashboardAnalytics, + DbViewerSessionResponse, DiscoverCompanyRequest, DiscoveredCompanyProfile, IpBan, @@ -166,6 +167,9 @@ export const api = { listSystemSecrets: () => request("/api/v1/system/secrets"), + createDbViewerSession: () => + request("/api/v1/db-viewer/session", { method: "POST" }), + setSystemSecret: (key: string, payload: SetSystemSecretPayload) => request(`/api/v1/system/secrets/${key}`, { method: "PUT", diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 46a0b3e..d4e409e 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -29,6 +29,10 @@ export interface SystemSecretStatus { value: string | null; } +export interface DbViewerSessionResponse { + token: string; +} + export interface SetSystemSecretPayload { value: string; } diff --git a/apps/web/tests/settings-database-viewer.test.tsx b/apps/web/tests/settings-database-viewer.test.tsx new file mode 100644 index 0000000..41248d4 --- /dev/null +++ b/apps/web/tests/settings-database-viewer.test.tsx @@ -0,0 +1,100 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import SettingsPage from "@/app/(app)/settings/page"; +import { renderWithQueryClient } from "./test-utils"; + +const ADMIN_USER = { + id: "user-1", + email: "admin@example.com", + display_name: "Admin", + timezone: "America/New_York", + is_active: true, + is_admin: true, + auth_mode: "jwt", +}; + +function systemStatusBody(isLocalhost: boolean, authMode: "local" | "jwt") { + return { + app_env: "development", + auth_mode: authMode, + llm_provider: "mock", + search_provider: "mock", + sms_enabled: false, + sms_provider: "twilio", + ninjapear_configured: false, + ninjapear_credit_balance: null, + ninjapear_estimated_credits_per_company: null, + is_localhost: isLocalhost, + turnstile_site_key: null, + components: [], + }; +} + +/** Covers every endpoint SettingsPage's admin-gated boxes touch on mount - + * empty-list/no-op responses for everything except /system/status, which + * each test configures to drive the local-vs-remote branch under test. */ +function mockFetchImplementation(isLocalhost: boolean, authMode: "local" | "jwt" = "jwt") { + return vi.fn().mockImplementation((url: string, init?: RequestInit) => { + const path = url.replace("http://localhost:8000", ""); + const ok = (json: unknown, status = 200) => + Promise.resolve({ ok: true, status, json: async () => json }); + + if (path === "/api/v1/auth/me") return ok(ADMIN_USER); + if (path === "/api/v1/system/status") return ok(systemStatusBody(isLocalhost, authMode)); + if (path === "/api/v1/notification-destinations") return ok([]); + if (path === "/api/v1/companies") return ok([]); + if (path === "/api/v1/system/logs") return ok([]); + if (path === "/api/v1/user-api-keys") return ok([]); + if (path === "/api/v1/system/secrets") return ok([]); + if (path === "/api/v1/auth/security-events") return ok([]); + if (path === "/api/v1/admin/ip-bans") return ok([]); + if (path === "/api/v1/admin/unban-requests") return ok([]); + if (path === "/api/v1/db-viewer/session" && init?.method === "POST") { + return ok({ token: "mock-bootstrap-token" }); + } + return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) }); + }); +} + +beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_DB_VIEWER_URL", "https://db.ciagent.org"); +}); + +describe("Settings - Database viewer box", () => { + it("links straight to the local Adminer instance for the local-dev bypass account", async () => { + vi.stubGlobal("fetch", mockFetchImplementation(true, "local")); + renderWithQueryClient(); + + const link = await screen.findByRole("link", { name: /open database viewer/i }); + expect(link).toHaveAttribute("href", "http://localhost:8081"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("mints a session token and opens the configured remote viewer URL for a real admin", async () => { + vi.stubGlobal("fetch", mockFetchImplementation(false, "jwt")); + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const user = userEvent.setup(); + renderWithQueryClient(); + + const button = await screen.findByRole("button", { name: /open database viewer/i }); + await user.click(button); + + await waitFor(() => { + expect(openSpy).toHaveBeenCalledWith( + "https://db.ciagent.org/_auth?token=mock-bootstrap-token", + "_blank", + "noopener,noreferrer", + ); + }); + }); + + it("shows a not-configured message when NEXT_PUBLIC_DB_VIEWER_URL is unset for a remote admin", async () => { + vi.stubEnv("NEXT_PUBLIC_DB_VIEWER_URL", ""); + vi.stubGlobal("fetch", mockFetchImplementation(false, "jwt")); + renderWithQueryClient(); + + expect(await screen.findByText(/not configured for this deployment/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /open database viewer/i })).not.toBeInTheDocument(); + }); +}); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 646d73b..b053c9a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -106,6 +106,7 @@ services: args: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} NEXT_PUBLIC_GIT_REPO_URL: ${NEXT_PUBLIC_GIT_REPO_URL:-} + NEXT_PUBLIC_DB_VIEWER_URL: ${NEXT_PUBLIC_DB_VIEWER_URL:-} restart: unless-stopped depends_on: - api @@ -150,6 +151,19 @@ services: - "2222:22" logging: *default-logging + # Settings -> Database viewer: internal-only, reached through nginx's + # db.ciagent.org block (app/api/v1/db_viewer.py issues the credential - + # see that module's docstring). No host port published. + adminer: + image: adminer:4.8.1-standalone + restart: unless-stopped + environment: + ADMINER_DEFAULT_SERVER: postgres + depends_on: + postgres: + condition: service_healthy + logging: *default-logging + nginx: image: nginx:1.27-alpine restart: unless-stopped @@ -157,6 +171,7 @@ services: - web - api - gitea + - adminer volumes: - ./infrastructure/nginx/nginx.conf:/etc/nginx/nginx.conf:ro # Cloudflare Origin CA cert/key, generated once via the Cloudflare diff --git a/docker-compose.yml b/docker-compose.yml index 3462257..4f13c49 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -110,5 +110,18 @@ services: depends_on: - api + adminer: + image: adminer:4.8.1-standalone + restart: unless-stopped + environment: + ADMINER_DEFAULT_SERVER: postgres + ports: + # Loopback-only, matching this app's loopback-trust philosophy - not + # meant to be reachable from the LAN. See Settings -> Database. + - "127.0.0.1:8081:8080" + depends_on: + postgres: + condition: service_healthy + volumes: postgres-data: diff --git a/infrastructure/docker/web.Dockerfile.prod b/infrastructure/docker/web.Dockerfile.prod index 1490c1f..00988de 100644 --- a/infrastructure/docker/web.Dockerfile.prod +++ b/infrastructure/docker/web.Dockerfile.prod @@ -17,8 +17,10 @@ COPY apps/web ./ # *browser* will use, not a Docker-internal service name. See .env.example. ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_GIT_REPO_URL +ARG NEXT_PUBLIC_DB_VIEWER_URL ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} \ - NEXT_PUBLIC_GIT_REPO_URL=${NEXT_PUBLIC_GIT_REPO_URL} + NEXT_PUBLIC_GIT_REPO_URL=${NEXT_PUBLIC_GIT_REPO_URL} \ + NEXT_PUBLIC_DB_VIEWER_URL=${NEXT_PUBLIC_DB_VIEWER_URL} RUN npm run build diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf index 9336ea3..dca003f 100644 --- a/infrastructure/nginx/nginx.conf +++ b/infrastructure/nginx/nginx.conf @@ -28,7 +28,7 @@ http { # HTTPS at the edge, but the origin shouldn't 400 a direct :80 probe. server { listen 80; - server_name ciagent.org api.ciagent.org git.ciagent.org; + server_name ciagent.org api.ciagent.org git.ciagent.org db.ciagent.org; return 301 https://$host$request_uri; } @@ -79,4 +79,49 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } + + # Settings -> Database viewer (Adminer). Access isn't gated by a shared + # password - the app itself (app/api/v1/db_viewer.py) mints a short-lived + # token from a live admin session, which /_auth here exchanges for a + # session cookie that /_verify re-checks (including a fresh `is_admin` + # lookup) on every request via auth_request. See DEPLOYMENT.md. + server { + listen 443 ssl; + server_name db.ciagent.org; + + ssl_certificate /etc/nginx/certs/cloudflare-origin.pem; + ssl_certificate_key /etc/nginx/certs/cloudflare-origin.key; + + # Adminer renders a raw SQL/data editor - not meant to be framed or + # MIME-sniffed as another content type. + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + + # Optional extra hardening for admins with a static IP: uncomment + # and set your own address to additionally require it alongside a + # valid session (default `satisfy all` - both must pass, not either). + # allow 203.0.113.9; + # deny all; + + location = /_verify { + internal; + proxy_pass http://api:8000/api/v1/db-viewer/verify; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Cookie $http_cookie; + } + + location = /_auth { + proxy_pass http://api:8000/api/v1/db-viewer/bootstrap; + proxy_set_header Host $host; + } + + location / { + auth_request /_verify; + proxy_pass http://adminer:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + } }